mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-14 10:02:45 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbc0144309 | ||
|
|
2cb5d87247 | ||
|
|
dc292d7ede | ||
|
|
c7fcdbed61 | ||
|
|
d2197bf05e | ||
|
|
cf11735a2b | ||
|
|
871711d394 | ||
|
|
823d1a7835 | ||
|
|
2813eed184 | ||
|
|
05426daee0 | ||
|
|
b11078fd92 | ||
|
|
f0e52756ea | ||
|
|
00301c503a | ||
|
|
e746e008a0 | ||
|
|
897f6a9e5a |
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
|
||||
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
|
||||
{
|
||||
"documents": [LIST_OF_DOCUMENT_IDS],
|
||||
|
||||
+4
-3
@@ -587,9 +587,10 @@ documents from the web UI:
|
||||
- From the document list, select one or more documents and choose **Run workflow** from the "Actions" menu.
|
||||
- From an individual document, choose **Run workflow** from the "Actions" menu.
|
||||
|
||||
You will be asked which workflow to run. By default the workflow's trigger filters still apply, so documents that do
|
||||
not match are skipped. You can instead choose to **run on all selected documents**, which ignores the trigger filters
|
||||
and runs the workflow's actions on every selected document.
|
||||
You will be asked which workflow to run. From the document list, the workflow's trigger filters apply by default, so
|
||||
documents that do not match are skipped. You can choose to **run on all selected documents** to ignore the filters.
|
||||
From an individual document, the option to ignore trigger filters is selected by default. Clear it to run the workflow
|
||||
only if the document matches its trigger filters.
|
||||
|
||||
Manual runs happen in the background. As with scheduled workflows, the document list is not refreshed automatically when
|
||||
they finish, so you may need to reload the list to see the results.
|
||||
|
||||
+34
-20
@@ -1366,7 +1366,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1971</context>
|
||||
<context context-type="linenumber">1973</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1577733187050997705" datatype="html">
|
||||
@@ -3420,7 +3420,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1972</context>
|
||||
<context context-type="linenumber">1974</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@@ -4339,7 +4339,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2025</context>
|
||||
<context context-type="linenumber">2027</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6661109599266152398" datatype="html">
|
||||
@@ -4350,7 +4350,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2026</context>
|
||||
<context context-type="linenumber">2028</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5162686434580248853" datatype="html">
|
||||
@@ -4361,7 +4361,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2027</context>
|
||||
<context context-type="linenumber">2029</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6665634854532231106" datatype="html">
|
||||
@@ -4413,18 +4413,32 @@
|
||||
<context context-type="linenumber">13,14</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="261810320774538615" datatype="html">
|
||||
<source>Run on this document regardless of trigger filters</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context>
|
||||
<context context-type="linenumber">22,23</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2322522720854680528" datatype="html">
|
||||
<source>Ignore the workflow's trigger filters for this document.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context>
|
||||
<context context-type="linenumber">23,24</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4512110365422311821" datatype="html">
|
||||
<source>Run on all selected documents</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context>
|
||||
<context context-type="linenumber">21,22</context>
|
||||
<context context-type="linenumber">25,26</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6575910678008555513" datatype="html">
|
||||
<source>Ignore the workflow's trigger filters and run on every selected document.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component.html</context>
|
||||
<context context-type="linenumber">22,23</context>
|
||||
<context context-type="linenumber">26,27</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2509141182388535183" datatype="html">
|
||||
@@ -9304,7 +9318,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1607</context>
|
||||
<context context-type="linenumber">1609</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="838042392151392247" datatype="html">
|
||||
@@ -9329,81 +9343,81 @@
|
||||
<source>Workflow will run on "<x id="PH" equiv-text="this.document().title"/>" in the background.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1600</context>
|
||||
<context context-type="linenumber">1602</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6030453331794586802" datatype="html">
|
||||
<source>Error downloading document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1671</context>
|
||||
<context context-type="linenumber">1673</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4458954481601077369" datatype="html">
|
||||
<source>Page Fit</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1749</context>
|
||||
<context context-type="linenumber">1751</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4663705961777238777" datatype="html">
|
||||
<source>PDF edit operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">1992</context>
|
||||
<context context-type="linenumber">1994</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="9043972994040261999" datatype="html">
|
||||
<source>Error executing PDF edit operation</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2004</context>
|
||||
<context context-type="linenumber">2006</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6172690334763056188" datatype="html">
|
||||
<source>Please enter the current password before attempting to remove it.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2015</context>
|
||||
<context context-type="linenumber">2017</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="968660764814228922" datatype="html">
|
||||
<source>Password removal operation for "<x id="PH" equiv-text="this.document().title"/>" will begin in the background.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2049</context>
|
||||
<context context-type="linenumber">2051</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2282118435712883014" datatype="html">
|
||||
<source>Error executing password removal operation</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2063</context>
|
||||
<context context-type="linenumber">2065</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3740891324955700797" datatype="html">
|
||||
<source>Print failed.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2113</context>
|
||||
<context context-type="linenumber">2115</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6457245677384603573" datatype="html">
|
||||
<source>Error loading document for printing.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2122</context>
|
||||
<context context-type="linenumber">2124</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6085793215710522488" datatype="html">
|
||||
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2205</context>
|
||||
<context context-type="linenumber">2207</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">2211</context>
|
||||
<context context-type="linenumber">2213</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4958946940233632319" datatype="html">
|
||||
|
||||
@@ -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 { SearchService } from 'src/app/services/rest/search.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 { ToastService } from 'src/app/services/toast.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
@@ -198,14 +199,14 @@ describe('AppFrameComponent', () => {
|
||||
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 reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
|
||||
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
|
||||
const tasksReload = jest
|
||||
.spyOn(tasksService, 'reload')
|
||||
.mockImplementation(() => {})
|
||||
const success = jest.spyOn(toastService, 'showSuccess')
|
||||
const success = jest.spyOn(toastService, 'show')
|
||||
const refresh = jest
|
||||
.spyOn(openDocumentsService, 'refreshDocument')
|
||||
.mockImplementation(() => {})
|
||||
@@ -230,7 +231,12 @@ describe('AppFrameComponent', () => {
|
||||
expect(refresh).toHaveBeenCalledWith(document.id)
|
||||
expect(maybeRefreshSpy).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()
|
||||
bulkEvents.next({
|
||||
task_id: 'bulk-task',
|
||||
@@ -245,14 +251,14 @@ describe('AppFrameComponent', () => {
|
||||
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
|
||||
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
|
||||
jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
|
||||
const error = jest.spyOn(toastService, 'showError')
|
||||
const error = jest.spyOn(toastService, 'show')
|
||||
const success = jest.spyOn(toastService, 'showSuccess')
|
||||
for (const status of [
|
||||
PaperlessTaskStatus.Failure,
|
||||
PaperlessTaskStatus.Revoked,
|
||||
]) {
|
||||
bulkEvents.next({
|
||||
task_id: 'bulk-task',
|
||||
task_id: `${status}-task`,
|
||||
operation: 'set_permissions',
|
||||
status,
|
||||
error:
|
||||
@@ -262,11 +268,36 @@ describe('AppFrameComponent', () => {
|
||||
})
|
||||
}
|
||||
expect(reload).toHaveBeenCalledTimes(2)
|
||||
expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update')
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'Bulk operation revoked.',
|
||||
'Task expired before execution.'
|
||||
expect.objectContaining({
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
} from 'src/app/services/rest/remote-version.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.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 { ToastService } from 'src/app/services/toast.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
@@ -115,6 +116,7 @@ export class AppFrameComponent
|
||||
settingsService = inject(SettingsService)
|
||||
tasksService = inject(TasksService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly taskNotifications = inject(TaskNotificationService)
|
||||
private modalService = inject(NgbModal)
|
||||
permissionsService = inject(PermissionsService)
|
||||
private djangoMessagesService = inject(DjangoMessagesService)
|
||||
@@ -182,7 +184,7 @@ export class AppFrameComponent
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(({ status, error }) => {
|
||||
.subscribe((result) => {
|
||||
this.documentList.reload(null, false)
|
||||
this.documentList.reduceSelectionToFilter()
|
||||
// maybe someday we can optimize to only refresh affected documents
|
||||
@@ -198,16 +200,7 @@ export class AppFrameComponent
|
||||
) {
|
||||
this.tasksService.reload()
|
||||
}
|
||||
if (status === PaperlessTaskStatus.Success) {
|
||||
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.taskNotifications.complete(result)
|
||||
})
|
||||
|
||||
this.lastScrollY = window.scrollY
|
||||
|
||||
+7
-2
@@ -18,8 +18,13 @@
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="runWorkflowForce" [(ngModel)]="force" />
|
||||
<label class="form-check-label" for="runWorkflowForce" i18n>Run on all selected documents</label>
|
||||
<div class="form-text" i18n>Ignore the workflow's trigger filters and run on every selected document.</div>
|
||||
@if (singleDocument) {
|
||||
<label class="form-check-label" for="runWorkflowForce" i18n>Run on this document regardless of trigger filters</label>
|
||||
<div class="form-text" i18n>Ignore the workflow's trigger filters for this document.</div>
|
||||
} @else {
|
||||
<label class="form-check-label" for="runWorkflowForce" i18n>Run on all selected documents</label>
|
||||
<div class="form-text" i18n>Ignore the workflow's trigger filters and run on every selected document.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
+21
@@ -39,4 +39,25 @@ describe('RunWorkflowConfirmDialogComponent', () => {
|
||||
expect(options).toHaveLength(2)
|
||||
expect(options[1].textContent).toContain('Manual workflow')
|
||||
})
|
||||
|
||||
it('should describe the checkbox for a selection or a single document', async () => {
|
||||
const checkbox = fixture.nativeElement.querySelector('#runWorkflowForce')
|
||||
expect(checkbox.checked).toBe(false)
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Run on all selected documents'
|
||||
)
|
||||
|
||||
component.singleDocument = true
|
||||
component.force = true
|
||||
fixture.detectChanges()
|
||||
await fixture.whenStable()
|
||||
fixture.detectChanges()
|
||||
expect(checkbox.checked).toBe(true)
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Run on this document regardless of trigger filters'
|
||||
)
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
'Run on all selected documents'
|
||||
)
|
||||
})
|
||||
})
|
||||
+2
@@ -13,6 +13,8 @@ export class RunWorkflowConfirmDialogComponent extends ConfirmDialogComponent {
|
||||
|
||||
public workflowId: number = null
|
||||
|
||||
public singleDocument: boolean = false
|
||||
|
||||
// i.e. ignore the workflow's trigger filters
|
||||
public force: boolean = false
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
background-color: var(--pngx-toast-bg);
|
||||
color: var(--bs-body-color);
|
||||
overflow: hidden;
|
||||
transition: background-color .18s ease, border-color .18s ease;
|
||||
|
||||
&.toast-success {
|
||||
--pngx-toast-accent: var(--pngx-toast-success);
|
||||
@@ -23,6 +24,13 @@
|
||||
|
||||
.toast-icon {
|
||||
color: var(--pngx-toast-accent);
|
||||
transition: color .18s ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast, .toast-icon {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toast-content {
|
||||
|
||||
@@ -65,6 +65,36 @@ describe('ToastComponent', () => {
|
||||
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', () => {
|
||||
jest.useFakeTimers()
|
||||
const toast = { content: 'Info', delay: 3000 }
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
Output,
|
||||
SimpleChanges,
|
||||
ViewChild,
|
||||
inject,
|
||||
signal,
|
||||
@@ -34,7 +36,7 @@ import { Toast, ToastLevel } from 'src/app/services/toast.service'
|
||||
styleUrl: './toast.component.scss',
|
||||
providers: [DatePipe, CustomDatePipe],
|
||||
})
|
||||
export class ToastComponent implements OnInit {
|
||||
export class ToastComponent implements OnInit, OnChanges {
|
||||
private clipboard = inject(Clipboard)
|
||||
private customDatePipe = inject(CustomDatePipe)
|
||||
private destroyRef = inject(DestroyRef)
|
||||
@@ -69,6 +71,12 @@ export class ToastComponent implements OnInit {
|
||||
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 {
|
||||
return this.customDatePipe.transform(this.toast.timestamp, 'relative')
|
||||
}
|
||||
|
||||
@@ -68,9 +68,9 @@ describe('ToastsComponent', () => {
|
||||
toasts.forEach((t) => toastSubject.next(t))
|
||||
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])
|
||||
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', () => {
|
||||
|
||||
@@ -32,10 +32,11 @@ export class ToastsComponent {
|
||||
return
|
||||
}
|
||||
this.toasts.update((toasts) =>
|
||||
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
|
||||
0,
|
||||
MAX_VISIBLE_TOASTS
|
||||
)
|
||||
toasts.some((existing) => existing.id === toast.id)
|
||||
? toasts.map((existing) =>
|
||||
existing.id === toast.id ? toast : existing
|
||||
)
|
||||
: [toast, ...toasts].slice(0, MAX_VISIBLE_TOASTS)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1013,48 +1013,85 @@ 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()
|
||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||
reprocessSpy.mockReturnValue(of(true))
|
||||
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-1' }))
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
component.reprocess()
|
||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||
openModal.componentInstance.confirmClicked.next()
|
||||
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).not.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', () => {
|
||||
jest.useFakeTimers()
|
||||
initNormally()
|
||||
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||
bulkEditSpy.mockReturnValue(of(true))
|
||||
bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-1' }))
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
component.runWorkflow()
|
||||
expect(openModal.componentInstance.singleDocument).toBe(true)
|
||||
expect(openModal.componentInstance.force).toBe(true)
|
||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||
openModal.componentInstance.workflowId = 5
|
||||
openModal.componentInstance.force = true
|
||||
openModal.componentInstance.confirmClicked.next()
|
||||
expect(bulkEditSpy).toHaveBeenCalledWith(
|
||||
{ documents: [doc.id] },
|
||||
'run_workflow',
|
||||
{ workflow: 5, filter_documents: false }
|
||||
)
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
jest.advanceTimersByTime(400)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'Workflow queued.' })
|
||||
)
|
||||
expect(modalCloseSpy).toHaveBeenCalled()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should apply workflow filters when the single-document checkbox is cleared', () => {
|
||||
initNormally()
|
||||
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||
bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-2' }))
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
component.runWorkflow()
|
||||
openModal.componentInstance.workflowId = 5
|
||||
openModal.componentInstance.force = false
|
||||
openModal.componentInstance.confirmClicked.next()
|
||||
expect(bulkEditSpy).toHaveBeenCalledWith(
|
||||
{ documents: [doc.id] },
|
||||
'run_workflow',
|
||||
{ workflow: 5, filter_documents: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass remote OCR choice when reprocessing', () => {
|
||||
initNormally()
|
||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||
reprocessSpy.mockReturnValue(of(true))
|
||||
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-2' }))
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
component.reprocess()
|
||||
@@ -1983,10 +2020,12 @@ describe('DocumentDetailComponent', () => {
|
||||
})
|
||||
|
||||
it('should support pdf editor, handle error', () => {
|
||||
jest.useFakeTimers()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
const queuedToastSpy = jest.spyOn(toastService, 'show')
|
||||
initNormally()
|
||||
component.selectedVersionId.set(10)
|
||||
component.editPdf()
|
||||
@@ -2005,6 +2044,7 @@ describe('DocumentDetailComponent', () => {
|
||||
update_document: false,
|
||||
include_metadata: true,
|
||||
source_mode: 'explicit_selection',
|
||||
from_webui: true,
|
||||
})
|
||||
req.error(new ErrorEvent('failed'))
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
@@ -2017,13 +2057,21 @@ describe('DocumentDetailComponent', () => {
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/edit_pdf/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
req.flush({ result: 'OK', task_id: 'edit-pdf-1' })
|
||||
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', () => {
|
||||
jest.useFakeTimers()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
||||
const queuedToastSpy = jest.spyOn(toastService, 'show')
|
||||
initNormally()
|
||||
component.selectedVersionId.set(10)
|
||||
component.password = 'secret'
|
||||
@@ -2044,8 +2092,15 @@ describe('DocumentDetailComponent', () => {
|
||||
include_metadata: false,
|
||||
delete_original: true,
|
||||
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', () => {
|
||||
@@ -2097,9 +2152,9 @@ describe('DocumentDetailComponent', () => {
|
||||
const req = httpTestingController.expectOne(
|
||||
`${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', () => {
|
||||
|
||||
@@ -97,6 +97,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.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 { TrashService } from 'src/app/services/trash.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
@@ -226,6 +227,7 @@ export class DocumentDetailComponent
|
||||
private documentListViewService = inject(DocumentListViewService)
|
||||
private documentTitlePipe = inject(DocumentTitlePipe)
|
||||
private toastService = inject(ToastService)
|
||||
private taskNotifications = inject(TaskNotificationService)
|
||||
private trashService = inject(TrashService)
|
||||
private settings = inject(SettingsService)
|
||||
private storagePathService = inject(StoragePathService)
|
||||
@@ -1558,9 +1560,11 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.remoteOcr
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Reprocess operation for "${this.document().title}" will begin in the background.`
|
||||
next: ({ task_id }) => {
|
||||
this.taskNotifications.showQueued(
|
||||
task_id,
|
||||
$localize`Document reprocessing queued.`,
|
||||
{ content: $localize`Document reprocessing completed.` }
|
||||
)
|
||||
if (modal) {
|
||||
modal.close()
|
||||
@@ -1587,6 +1591,8 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.message = $localize`Run a workflow on "${this.document().title}".`
|
||||
modal.componentInstance.btnCaption = $localize`Run`
|
||||
modal.componentInstance.workflows = this.manualWorkflows()
|
||||
modal.componentInstance.singleDocument = true
|
||||
modal.componentInstance.force = true
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.documentsService
|
||||
@@ -1595,9 +1601,13 @@ export class DocumentDetailComponent
|
||||
filter_documents: !modal.componentInstance.force,
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Workflow will run on "${this.document().title}" in the background.`
|
||||
next: ({ task_id }) => {
|
||||
this.taskNotifications.showQueued(
|
||||
task_id,
|
||||
$localize`Workflow queued.`,
|
||||
{
|
||||
content: $localize`Workflow completed.`,
|
||||
}
|
||||
)
|
||||
modal.close()
|
||||
},
|
||||
@@ -1987,14 +1997,15 @@ export class DocumentDetailComponent
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`PDF edit operation for "${this.document().title}" will begin in the background.`
|
||||
next: ({ task_id }) => {
|
||||
this.taskNotifications.showQueued(
|
||||
task_id,
|
||||
$localize`PDF edit queued.`,
|
||||
{
|
||||
content: $localize`PDF edit completed.`,
|
||||
}
|
||||
)
|
||||
modal.close()
|
||||
if (modal.componentInstance.deleteOriginal) {
|
||||
this.openDocumentService.closeDocument(this.document())
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
@@ -2044,17 +2055,14 @@ export class DocumentDetailComponent
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Password removal operation for "${this.document().title}" will begin in the background.`
|
||||
next: ({ task_id }) => {
|
||||
this.taskNotifications.showQueued(
|
||||
task_id,
|
||||
$localize`Password removal queued.`,
|
||||
{ content: $localize`Password removal completed.` }
|
||||
)
|
||||
this.networkActive.set(false)
|
||||
modal.close()
|
||||
if (!dialog.updateDocument && dialog.deleteOriginal) {
|
||||
this.openDocumentService.closeDocument(this.document())
|
||||
} else if (dialog.updateDocument) {
|
||||
this.openDocumentService.refreshDocument(this.documentId())
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
dialog.buttonsEnabled.set(true)
|
||||
|
||||
+23
-14
@@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common'
|
||||
import { SimpleChange, signal } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||
@@ -33,7 +34,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
>
|
||||
>
|
||||
let toastService: jest.Mocked<
|
||||
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
|
||||
Pick<ToastService, 'show' | 'showError' | 'showInfo' | 'showSuccess'>
|
||||
>
|
||||
let finished$: Subject<{ taskId: string }>
|
||||
let failed$: Subject<{ taskId: string; message?: string }>
|
||||
@@ -54,6 +55,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
}
|
||||
modalService = { open: jest.fn() }
|
||||
toastService = {
|
||||
show: jest.fn(),
|
||||
showError: jest.fn(),
|
||||
showInfo: jest.fn(),
|
||||
showSuccess: jest.fn(),
|
||||
@@ -88,6 +90,10 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
provide: PermissionsService,
|
||||
useValue: permissionsService,
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { navigate: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: WebsocketStatusService,
|
||||
useValue: {
|
||||
@@ -377,7 +383,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
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 modal = {
|
||||
componentInstance: {
|
||||
@@ -388,14 +395,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
close: jest.fn(),
|
||||
}
|
||||
modalService.open.mockReturnValue(modal as any)
|
||||
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
|
||||
// Newest first, as the API returns them. The merged document has a lower id
|
||||
// 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))
|
||||
documentService.mergeDocumentsAsVersions.mockReturnValue(
|
||||
of({ result: 'OK', task_id: 'versions-1' })
|
||||
)
|
||||
component.newVersionLabel = ' Imported '
|
||||
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
|
||||
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
|
||||
@@ -410,11 +412,18 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
'Imported'
|
||||
)
|
||||
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
|
||||
expect(documentService.getVersions).toHaveBeenCalledWith(3)
|
||||
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
||||
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
|
||||
expect(documentService.getVersions).not.toHaveBeenCalled()
|
||||
expect(versionsEmitSpy).not.toHaveBeenCalled()
|
||||
expect(selectedEmitSpy).not.toHaveBeenCalled()
|
||||
expect(component.newVersionLabel).toEqual('')
|
||||
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 { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
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 {
|
||||
UploadState,
|
||||
@@ -77,6 +78,7 @@ export class DocumentVersionDropdownComponent
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly taskNotifications = inject(TaskNotificationService)
|
||||
private readonly destroy$ = new Subject<void>()
|
||||
private readonly documentChange$ = new Subject<void>()
|
||||
|
||||
@@ -305,23 +307,19 @@ export class DocumentVersionDropdownComponent
|
||||
versionLabel
|
||||
)
|
||||
.pipe(
|
||||
switchMap(() => this.documentsService.getVersions(this.documentId)),
|
||||
first(),
|
||||
finalize(() => dialog.buttonsEnabled.set(true)),
|
||||
takeUntil(this.destroy$),
|
||||
takeUntil(this.documentChange$)
|
||||
)
|
||||
.subscribe({
|
||||
next: (document) => {
|
||||
if (document?.versions?.length) {
|
||||
this.versionsUpdated.emit(document.versions)
|
||||
// The API returns versions newest first
|
||||
this.versionSelected.emit(document.versions[0].id)
|
||||
}
|
||||
next: ({ task_id }) => {
|
||||
this.newVersionLabel = ''
|
||||
modal.close()
|
||||
this.toastService.showSuccess(
|
||||
$localize`Existing document added as a version.`
|
||||
this.taskNotifications.showQueued(
|
||||
task_id,
|
||||
$localize`Existing document queued to be added as a version.`,
|
||||
{ content: $localize`Existing document added as a version.` }
|
||||
)
|
||||
},
|
||||
error: (error) => {
|
||||
|
||||
@@ -699,6 +699,8 @@ describe('BulkEditorComponent', () => {
|
||||
component.runWorkflowSelected()
|
||||
expect(modal).not.toBeUndefined()
|
||||
expect(modal.componentInstance.workflows).toEqual(component.manualWorkflows)
|
||||
expect(modal.componentInstance.singleDocument).toBe(false)
|
||||
expect(modal.componentInstance.force).toBe(false)
|
||||
modal.componentInstance.workflowId = 5
|
||||
modal.componentInstance.force = true
|
||||
modal.componentInstance.confirm()
|
||||
@@ -1237,14 +1239,15 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/delete/`
|
||||
)
|
||||
const notifySpy = jest
|
||||
.spyOn(trashService, 'notifyMovedToTrash')
|
||||
const trackSpy = jest
|
||||
.spyOn(trashService, 'trackMovedToTrash')
|
||||
.mockImplementation(() => {})
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'delete-1' })
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
from_webui: true,
|
||||
})
|
||||
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4])
|
||||
expect(trackSpy).toHaveBeenCalledWith('delete-1', 2, [3, 4])
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
@@ -1261,9 +1264,9 @@ describe('BulkEditorComponent', () => {
|
||||
req = httpTestingController.expectOne(
|
||||
`${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
|
||||
expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null)
|
||||
expect(trackSpy).toHaveBeenLastCalledWith('delete-2', 0, null)
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
@@ -1343,10 +1346,11 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/reprocess/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'reprocess-1' })
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
remote_ocr: false,
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1377,11 +1381,12 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/rotate/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'rotate-1' })
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
degrees: 90,
|
||||
source_mode: 'latest_version',
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1419,10 +1424,11 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/merge/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'merge-1' })
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
metadata_document_id: 3,
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1437,11 +1443,12 @@ describe('BulkEditorComponent', () => {
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/merge/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'merge-2' })
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
metadata_document_id: 3,
|
||||
delete_originals: true,
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1458,11 +1465,12 @@ describe('BulkEditorComponent', () => {
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/merge/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'merge-3' })
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
metadata_document_id: 3,
|
||||
archive_fallback: true,
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1474,6 +1482,7 @@ describe('BulkEditorComponent', () => {
|
||||
})
|
||||
|
||||
it('should support merging documents as versions', () => {
|
||||
jest.useFakeTimers()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
@@ -1501,8 +1510,8 @@ describe('BulkEditorComponent', () => {
|
||||
.mockReturnValue(true)
|
||||
const mergeAsVersionsSpy = jest
|
||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||
.mockReturnValue(of(true))
|
||||
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
.mockReturnValue(of({ result: 'OK', task_id: 'versions-1' }))
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
fixture.detectChanges()
|
||||
|
||||
component.mergeSelectedAsVersions()
|
||||
@@ -1518,9 +1527,11 @@ describe('BulkEditorComponent', () => {
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
)
|
||||
expect(documentListViewService.selected.size).toEqual(0)
|
||||
expect(toastSuccessSpy).toHaveBeenCalledWith(
|
||||
'Documents merged as versions.'
|
||||
jest.advanceTimersByTime(400)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'Bulk operation queued.' })
|
||||
)
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should not report success when merging documents as versions fails', () => {
|
||||
@@ -1614,6 +1625,7 @@ describe('BulkEditorComponent', () => {
|
||||
it.each([true, false])(
|
||||
'shows queued feedback and gates the task link on permission (%s)',
|
||||
(canViewTasks) => {
|
||||
jest.useFakeTimers()
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
.mockReturnValue(canViewTasks)
|
||||
@@ -1634,6 +1646,8 @@ describe('BulkEditorComponent', () => {
|
||||
)
|
||||
req.flush({ result: 'OK', task_id: 'queued-operation' })
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
expect(queued).not.toHaveBeenCalled()
|
||||
jest.advanceTimersByTime(400)
|
||||
const notification = queued.mock.calls.at(-1)[0]
|
||||
expect(notification.content).toBe('Bulk operation queued.')
|
||||
if (canViewTasks) {
|
||||
@@ -1649,6 +1663,7 @@ describe('BulkEditorComponent', () => {
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
expect(documentListViewService.selected.size).toBe(0)
|
||||
expect(documentListViewService.allSelected).toBe(false)
|
||||
jest.useRealTimers()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -19,25 +19,18 @@ import {
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { saveAs } from 'file-saver'
|
||||
import {
|
||||
first,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
||||
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 { BulkOperationResponse } from 'src/app/data/bulk-operation'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
import { SelectionDataItem } from 'src/app/data/results'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
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 { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
@@ -52,12 +45,12 @@ import {
|
||||
DocumentService,
|
||||
MergeDocumentsRequest,
|
||||
} 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 { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.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 { TaskNotificationService } from 'src/app/services/task-notification.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { TrashService } from 'src/app/services/trash.service'
|
||||
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 { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-bulk-editor',
|
||||
@@ -110,13 +102,12 @@ export class BulkEditorComponent
|
||||
private documentService = inject(DocumentService)
|
||||
private trashService = inject(TrashService)
|
||||
private modalService = inject(NgbModal)
|
||||
private openDocumentService = inject(OpenDocumentsService)
|
||||
private settings = inject(SettingsService)
|
||||
private toastService = inject(ToastService)
|
||||
private taskNotifications = inject(TaskNotificationService)
|
||||
private storagePathService = inject(StoragePathService)
|
||||
private customFieldService = inject(CustomFieldsService)
|
||||
public readonly permissionService = inject(PermissionsService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||
private readonly workflowService = inject(WorkflowService)
|
||||
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()
|
||||
this.list.selectNone()
|
||||
const canViewTasks = this.permissionService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.PaperlessTask
|
||||
)
|
||||
this.toastService.show({
|
||||
content: $localize`Bulk operation queued.`,
|
||||
delay: 5000,
|
||||
...(canViewTasks
|
||||
? {
|
||||
actionName: $localize`View task`,
|
||||
action: () =>
|
||||
this.router.navigate(['/tasks'], {
|
||||
queryParams: { task_id: taskId },
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
if (onQueued) {
|
||||
onQueued(taskId)
|
||||
} else {
|
||||
this.taskNotifications.showQueued(
|
||||
taskId,
|
||||
$localize`Bulk operation queued.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private executeDocumentAction(
|
||||
modal: NgbModalRef,
|
||||
request: Observable<any>,
|
||||
options: { clearSelection?: boolean; successMessage?: string } = {}
|
||||
request: Observable<BulkOperationResponse>,
|
||||
onQueued?: (taskId: string) => void
|
||||
) {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
}
|
||||
request.pipe(first()).subscribe({
|
||||
next: () => {
|
||||
this.handleOperationSuccess(modal, options.clearSelection ?? false)
|
||||
if (options.successMessage) {
|
||||
this.toastService.showSuccess(options.successMessage)
|
||||
}
|
||||
next: ({ task_id }) => {
|
||||
this.handleOperationQueued(modal, task_id, onQueued)
|
||||
},
|
||||
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) {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
@@ -973,16 +938,8 @@ export class BulkEditorComponent
|
||||
const count = this.getSelectionSize()
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService
|
||||
.deleteDocuments(this.getSelectionQuery())
|
||||
.pipe(
|
||||
tap(() =>
|
||||
this.trashService.notifyMovedToTrash(
|
||||
$localize`Moved ${count} document(s) to the trash`,
|
||||
documents
|
||||
)
|
||||
)
|
||||
)
|
||||
this.documentService.deleteDocuments(this.getSelectionQuery()),
|
||||
(taskId) => this.trashService.trackMovedToTrash(taskId, count, documents)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1096,11 +1053,7 @@ export class BulkEditorComponent
|
||||
mergeDialog.buttonsEnabled.set(false)
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
|
||||
{ clearSelection: !!args.delete_originals }
|
||||
)
|
||||
this.toastService.showInfo(
|
||||
$localize`Merged document will be queued for consumption.`
|
||||
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1125,11 +1078,7 @@ export class BulkEditorComponent
|
||||
this.documentService.mergeDocumentsAsVersions(
|
||||
mergeDialog.documentIDs(),
|
||||
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.body).toEqual({
|
||||
documents: ids,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -303,6 +304,7 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
remote_ocr: false,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -317,6 +319,7 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
remote_ocr: true,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -331,6 +334,7 @@ describe(`DocumentService`, () => {
|
||||
documents: ids,
|
||||
degrees: 90,
|
||||
source_mode: 'latest_version',
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -346,6 +350,7 @@ describe(`DocumentService`, () => {
|
||||
documents: ids,
|
||||
metadata_document_id: 1,
|
||||
delete_originals: true,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -359,6 +364,7 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
root_document_id: 2,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -374,6 +380,7 @@ describe(`DocumentService`, () => {
|
||||
documents: ids,
|
||||
root_document_id: 2,
|
||||
version_label: 'Imported',
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -388,6 +395,7 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
operations: [{ page: 1, rotate: 90, doc: 0 }],
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -403,6 +411,7 @@ describe(`DocumentService`, () => {
|
||||
documents: ids,
|
||||
password: 'secret',
|
||||
update_document: true,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -351,19 +351,24 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
}
|
||||
|
||||
deleteDocuments(selection: DocumentSelectionQuery) {
|
||||
return this.http.post(this.getResourceUrl(null, 'delete'), {
|
||||
...selection,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'delete'),
|
||||
{ ...selection, from_webui: true }
|
||||
)
|
||||
}
|
||||
|
||||
reprocessDocuments(
|
||||
selection: DocumentSelectionQuery,
|
||||
remoteOcr: boolean = false
|
||||
) {
|
||||
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
|
||||
...selection,
|
||||
remote_ocr: remoteOcr,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'reprocess'),
|
||||
{
|
||||
...selection,
|
||||
remote_ocr: remoteOcr,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
rotateDocuments(
|
||||
@@ -371,18 +376,26 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
degrees: number,
|
||||
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
|
||||
) {
|
||||
return this.http.post(this.getResourceUrl(null, 'rotate'), {
|
||||
...selection,
|
||||
degrees,
|
||||
source_mode: sourceMode,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'rotate'),
|
||||
{
|
||||
...selection,
|
||||
degrees,
|
||||
source_mode: sourceMode,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) {
|
||||
return this.http.post(this.getResourceUrl(null, 'merge'), {
|
||||
documents: ids,
|
||||
...request,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'merge'),
|
||||
{
|
||||
documents: ids,
|
||||
...request,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
mergeDocumentsAsVersions(
|
||||
@@ -390,28 +403,40 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
rootDocumentId: number,
|
||||
versionLabel?: string
|
||||
) {
|
||||
return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), {
|
||||
documents: ids,
|
||||
root_document_id: rootDocumentId,
|
||||
...(versionLabel ? { version_label: versionLabel } : {}),
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'merge_as_versions'),
|
||||
{
|
||||
documents: ids,
|
||||
root_document_id: rootDocumentId,
|
||||
...(versionLabel ? { version_label: versionLabel } : {}),
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
|
||||
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
|
||||
documents: ids,
|
||||
...request,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'edit_pdf'),
|
||||
{
|
||||
documents: ids,
|
||||
...request,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
removePasswordDocuments(
|
||||
ids: number[],
|
||||
request: RemovePasswordDocumentsRequest
|
||||
) {
|
||||
return this.http.post(this.getResourceUrl(null, 'remove_password'), {
|
||||
documents: ids,
|
||||
...request,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'remove_password'),
|
||||
{
|
||||
documents: ids,
|
||||
...request,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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', () => {
|
||||
const toast = {
|
||||
title: 'Title',
|
||||
|
||||
@@ -75,7 +75,14 @@ export class ToastService {
|
||||
if (persistent) {
|
||||
// the dropdown is open, so it's seen as it arrives
|
||||
toast.read = !!this._suppressPopupToasts
|
||||
this.toasts.unshift(toast)
|
||||
const index = this.toasts.findIndex(
|
||||
(existing) => existing.id === toast.id
|
||||
)
|
||||
if (index === -1) {
|
||||
this.toasts.unshift(toast)
|
||||
} else {
|
||||
this.toasts[index] = toast
|
||||
}
|
||||
this.toastsSubject.next(this.toasts)
|
||||
}
|
||||
// Popups are suppressed while the dropdown is open
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { PaperlessTaskStatus } from '../data/paperless-task'
|
||||
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'
|
||||
|
||||
describe('TrashService', () => {
|
||||
@@ -105,4 +107,52 @@ describe('TrashService', () => {
|
||||
expect(toast.content).toEqual('Moved 500 document(s) to the trash')
|
||||
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 { Results } from '../data/results'
|
||||
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({
|
||||
providedIn: 'root',
|
||||
@@ -14,6 +15,22 @@ export class TrashService {
|
||||
private http = inject(HttpClient)
|
||||
private toastService = inject(ToastService)
|
||||
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>> {
|
||||
const httpParams = new HttpParams().set('page', page.toString())
|
||||
@@ -43,14 +60,22 @@ export class TrashService {
|
||||
* Shows a toast with an Undo action when possible
|
||||
*/
|
||||
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
|
||||
this.toastService.show({
|
||||
return {
|
||||
content,
|
||||
delay: 10000,
|
||||
level: ToastLevel.Success,
|
||||
actionName: canUndo ? $localize`Undo` : undefined,
|
||||
action: canUndo ? () => this.undoMoveToTrash(documents) : undefined,
|
||||
})
|
||||
...(canUndo
|
||||
? {
|
||||
actionName: $localize`Undo`,
|
||||
action: () => this.undoMoveToTrash(documents),
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
)
|
||||
logger.error(f"Error deleting documents: {e!s}")
|
||||
raise
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -474,9 +475,9 @@ def run_workflow(
|
||||
"""
|
||||
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,
|
||||
workflow_id=workflow,
|
||||
filter_documents=filter_documents,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Queued document metadata edits."""
|
||||
"""Queued document bulk operations."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
@@ -12,6 +12,7 @@ from rest_framework.exceptions import PermissionDenied
|
||||
from documents import bulk_edit
|
||||
from documents.models import CustomFieldInstance
|
||||
from documents.models import Document
|
||||
from documents.models import PaperlessTask
|
||||
from documents.permissions import has_document_operation_permissions
|
||||
from documents.tasks import bulk_update_documents
|
||||
|
||||
@@ -25,7 +26,35 @@ MODIFIED_FIELDS = {
|
||||
"modify_custom_fields": "custom_fields",
|
||||
"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]:
|
||||
@@ -66,6 +95,7 @@ def execute_bulk_operation(
|
||||
document_ids: list[int],
|
||||
parameters: dict,
|
||||
user_id: int,
|
||||
trigger_source: str = PaperlessTask.TriggerSource.API_UPLOAD,
|
||||
) -> dict:
|
||||
if operation not in QUEUED_OPERATIONS:
|
||||
raise ValueError(f"Unsupported bulk operation: {operation}")
|
||||
@@ -79,6 +109,39 @@ def 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] = []
|
||||
modified_field = MODIFIED_FIELDS[operation]
|
||||
# Keep metadata writes and their audit entries together. Follow-up processing
|
||||
|
||||
@@ -1688,6 +1688,7 @@ class MergeDocumentsSerializer(DocumentListSerializer, SourceModeValidationMixin
|
||||
|
||||
class MergeDocumentsAsVersionsSerializer(DocumentListSerializer):
|
||||
root_document_id = serializers.IntegerField(required=True)
|
||||
from_webui = serializers.BooleanField(required=False, default=False)
|
||||
version_label = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
@@ -1801,11 +1802,12 @@ class RemovePasswordDocumentsSerializer(
|
||||
|
||||
|
||||
class DeleteDocumentsSerializer(DocumentSelectionSerializer):
|
||||
pass
|
||||
from_webui = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
||||
remote_ocr = serializers.BooleanField(required=False, default=False)
|
||||
from_webui = serializers.BooleanField(required=False, default=False)
|
||||
|
||||
|
||||
class BulkEditSerializer(
|
||||
|
||||
@@ -1206,6 +1206,8 @@ def before_task_publish_handler(
|
||||
"document_count": len(task_kwargs["document_ids"]),
|
||||
}
|
||||
owner_id = task_kwargs["user_id"]
|
||||
if task_kwargs["operation"] == "delete":
|
||||
task_type = PaperlessTask.TaskType.BULK_DELETE
|
||||
else:
|
||||
input_data = _extract_input_data(task_type, task_kwargs)
|
||||
owner_id = _extract_owner_id(task_type, task_kwargs)
|
||||
|
||||
@@ -698,10 +698,10 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
||||
def test_delete_documents_old_uuid_field(self, m) -> None:
|
||||
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]
|
||||
bulk_edit.delete(doc_ids)
|
||||
with self.assertLogs(level="WARNING") as cm:
|
||||
bulk_edit.delete(doc_ids)
|
||||
self.assertIn("possible incompatible database column", cm.output[0])
|
||||
with self.assertRaisesRegex(Exception, "Data too long for column"):
|
||||
bulk_edit.delete(doc_ids)
|
||||
self.assertIn("possible incompatible database column", cm.output[0])
|
||||
|
||||
|
||||
class TestPDFActions(DirectoriesMixin, TestCase):
|
||||
|
||||
@@ -54,6 +54,7 @@ def test_request_only_queues(bulk_setup, endpoint, operation, parameters):
|
||||
"document_ids": [doc.pk],
|
||||
"parameters": parameters,
|
||||
"user_id": user.pk,
|
||||
"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
|
||||
|
||||
|
||||
@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
|
||||
def test_queue_failure_does_not_change_documents(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}
|
||||
|
||||
|
||||
@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
|
||||
def test_large_filtered_selection_is_one_task(bulk_setup):
|
||||
_, client, _, _ = bulk_setup
|
||||
@@ -284,24 +382,46 @@ async def test_completion_socket_is_private(anyio_backend):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("endpoint", ["delete", "bulk_edit"])
|
||||
def test_trash_remains_synchronous(bulk_setup, endpoint):
|
||||
_, client, doc, _ = bulk_setup
|
||||
data = {"documents": [doc.pk]}
|
||||
if endpoint == "bulk_edit":
|
||||
data.update(method="delete", parameters={})
|
||||
with (
|
||||
mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue,
|
||||
mock.patch("documents.search.get_backend"),
|
||||
):
|
||||
def test_legacy_trash_queues_without_deleting_in_request(bulk_setup):
|
||||
user, client, doc, _ = bulk_setup
|
||||
data = {"documents": [doc.pk], "method": "delete", "parameters": {}}
|
||||
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
|
||||
enqueue.return_value.id = "delete-task"
|
||||
response = client.post(
|
||||
f"/api/documents/{endpoint}/",
|
||||
"/api/documents/bulk_edit/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_ACCEPT="application/json; version=9",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"result": "OK"}
|
||||
assert not Document.objects.filter(pk=doc.pk).exists()
|
||||
assert Document.deleted_objects.filter(pk=doc.pk).exists()
|
||||
enqueue.assert_not_called()
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
@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 documents.bulk_edit import merge_as_versions
|
||||
from documents.bulk_operations import execute_bulk_operation
|
||||
from documents.models import Document
|
||||
from documents.serialisers import MergeDocumentsAsVersionsSerializer
|
||||
from paperless_testing.factories import UserFactory
|
||||
@@ -34,6 +35,7 @@ class TestMergeDocumentsAsVersionsSerializer(TestCase):
|
||||
{
|
||||
"documents": [self.doc1.id, self.doc2.id, self.doc3.id],
|
||||
"root_document_id": self.doc2.id,
|
||||
"from_webui": False,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -348,6 +350,17 @@ class TestMergeDocumentsAsVersionsAPI(APITestCase):
|
||||
owner=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")
|
||||
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.data, {"result": "OK"})
|
||||
self.assertEqual(
|
||||
response.data,
|
||||
{"result": "OK", "task_id": "test-bulk-operation"},
|
||||
)
|
||||
merge_mock.assert_called_once_with(
|
||||
[self.doc1.id, self.doc2.id],
|
||||
root_document_id=self.doc2.id,
|
||||
|
||||
+35
-81
@@ -107,9 +107,9 @@ from documents import bulk_edit
|
||||
from documents.bulk_download import ArchiveOnlyStrategy
|
||||
from documents.bulk_download import OriginalAndArchiveStrategy
|
||||
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 snapshot_field
|
||||
from documents.caching import get_llm_suggestion_cache
|
||||
from documents.caching import get_metadata_cache
|
||||
from documents.caching import get_suggestion_cache
|
||||
@@ -2974,38 +2974,30 @@ class DocumentSelectionMixin:
|
||||
class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
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):
|
||||
if "update_callback" in parameters:
|
||||
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(
|
||||
kwargs={
|
||||
"operation": method.__name__,
|
||||
"document_ids": documents,
|
||||
"parameters": parameters,
|
||||
"user_id": self.request.user.pk,
|
||||
"trigger_source": trigger_source,
|
||||
},
|
||||
headers={
|
||||
"trigger_source": (
|
||||
PaperlessTask.TriggerSource.WEB_UI
|
||||
if from_webui
|
||||
else PaperlessTask.TriggerSource.API_UPLOAD
|
||||
),
|
||||
},
|
||||
headers={"trigger_source": trigger_source},
|
||||
)
|
||||
return Response({"result": "OK", "task_id": task.id})
|
||||
|
||||
@@ -3129,15 +3121,6 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
user = self.request.user
|
||||
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(
|
||||
user=user,
|
||||
documents=documents,
|
||||
@@ -3147,8 +3130,12 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
try:
|
||||
result = method(documents, **parameters)
|
||||
return Response({"result": result})
|
||||
return self._queue_bulk_operation(
|
||||
method,
|
||||
documents,
|
||||
parameters,
|
||||
from_webui,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"An error occurred performing {operation_label}: {e!s}")
|
||||
return HttpResponseBadRequest(
|
||||
@@ -3169,19 +3156,13 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
|
||||
name="BulkEditDocumentsResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
"task_id": serializers.CharField(required=False),
|
||||
"task_id": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
class BulkEditView(DocumentOperationPermissionMixin):
|
||||
LEGACY_MODIFIED_FIELDS = {
|
||||
"delete": "deleted_at",
|
||||
"reprocess": "checksum",
|
||||
"remove_password": None,
|
||||
}
|
||||
|
||||
serializer_class = BulkEditSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
@@ -3211,14 +3192,6 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
||||
user=user,
|
||||
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(
|
||||
user=user,
|
||||
documents=documents,
|
||||
@@ -3228,38 +3201,12 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
||||
return HttpResponseForbidden("Insufficient permissions")
|
||||
|
||||
try:
|
||||
if method.__name__ in QUEUED_OPERATIONS:
|
||||
return self._queue_bulk_operation(
|
||||
method,
|
||||
documents,
|
||||
parameters,
|
||||
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})
|
||||
return self._queue_bulk_operation(
|
||||
method,
|
||||
documents,
|
||||
parameters,
|
||||
from_webui,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"An error occurred performing bulk edit: {e!s}")
|
||||
return HttpResponseBadRequest(
|
||||
@@ -3276,6 +3223,7 @@ class BulkEditView(DocumentOperationPermissionMixin):
|
||||
name="RotateDocumentsResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
"task_id": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -3303,6 +3251,7 @@ class RotateDocumentsView(DocumentOperationPermissionMixin):
|
||||
name="MergeDocumentsResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
"task_id": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -3330,6 +3279,7 @@ class MergeDocumentsView(DocumentOperationPermissionMixin):
|
||||
name="MergeDocumentsAsVersionsResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
"task_id": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -3357,6 +3307,7 @@ class MergeDocumentsAsVersionsView(DocumentOperationPermissionMixin):
|
||||
name="DeleteDocumentsResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
"task_id": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -3384,6 +3335,7 @@ class DeleteDocumentsView(DocumentOperationPermissionMixin):
|
||||
name="ReprocessDocumentsResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
"task_id": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -3411,6 +3363,7 @@ class ReprocessDocumentsView(DocumentOperationPermissionMixin):
|
||||
name="EditPdfDocumentsResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
"task_id": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -3438,6 +3391,7 @@ class EditPdfDocumentsView(DocumentOperationPermissionMixin):
|
||||
name="RemovePasswordDocumentsResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
"task_id": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user