mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-14 08:32:19 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb5d87247 | ||
|
|
dc292d7ede | ||
|
|
c7fcdbed61 | ||
|
|
d2197bf05e | ||
|
|
cf11735a2b | ||
|
|
871711d394 | ||
|
|
823d1a7835 | ||
|
|
2813eed184 | ||
|
|
05426daee0 | ||
|
|
b11078fd92 | ||
|
|
f0e52756ea | ||
|
|
00301c503a | ||
|
|
e746e008a0 | ||
|
|
897f6a9e5a | ||
|
|
694619c33e | ||
|
|
1f2faaec86 | ||
|
|
6b15d625ed |
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.
|
||||
|
||||
+78
-28
@@ -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">
|
||||
@@ -3282,6 +3282,10 @@
|
||||
<context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
|
||||
<context context-type="linenumber">55,56</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
|
||||
<context context-type="linenumber">25,26</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.html</context>
|
||||
<context context-type="linenumber">31,32</context>
|
||||
@@ -3416,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>
|
||||
@@ -4335,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">
|
||||
@@ -4346,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">
|
||||
@@ -4357,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">
|
||||
@@ -4409,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">
|
||||
@@ -5763,6 +5781,10 @@
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">358,359</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
|
||||
<context context-type="linenumber">27,28</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8057014866157903311" datatype="html">
|
||||
<source>Path test failed</source>
|
||||
@@ -9296,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">
|
||||
@@ -9321,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">
|
||||
@@ -10830,50 +10852,78 @@
|
||||
<context context-type="linenumber">23</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1044349881182559852" datatype="html">
|
||||
<source>Enter note</source>
|
||||
<trans-unit id="4388879716045736175" datatype="html">
|
||||
<source>Note</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
|
||||
<context context-type="linenumber">5</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8039873301454218547" datatype="html">
|
||||
<source>Enter note (Markdown is supported)</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
|
||||
<context context-type="linenumber">5</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2713764221056344068" datatype="html">
|
||||
<source>Note preview</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
|
||||
<context context-type="linenumber">7</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7604794618690171820" datatype="html">
|
||||
<source>Nothing to preview</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
|
||||
<context context-type="linenumber">11,12</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7770536883443596194" datatype="html">
|
||||
<source> Please enter a note. </source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
|
||||
<context context-type="linenumber">7</context>
|
||||
<context context-type="linenumber">16,17</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3124852678467222928" datatype="html">
|
||||
<source>Note input mode</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
|
||||
<context context-type="linenumber">23</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8433732438274024544" datatype="html">
|
||||
<source>Add note</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
|
||||
<context context-type="linenumber">14,15</context>
|
||||
<context context-type="linenumber">29,30</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8428006099054244235" datatype="html">
|
||||
<source>Delete note</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
|
||||
<context context-type="linenumber">25</context>
|
||||
<context context-type="linenumber">46</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.html</context>
|
||||
<context context-type="linenumber">27,28</context>
|
||||
<context context-type="linenumber">48,49</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2054854290951060833" datatype="html">
|
||||
<source>Error saving note</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.ts</context>
|
||||
<context context-type="linenumber">83</context>
|
||||
<context context-type="linenumber">94</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4144411010137688084" datatype="html">
|
||||
<source>Error deleting note</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-notes/document-notes.component.ts</context>
|
||||
<context context-type="linenumber">97</context>
|
||||
<context context-type="linenumber">108</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7103632680753685326" datatype="html">
|
||||
|
||||
@@ -35,6 +35,7 @@ import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { TrashService } from 'src/app/services/trash.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
@@ -230,7 +231,7 @@ describe('AppFrameComponent', () => {
|
||||
expect(refresh).toHaveBeenCalledWith(document.id)
|
||||
expect(maybeRefreshSpy).toHaveBeenCalledTimes(1)
|
||||
expect(tasksReload).toHaveBeenCalledTimes(1)
|
||||
expect(success).toHaveBeenCalledWith('Bulk operation completed.')
|
||||
expect(success).toHaveBeenCalledWith('Operation completed.')
|
||||
fixture.destroy()
|
||||
bulkEvents.next({
|
||||
task_id: 'bulk-task',
|
||||
@@ -262,14 +263,33 @@ describe('AppFrameComponent', () => {
|
||||
})
|
||||
}
|
||||
expect(reload).toHaveBeenCalledTimes(2)
|
||||
expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update')
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'Bulk operation revoked.',
|
||||
'Operation failed. Some changes may already have been applied.',
|
||||
'Could not update'
|
||||
)
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'Operation revoked.',
|
||||
'Task expired before execution.'
|
||||
)
|
||||
expect(success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers the delete success toast to TrashService', () => {
|
||||
const success = jest.spyOn(toastService, 'showSuccess')
|
||||
const trashService = TestBed.inject(TrashService)
|
||||
const handleStatus = jest.spyOn(trashService, 'handleDeleteTaskStatus')
|
||||
bulkEvents.next({
|
||||
task_id: 'delete-1',
|
||||
operation: 'delete',
|
||||
status: PaperlessTaskStatus.Success,
|
||||
})
|
||||
expect(handleStatus).toHaveBeenCalledWith(
|
||||
'delete-1',
|
||||
PaperlessTaskStatus.Success
|
||||
)
|
||||
expect(success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should initialize the saved view service', () => {
|
||||
expect(savedViewSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -59,6 +59,7 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { TrashService } from 'src/app/services/trash.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
@@ -115,6 +116,7 @@ export class AppFrameComponent
|
||||
settingsService = inject(SettingsService)
|
||||
tasksService = inject(TasksService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly trashService = inject(TrashService)
|
||||
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(({ task_id, operation, status, error }) => {
|
||||
this.documentList.reload(null, false)
|
||||
this.documentList.reduceSelectionToFilter()
|
||||
// maybe someday we can optimize to only refresh affected documents
|
||||
@@ -198,13 +200,16 @@ export class AppFrameComponent
|
||||
) {
|
||||
this.tasksService.reload()
|
||||
}
|
||||
if (status === PaperlessTaskStatus.Success) {
|
||||
this.toastService.showSuccess($localize`Bulk operation completed.`)
|
||||
if (operation === 'delete') {
|
||||
this.trashService.handleDeleteTaskStatus(task_id, status)
|
||||
}
|
||||
if (status === PaperlessTaskStatus.Success && operation !== 'delete') {
|
||||
this.toastService.showSuccess($localize`Operation completed.`)
|
||||
} else if (status === PaperlessTaskStatus.Revoked) {
|
||||
this.toastService.showError($localize`Bulk operation revoked.`, error)
|
||||
} else {
|
||||
this.toastService.showError($localize`Operation revoked.`, error)
|
||||
} else if (status === PaperlessTaskStatus.Failure) {
|
||||
this.toastService.showError(
|
||||
$localize`Bulk operation failed. Some changes may already have been applied.`,
|
||||
$localize`Operation failed. Some changes may already have been applied.`,
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -1013,48 +1013,77 @@ describe('DocumentDetailComponent', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should support reprocess, confirm and close modal after started', () => {
|
||||
it('should queue reprocess, show the task link and close the modal', () => {
|
||||
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).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()
|
||||
})
|
||||
|
||||
it('should run a workflow on the document, confirm and close modal', () => {
|
||||
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()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'Workflow queued.' })
|
||||
)
|
||||
expect(modalCloseSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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()
|
||||
@@ -1987,6 +2016,7 @@ describe('DocumentDetailComponent', () => {
|
||||
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 +2035,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 +2048,18 @@ 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' })
|
||||
expect(closeSpy).not.toHaveBeenCalled()
|
||||
expect(queuedToastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'PDF edit queued.' })
|
||||
)
|
||||
})
|
||||
|
||||
it('should support removing password protection from pdfs', () => {
|
||||
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 +2080,13 @@ 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' })
|
||||
expect(closeSpy).not.toHaveBeenCalled()
|
||||
expect(queuedToastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'Password removal queued.' })
|
||||
)
|
||||
})
|
||||
|
||||
it('should require the current password before removing it', () => {
|
||||
@@ -2097,9 +2138,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', () => {
|
||||
|
||||
@@ -1558,9 +1558,10 @@ 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.notifyTaskQueued(
|
||||
task_id,
|
||||
$localize`Document reprocessing queued.`
|
||||
)
|
||||
if (modal) {
|
||||
modal.close()
|
||||
@@ -1587,6 +1588,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,10 +1598,8 @@ 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.notifyTaskQueued(task_id, $localize`Workflow queued.`)
|
||||
modal.close()
|
||||
},
|
||||
error: (error) => {
|
||||
@@ -1612,6 +1613,26 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
private notifyTaskQueued(taskId: string, content: string) {
|
||||
const canViewTasks = this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.PaperlessTask
|
||||
)
|
||||
this.toastService.show({
|
||||
content,
|
||||
delay: 5000,
|
||||
...(canViewTasks
|
||||
? {
|
||||
actionName: $localize`View task`,
|
||||
action: () =>
|
||||
this.router.navigate(['/tasks'], {
|
||||
queryParams: { task_id: taskId },
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
private getSelectedNonLatestVersionId(): number | null {
|
||||
const versions = this.document()?.versions ?? []
|
||||
if (!versions.length || !this.selectedVersionId()) {
|
||||
@@ -1987,14 +2008,9 @@ 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.notifyTaskQueued(task_id, $localize`PDF edit queued.`)
|
||||
modal.close()
|
||||
if (modal.componentInstance.deleteOriginal) {
|
||||
this.openDocumentService.closeDocument(this.document())
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
@@ -2044,17 +2060,13 @@ 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.notifyTaskQueued(
|
||||
task_id,
|
||||
$localize`Password removal queued.`
|
||||
)
|
||||
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)
|
||||
|
||||
+20
-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,7 @@ 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', () => {
|
||||
const confirmClicked = new Subject<number>()
|
||||
const modal = {
|
||||
componentInstance: {
|
||||
@@ -388,14 +394,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 +411,16 @@ 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()
|
||||
expect(toastService.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: 'Existing document queued to be added as a version.',
|
||||
actionName: 'View task',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
+25
-9
@@ -11,6 +11,7 @@ import {
|
||||
SimpleChanges,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { merge, of, Subject } from 'rxjs'
|
||||
import {
|
||||
@@ -27,6 +28,11 @@ import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon
|
||||
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 {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import {
|
||||
@@ -77,6 +83,8 @@ export class DocumentVersionDropdownComponent
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly router = inject(Router)
|
||||
private readonly permissionsService = inject(PermissionsService)
|
||||
private readonly destroy$ = new Subject<void>()
|
||||
private readonly documentChange$ = new Subject<void>()
|
||||
|
||||
@@ -305,24 +313,32 @@ 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.`
|
||||
const canViewTasks = this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.PaperlessTask
|
||||
)
|
||||
this.toastService.show({
|
||||
content: $localize`Existing document queued to be added as a version.`,
|
||||
delay: 5000,
|
||||
...(canViewTasks
|
||||
? {
|
||||
actionName: $localize`View task`,
|
||||
action: () =>
|
||||
this.router.navigate(['/tasks'], {
|
||||
queryParams: { task_id },
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
|
||||
@@ -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`
|
||||
@@ -1501,8 +1509,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,8 +1526,8 @@ 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.'
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'Bulk operation queued.' })
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -19,15 +19,8 @@ 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 { BulkOperationResponse } from 'src/app/data/bulk-operation'
|
||||
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 { CustomField } from 'src/app/data/custom-field'
|
||||
@@ -37,7 +30,6 @@ import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
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,7 +44,6 @@ 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'
|
||||
@@ -110,13 +101,11 @@ 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 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)
|
||||
@@ -371,41 +360,21 @@ export class BulkEditorComponent
|
||||
|
||||
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?.(task_id)
|
||||
},
|
||||
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 +942,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 +1057,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 +1082,7 @@ export class BulkEditorComponent
|
||||
this.documentService.mergeDocumentsAsVersions(
|
||||
mergeDialog.documentIDs(),
|
||||
mergeDialog.rootDocumentID()
|
||||
),
|
||||
{
|
||||
clearSelection: true,
|
||||
successMessage: $localize`Documents merged as versions.`,
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,11 +16,14 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.25em; }
|
||||
h2 { font-size: 1.15em; }
|
||||
h3 { font-size: 1.05em; }
|
||||
|
||||
table {
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th, td {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { TrashService } from './trash.service'
|
||||
@@ -105,4 +106,27 @@ 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)
|
||||
service.handleDeleteTaskStatus('other-delete', PaperlessTaskStatus.Success)
|
||||
expect(toasts).toHaveLength(0)
|
||||
service.handleDeleteTaskStatus('delete-1', PaperlessTaskStatus.Success)
|
||||
expect(toasts).toHaveLength(1)
|
||||
expect(toasts[0].actionName).toBe('Undo')
|
||||
})
|
||||
|
||||
it('handles completion before the queue response and does not offer Undo on failure', () => {
|
||||
let toasts: Toast[] = []
|
||||
toastService.getToasts().subscribe((t) => (toasts = [...t]))
|
||||
service.handleDeleteTaskStatus('delete-1', PaperlessTaskStatus.Success)
|
||||
service.trackMovedToTrash('delete-1', 1, [1])
|
||||
expect(toasts[0].actionName).toBe('Undo')
|
||||
service.trackMovedToTrash('delete-2', 1, [2])
|
||||
service.handleDeleteTaskStatus('delete-2', PaperlessTaskStatus.Failure)
|
||||
expect(toasts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { Injectable, inject } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { Document } from '../data/document'
|
||||
import { PaperlessTaskStatus } from '../data/paperless-task'
|
||||
import { Results } from '../data/results'
|
||||
import { DocumentListViewService } from './document-list-view.service'
|
||||
import { ToastLevel, ToastService } from './toast.service'
|
||||
@@ -14,6 +15,49 @@ export class TrashService {
|
||||
private http = inject(HttpClient)
|
||||
private toastService = inject(ToastService)
|
||||
private documentListViewService = inject(DocumentListViewService)
|
||||
private pendingMoves = new Map<
|
||||
string,
|
||||
{ count: number; documents?: number[] }
|
||||
>()
|
||||
private completedMoves = new Map<string, PaperlessTaskStatus>()
|
||||
|
||||
public trackMovedToTrash(
|
||||
taskId: string,
|
||||
count: number,
|
||||
documents?: number[]
|
||||
) {
|
||||
const status = this.completedMoves.get(taskId)
|
||||
if (status !== undefined) {
|
||||
this.completedMoves.delete(taskId)
|
||||
if (status === PaperlessTaskStatus.Success) {
|
||||
this.notifyMovedToTrash(
|
||||
$localize`Moved ${count} document(s) to the trash`,
|
||||
documents
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.pendingMoves.set(taskId, { count, documents })
|
||||
}
|
||||
}
|
||||
|
||||
public handleDeleteTaskStatus(taskId: string, status: PaperlessTaskStatus) {
|
||||
const move = this.pendingMoves.get(taskId)
|
||||
if (move) {
|
||||
this.pendingMoves.delete(taskId)
|
||||
if (status === PaperlessTaskStatus.Success) {
|
||||
this.notifyMovedToTrash(
|
||||
$localize`Moved ${move.count} document(s) to the trash`,
|
||||
move.documents
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// in case the task finishes fast
|
||||
this.completedMoves.set(taskId, status)
|
||||
if (this.completedMoves.size > 100) {
|
||||
this.completedMoves.delete(this.completedMoves.keys().next().value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getTrash(page: number = 1): Observable<Results<Document>> {
|
||||
const httpParams = new HttpParams().set('page', page.toString())
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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(),
|
||||
},
|
||||
),
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user