mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-28 03:46:06 -05:00 
			
		
		
		
	Compare commits
	
		
			8 Commits
		
	
	
		
			v2.15.3
			...
			feature-re
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | ec12e71487 | ||
|   | 62b470f691 | ||
|   | a2e4977201 | ||
|   | 0fcd69b739 | ||
|   | af1c64e969 | ||
|   | 85c661dff2 | ||
|   | 3a7eee2c2e | ||
|   | bc4d3925cc | 
| @@ -18,6 +18,7 @@ | |||||||
| # Paths and folders | # Paths and folders | ||||||
|  |  | ||||||
| #PAPERLESS_CONSUMPTION_DIR=../consume | #PAPERLESS_CONSUMPTION_DIR=../consume | ||||||
|  | #PAPERLESS_CONSUMPTION_FAILED_DIR=../consume/failed | ||||||
| #PAPERLESS_DATA_DIR=../data | #PAPERLESS_DATA_DIR=../data | ||||||
| #PAPERLESS_EMPTY_TRASH_DIR= | #PAPERLESS_EMPTY_TRASH_DIR= | ||||||
| #PAPERLESS_MEDIA_ROOT=../media | #PAPERLESS_MEDIA_ROOT=../media | ||||||
|   | |||||||
| @@ -1994,120 +1994,141 @@ | |||||||
|           <context context-type="linenumber">72</context> |           <context context-type="linenumber">72</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|  |       <trans-unit id="7934833136974560675" datatype="html"> | ||||||
|  |         <source>Retry</source> | ||||||
|  |         <context-group purpose="location"> | ||||||
|  |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> | ||||||
|  |           <context context-type="linenumber">86</context> | ||||||
|  |         </context-group> | ||||||
|  |       </trans-unit> | ||||||
|       <trans-unit id="1536087519743707362" datatype="html"> |       <trans-unit id="1536087519743707362" datatype="html"> | ||||||
|         <source>Dismiss</source> |         <source>Dismiss</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> | ||||||
|           <context context-type="linenumber">85</context> |           <context context-type="linenumber">90</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> | ||||||
|           <context context-type="linenumber">68</context> |           <context context-type="linenumber">71</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="2134950584701094962" datatype="html"> |       <trans-unit id="2134950584701094962" datatype="html"> | ||||||
|         <source>Open Document</source> |         <source>Open Document</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> | ||||||
|           <context context-type="linenumber">90</context> |           <context context-type="linenumber">95</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="428536141871853903" datatype="html"> |       <trans-unit id="428536141871853903" datatype="html"> | ||||||
|         <source>{VAR_PLURAL, plural, =1 {One <x id="INTERPOLATION"/> task} other {<x id="INTERPOLATION_1"/> total <x id="INTERPOLATION"/> tasks}}</source> |         <source>{VAR_PLURAL, plural, =1 {One <x id="INTERPOLATION"/> task} other {<x id="INTERPOLATION_1"/> total <x id="INTERPOLATION"/> tasks}}</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> | ||||||
|           <context context-type="linenumber">109</context> |           <context context-type="linenumber">114</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="1943508481059904274" datatype="html"> |       <trans-unit id="1943508481059904274" datatype="html"> | ||||||
|         <source> (<x id="INTERPOLATION" equiv-text="{{selectedTasks.size}}"/> selected)</source> |         <source> (<x id="INTERPOLATION" equiv-text="{{selectedTasks.size}}"/> selected)</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> | ||||||
|           <context context-type="linenumber">111</context> |           <context context-type="linenumber">116</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="5639839509673911668" datatype="html"> |       <trans-unit id="5639839509673911668" datatype="html"> | ||||||
|         <source>Failed<x id="START_BLOCK_IF" equiv-text="@if (tasksService.failedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-danger ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> |         <source>Failed<x id="START_BLOCK_IF" equiv-text="@if (tasksService.failedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-danger ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> | ||||||
|           <context context-type="linenumber">123,125</context> |           <context context-type="linenumber">128,130</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="8210778930307085868" datatype="html"> |       <trans-unit id="8210778930307085868" datatype="html"> | ||||||
|         <source>Complete<x id="START_BLOCK_IF" equiv-text="@if (tasksService.completedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.completedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> |         <source>Complete<x id="START_BLOCK_IF" equiv-text="@if (tasksService.completedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.completedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> | ||||||
|           <context context-type="linenumber">131,133</context> |           <context context-type="linenumber">136,138</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="3522801015717851360" datatype="html"> |       <trans-unit id="3522801015717851360" datatype="html"> | ||||||
|         <source>Started<x id="START_BLOCK_IF" equiv-text="@if (tasksService.startedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.startedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> |         <source>Started<x id="START_BLOCK_IF" equiv-text="@if (tasksService.startedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.startedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> | ||||||
|           <context context-type="linenumber">139,141</context> |           <context context-type="linenumber">144,146</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="2341807459308874922" datatype="html"> |       <trans-unit id="2341807459308874922" datatype="html"> | ||||||
|         <source>Queued<x id="START_BLOCK_IF" equiv-text="@if (tasksService.queuedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.queuedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> |         <source>Queued<x id="START_BLOCK_IF" equiv-text="@if (tasksService.queuedFileTasks.length > 0) {"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-secondary ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.queuedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_BLOCK_IF" equiv-text="}"/></source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context> | ||||||
|           <context context-type="linenumber">147,149</context> |           <context context-type="linenumber">152,154</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="5404910960991552159" datatype="html"> |       <trans-unit id="5404910960991552159" datatype="html"> | ||||||
|         <source>Dismiss selected</source> |         <source>Dismiss selected</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> | ||||||
|           <context context-type="linenumber">31</context> |           <context context-type="linenumber">33</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="8829078752502782653" datatype="html"> |       <trans-unit id="8829078752502782653" datatype="html"> | ||||||
|         <source>Dismiss all</source> |         <source>Dismiss all</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> | ||||||
|           <context context-type="linenumber">32</context> |           <context context-type="linenumber">34</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="1323591410517879795" datatype="html"> |       <trans-unit id="1323591410517879795" datatype="html"> | ||||||
|         <source>Confirm Dismiss All</source> |         <source>Confirm Dismiss All</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> | ||||||
|           <context context-type="linenumber">65</context> |           <context context-type="linenumber">68</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="4157200209636243740" datatype="html"> |       <trans-unit id="4157200209636243740" datatype="html"> | ||||||
|         <source>Dismiss all <x id="PH" equiv-text="tasks.size"/> tasks?</source> |         <source>Dismiss all <x id="PH" equiv-text="tasks.size"/> tasks?</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> | ||||||
|           <context context-type="linenumber">66</context> |           <context context-type="linenumber">69</context> | ||||||
|  |         </context-group> | ||||||
|  |       </trans-unit> | ||||||
|  |       <trans-unit id="7611027432301841688" datatype="html"> | ||||||
|  |         <source>Retrying task...</source> | ||||||
|  |         <context-group purpose="location"> | ||||||
|  |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> | ||||||
|  |           <context context-type="linenumber">92</context> | ||||||
|  |         </context-group> | ||||||
|  |       </trans-unit> | ||||||
|  |       <trans-unit id="5445438607105804721" datatype="html"> | ||||||
|  |         <source>Failed to retry task</source> | ||||||
|  |         <context-group purpose="location"> | ||||||
|  |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> | ||||||
|  |           <context context-type="linenumber">95</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="9011556615675272238" datatype="html"> |       <trans-unit id="9011556615675272238" datatype="html"> | ||||||
|         <source>queued</source> |         <source>queued</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> | ||||||
|           <context context-type="linenumber">135</context> |           <context context-type="linenumber">149</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="6415892379431855826" datatype="html"> |       <trans-unit id="6415892379431855826" datatype="html"> | ||||||
|         <source>started</source> |         <source>started</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> | ||||||
|           <context context-type="linenumber">137</context> |           <context context-type="linenumber">151</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="7510279840486540181" datatype="html"> |       <trans-unit id="7510279840486540181" datatype="html"> | ||||||
|         <source>completed</source> |         <source>completed</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> | ||||||
|           <context context-type="linenumber">139</context> |           <context context-type="linenumber">153</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="4083337005045748464" datatype="html"> |       <trans-unit id="4083337005045748464" datatype="html"> | ||||||
|         <source>failed</source> |         <source>failed</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> |           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context> | ||||||
|           <context context-type="linenumber">141</context> |           <context context-type="linenumber">155</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="3418677553313974490" datatype="html"> |       <trans-unit id="3418677553313974490" datatype="html"> | ||||||
|   | |||||||
| @@ -81,6 +81,9 @@ | |||||||
|           </td> |           </td> | ||||||
|           <td scope="row"> |           <td scope="row"> | ||||||
|             <div class="btn-group" role="group"> |             <div class="btn-group" role="group"> | ||||||
|  |               @if (task.status === PaperlessTaskStatus.Failed) { | ||||||
|  |                 <ng-container *ngTemplateOutlet="retryDropdown; context: { task: task }"></ng-container> | ||||||
|  |               } | ||||||
|               <button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }"> |               <button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }"> | ||||||
|                 <i-bs name="check"></i-bs> <ng-container i18n>Dismiss</ng-container> |                 <i-bs name="check"></i-bs> <ng-container i18n>Dismiss</ng-container> | ||||||
|               </button> |               </button> | ||||||
| @@ -153,3 +156,25 @@ | |||||||
|   </li> |   </li> | ||||||
| </ul> | </ul> | ||||||
| <div [ngbNavOutlet]="nav"></div> | <div [ngbNavOutlet]="nav"></div> | ||||||
|  |  | ||||||
|  | <ng-template #retryDropdown let-task="task"> | ||||||
|  |   <div ngbDropdown> | ||||||
|  |     <button class="btn btn-sm btn-outline-primary" (click)="$event.stopImmediatePropagation()" ngbDropdownToggle> | ||||||
|  |       <i-bs name="arrow-repeat"></i-bs> <ng-container i18n>Retry</ng-container> | ||||||
|  |     </button> | ||||||
|  |     <div ngbDropdownMenu class="shadow retry-dropdown"> | ||||||
|  |       <div class="p-2"> | ||||||
|  |         <ul class="list-group list-group-flush"> | ||||||
|  |             <li class="list-group-item small" i18n> | ||||||
|  |               <pngx-input-check [(ngModel)]="retryClean" i18n-title title="Attempt to clean pdf"></pngx-input-check> | ||||||
|  |             </li> | ||||||
|  |         </ul> | ||||||
|  |         <div class="d-flex justify-content-end"> | ||||||
|  |           <button class="btn btn-sm btn-outline-primary" (click)="retryTask(task); $event.stopPropagation();"> | ||||||
|  |             <ng-container i18n>Proceed</ng-container> | ||||||
|  |           </button> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </ng-template> | ||||||
|   | |||||||
| @@ -26,3 +26,7 @@ pre { | |||||||
|         max-width: 150px; |         max-width: 150px; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .retry-dropdown { | ||||||
|  |     width: 300px; | ||||||
|  | } | ||||||
|   | |||||||
| @@ -31,6 +31,9 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard' | |||||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||||
| import { FormsModule } from '@angular/forms' | import { FormsModule } from '@angular/forms' | ||||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||||
|  | import { ToastService } from 'src/app/services/toast.service' | ||||||
|  | import { of, throwError } from 'rxjs' | ||||||
|  | import { CheckComponent } from '../../common/input/check/check.component' | ||||||
|  |  | ||||||
| const tasks: PaperlessTask[] = [ | const tasks: PaperlessTask[] = [ | ||||||
|   { |   { | ||||||
| @@ -115,6 +118,7 @@ describe('TasksComponent', () => { | |||||||
|   let modalService: NgbModal |   let modalService: NgbModal | ||||||
|   let router: Router |   let router: Router | ||||||
|   let httpTestingController: HttpTestingController |   let httpTestingController: HttpTestingController | ||||||
|  |   let toastService: ToastService | ||||||
|   let reloadSpy |   let reloadSpy | ||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
| @@ -125,6 +129,7 @@ describe('TasksComponent', () => { | |||||||
|         IfPermissionsDirective, |         IfPermissionsDirective, | ||||||
|         CustomDatePipe, |         CustomDatePipe, | ||||||
|         ConfirmDialogComponent, |         ConfirmDialogComponent, | ||||||
|  |         CheckComponent, | ||||||
|       ], |       ], | ||||||
|       imports: [ |       imports: [ | ||||||
|         NgbModule, |         NgbModule, | ||||||
| @@ -152,6 +157,7 @@ describe('TasksComponent', () => { | |||||||
|     httpTestingController = TestBed.inject(HttpTestingController) |     httpTestingController = TestBed.inject(HttpTestingController) | ||||||
|     modalService = TestBed.inject(NgbModal) |     modalService = TestBed.inject(NgbModal) | ||||||
|     router = TestBed.inject(Router) |     router = TestBed.inject(Router) | ||||||
|  |     toastService = TestBed.inject(ToastService) | ||||||
|     fixture = TestBed.createComponent(TasksComponent) |     fixture = TestBed.createComponent(TasksComponent) | ||||||
|     component = fixture.componentInstance |     component = fixture.componentInstance | ||||||
|     jest.useFakeTimers() |     jest.useFakeTimers() | ||||||
| @@ -173,8 +179,10 @@ describe('TasksComponent', () => { | |||||||
|       `Failed${currentTasksLength}` |       `Failed${currentTasksLength}` | ||||||
|     ) |     ) | ||||||
|     expect( |     expect( | ||||||
|       fixture.debugElement.queryAll(By.css('table input[type="checkbox"]')) |       fixture.debugElement.queryAll( | ||||||
|     ).toHaveLength(currentTasksLength + 1) |         By.css('table td > .form-check input[type="checkbox"]') | ||||||
|  |       ) | ||||||
|  |     ).toHaveLength(currentTasksLength) | ||||||
|  |  | ||||||
|     currentTasksLength = tasks.filter( |     currentTasksLength = tasks.filter( | ||||||
|       (t) => t.status === PaperlessTaskStatus.Complete |       (t) => t.status === PaperlessTaskStatus.Complete | ||||||
| @@ -289,4 +297,20 @@ describe('TasksComponent', () => { | |||||||
|     jest.advanceTimersByTime(6000) |     jest.advanceTimersByTime(6000) | ||||||
|     expect(reloadSpy).toHaveBeenCalledTimes(2) |     expect(reloadSpy).toHaveBeenCalledTimes(2) | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|  |   it('should retry a task, show toast on error or success', () => { | ||||||
|  |     const retrySpy = jest.spyOn(tasksService, 'retryTask') | ||||||
|  |     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||||
|  |     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||||
|  |     retrySpy.mockReturnValueOnce(of({ task_id: '123' })) | ||||||
|  |     component.retryTask(tasks[0]) | ||||||
|  |     expect(retrySpy).toHaveBeenCalledWith(tasks[0], false) | ||||||
|  |     expect(toastInfoSpy).toHaveBeenCalledWith('Retrying task...') | ||||||
|  |     retrySpy.mockReturnValueOnce(throwError(() => new Error('test'))) | ||||||
|  |     component.retryTask(tasks[0]) | ||||||
|  |     expect(toastErrorSpy).toHaveBeenCalledWith( | ||||||
|  |       'Failed to retry task', | ||||||
|  |       new Error('test') | ||||||
|  |     ) | ||||||
|  |   }) | ||||||
| }) | }) | ||||||
|   | |||||||
| @@ -2,10 +2,11 @@ import { Component, OnInit, OnDestroy } from '@angular/core' | |||||||
| import { Router } from '@angular/router' | import { Router } from '@angular/router' | ||||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { first } from 'rxjs' | import { first } from 'rxjs' | ||||||
| import { PaperlessTask } from 'src/app/data/paperless-task' | import { PaperlessTask, PaperlessTaskStatus } from 'src/app/data/paperless-task' | ||||||
| import { TasksService } from 'src/app/services/tasks.service' | import { TasksService } from 'src/app/services/tasks.service' | ||||||
| import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' | ||||||
| import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' | import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' | ||||||
|  | import { ToastService } from 'src/app/services/toast.service' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'pngx-tasks', |   selector: 'pngx-tasks', | ||||||
| @@ -16,6 +17,7 @@ export class TasksComponent | |||||||
|   extends ComponentWithPermissions |   extends ComponentWithPermissions | ||||||
|   implements OnInit, OnDestroy |   implements OnInit, OnDestroy | ||||||
| { | { | ||||||
|  |   public PaperlessTaskStatus = PaperlessTaskStatus | ||||||
|   public activeTab: string |   public activeTab: string | ||||||
|   public selectedTasks: Set<number> = new Set() |   public selectedTasks: Set<number> = new Set() | ||||||
|   public togggleAll: boolean = false |   public togggleAll: boolean = false | ||||||
| @@ -26,6 +28,8 @@ export class TasksComponent | |||||||
|  |  | ||||||
|   public autoRefreshInterval: any |   public autoRefreshInterval: any | ||||||
|  |  | ||||||
|  |   public retryClean: boolean = false | ||||||
|  |  | ||||||
|   get dismissButtonText(): string { |   get dismissButtonText(): string { | ||||||
|     return this.selectedTasks.size > 0 |     return this.selectedTasks.size > 0 | ||||||
|       ? $localize`Dismiss selected` |       ? $localize`Dismiss selected` | ||||||
| @@ -35,6 +39,7 @@ export class TasksComponent | |||||||
|   constructor( |   constructor( | ||||||
|     public tasksService: TasksService, |     public tasksService: TasksService, | ||||||
|     private modalService: NgbModal, |     private modalService: NgbModal, | ||||||
|  |     private toastService: ToastService, | ||||||
|     private readonly router: Router |     private readonly router: Router | ||||||
|   ) { |   ) { | ||||||
|     super() |     super() | ||||||
| @@ -83,6 +88,17 @@ export class TasksComponent | |||||||
|     this.router.navigate(['documents', task.related_document]) |     this.router.navigate(['documents', task.related_document]) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   retryTask(task: PaperlessTask) { | ||||||
|  |     this.tasksService.retryTask(task, this.retryClean).subscribe({ | ||||||
|  |       next: () => { | ||||||
|  |         this.toastService.showInfo($localize`Retrying task...`) | ||||||
|  |       }, | ||||||
|  |       error: (e) => { | ||||||
|  |         this.toastService.showError($localize`Failed to retry task`, e) | ||||||
|  |       }, | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|   expandTask(task: PaperlessTask) { |   expandTask(task: PaperlessTask) { | ||||||
|     this.expandedTask = this.expandedTask == task.id ? undefined : task.id |     this.expandedTask = this.expandedTask == task.id ? undefined : task.id | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -118,4 +118,29 @@ describe('TasksService', () => { | |||||||
|     expect(tasksService.queuedFileTasks).toHaveLength(1) |     expect(tasksService.queuedFileTasks).toHaveLength(1) | ||||||
|     expect(tasksService.startedFileTasks).toHaveLength(1) |     expect(tasksService.startedFileTasks).toHaveLength(1) | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|  |   it('should call retry task api endpoint', () => { | ||||||
|  |     const task = { | ||||||
|  |       id: 1, | ||||||
|  |       type: PaperlessTaskType.File, | ||||||
|  |       status: PaperlessTaskStatus.Failed, | ||||||
|  |       acknowledged: false, | ||||||
|  |       task_id: '1234', | ||||||
|  |       task_file_name: 'file1.pdf', | ||||||
|  |       date_created: new Date(), | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     tasksService.retryTask(task, true).subscribe() | ||||||
|  |     const reloadSpy = jest.spyOn(tasksService, 'reload') | ||||||
|  |     const req = httpTestingController.expectOne( | ||||||
|  |       `${environment.apiBaseUrl}tasks/${task.id}/retry/` | ||||||
|  |     ) | ||||||
|  |     expect(req.request.method).toEqual('POST') | ||||||
|  |     expect(req.request.body).toEqual({ | ||||||
|  |       clean: true, | ||||||
|  |     }) | ||||||
|  |     req.flush({ task_id: 12345 }) | ||||||
|  |     expect(reloadSpy).toHaveBeenCalled() | ||||||
|  |     httpTestingController.expectOne(`${environment.apiBaseUrl}tasks/`).flush([]) | ||||||
|  |   }) | ||||||
| }) | }) | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import { HttpClient } from '@angular/common/http' | import { HttpClient } from '@angular/common/http' | ||||||
| import { Injectable } from '@angular/core' | import { Injectable } from '@angular/core' | ||||||
| import { Subject } from 'rxjs' | import { Observable, Subject } from 'rxjs' | ||||||
| import { first, takeUntil } from 'rxjs/operators' | import { first, takeUntil, tap } from 'rxjs/operators' | ||||||
| import { | import { | ||||||
|   PaperlessTask, |   PaperlessTask, | ||||||
|   PaperlessTaskStatus, |   PaperlessTaskStatus, | ||||||
| @@ -73,6 +73,20 @@ export class TasksService { | |||||||
|       }) |       }) | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   public retryTask(task: PaperlessTask, clean: boolean): Observable<any> { | ||||||
|  |     return this.http | ||||||
|  |       .post(`${this.baseUrl}tasks/${task.id}/retry/`, { | ||||||
|  |         clean, | ||||||
|  |       }) | ||||||
|  |       .pipe( | ||||||
|  |         takeUntil(this.unsubscribeNotifer), | ||||||
|  |         first(), | ||||||
|  |         tap(() => { | ||||||
|  |           this.reload() | ||||||
|  |         }) | ||||||
|  |       ) | ||||||
|  |   } | ||||||
|  |  | ||||||
|   public cancelPending(): void { |   public cancelPending(): void { | ||||||
|     this.unsubscribeNotifer.next(true) |     this.unsubscribeNotifer.next(true) | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -148,6 +148,17 @@ class ConsumerPlugin( | |||||||
|     ): |     ): | ||||||
|         self._send_progress(100, 100, ProgressStatusOptions.FAILED, message) |         self._send_progress(100, 100, ProgressStatusOptions.FAILED, message) | ||||||
|         self.log.error(log_message or message, exc_info=exc_info) |         self.log.error(log_message or message, exc_info=exc_info) | ||||||
|  |         # Move the file to the failed directory | ||||||
|  |         if ( | ||||||
|  |             self.input_doc.original_file.exists() | ||||||
|  |             and not Path( | ||||||
|  |                 settings.CONSUMPTION_FAILED_DIR / self.input_doc.original_file.name, | ||||||
|  |             ).exists() | ||||||
|  |         ): | ||||||
|  |             copy_file_with_basic_stats( | ||||||
|  |                 self.input_doc.original_file, | ||||||
|  |                 settings.CONSUMPTION_FAILED_DIR / self.input_doc.original_file.name, | ||||||
|  |             ) | ||||||
|         raise ConsumerError(f"{self.filename}: {log_message or message}") from exception |         raise ConsumerError(f"{self.filename}: {log_message or message}") from exception | ||||||
|  |  | ||||||
|     def pre_check_file_exists(self): |     def pre_check_file_exists(self): | ||||||
|   | |||||||
| @@ -679,24 +679,28 @@ class PaperlessTask(models.Model): | |||||||
|         verbose_name=_("Task State"), |         verbose_name=_("Task State"), | ||||||
|         help_text=_("Current state of the task being run"), |         help_text=_("Current state of the task being run"), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     date_created = models.DateTimeField( |     date_created = models.DateTimeField( | ||||||
|         null=True, |         null=True, | ||||||
|         default=timezone.now, |         default=timezone.now, | ||||||
|         verbose_name=_("Created DateTime"), |         verbose_name=_("Created DateTime"), | ||||||
|         help_text=_("Datetime field when the task result was created in UTC"), |         help_text=_("Datetime field when the task result was created in UTC"), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     date_started = models.DateTimeField( |     date_started = models.DateTimeField( | ||||||
|         null=True, |         null=True, | ||||||
|         default=None, |         default=None, | ||||||
|         verbose_name=_("Started DateTime"), |         verbose_name=_("Started DateTime"), | ||||||
|         help_text=_("Datetime field when the task was started in UTC"), |         help_text=_("Datetime field when the task was started in UTC"), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     date_done = models.DateTimeField( |     date_done = models.DateTimeField( | ||||||
|         null=True, |         null=True, | ||||||
|         default=None, |         default=None, | ||||||
|         verbose_name=_("Completed DateTime"), |         verbose_name=_("Completed DateTime"), | ||||||
|         help_text=_("Datetime field when the task was completed in UTC"), |         help_text=_("Datetime field when the task was completed in UTC"), | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     result = models.TextField( |     result = models.TextField( | ||||||
|         null=True, |         null=True, | ||||||
|         default=None, |         default=None, | ||||||
|   | |||||||
| @@ -1585,6 +1585,14 @@ class TasksViewSerializer(serializers.ModelSerializer): | |||||||
|         return result |         return result | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class RetryTaskSerializer(serializers.Serializer): | ||||||
|  |     clean = serializers.BooleanField( | ||||||
|  |         default=False, | ||||||
|  |         write_only=True, | ||||||
|  |         required=False, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AcknowledgeTasksViewSerializer(serializers.Serializer): | class AcknowledgeTasksViewSerializer(serializers.Serializer): | ||||||
|     tasks = serializers.ListField( |     tasks = serializers.ListField( | ||||||
|         required=True, |         required=True, | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import logging | import logging | ||||||
| import os | import os | ||||||
| import shutil | import shutil | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
| from celery import states | from celery import states | ||||||
| from celery.signals import before_task_publish | from celery.signals import before_task_publish | ||||||
| @@ -520,6 +521,19 @@ def update_filename_and_move_files( | |||||||
|             ) |             ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @receiver(models.signals.post_save, sender=PaperlessTask) | ||||||
|  | def cleanup_failed_documents(sender, instance: PaperlessTask, **kwargs): | ||||||
|  |     if instance.status != states.FAILURE or not instance.acknowledged: | ||||||
|  |         return | ||||||
|  |  | ||||||
|  |     if instance.task_file_name: | ||||||
|  |         try: | ||||||
|  |             Path(settings.CONSUMPTION_FAILED_DIR / instance.task_file_name).unlink() | ||||||
|  |             logger.debug(f"Cleaned up failed file {instance.task_file_name}") | ||||||
|  |         except FileNotFoundError: | ||||||
|  |             logger.warning(f"Failed to clean up failed file {instance.task_file_name}") | ||||||
|  |  | ||||||
|  |  | ||||||
| def set_log_entry(sender, document: Document, logging_group=None, **kwargs): | def set_log_entry(sender, document: Document, logging_group=None, **kwargs): | ||||||
|     ct = ContentType.objects.get(model="document") |     ct = ContentType.objects.get(model="document") | ||||||
|     user = User.objects.get(username="consumer") |     user = User.objects.get(username="consumer") | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ from tempfile import TemporaryDirectory | |||||||
| import tqdm | import tqdm | ||||||
| from celery import Task | from celery import Task | ||||||
| from celery import shared_task | from celery import shared_task | ||||||
|  | from celery import states | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.db import transaction | from django.db import transaction | ||||||
| @@ -27,12 +28,14 @@ from documents.consumer import ConsumerPlugin | |||||||
| from documents.consumer import WorkflowTriggerPlugin | from documents.consumer import WorkflowTriggerPlugin | ||||||
| from documents.data_models import ConsumableDocument | from documents.data_models import ConsumableDocument | ||||||
| from documents.data_models import DocumentMetadataOverrides | from documents.data_models import DocumentMetadataOverrides | ||||||
|  | from documents.data_models import DocumentSource | ||||||
| from documents.double_sided import CollatePlugin | from documents.double_sided import CollatePlugin | ||||||
| from documents.file_handling import create_source_path_directory | from documents.file_handling import create_source_path_directory | ||||||
| from documents.file_handling import generate_unique_filename | from documents.file_handling import generate_unique_filename | ||||||
| from documents.models import Correspondent | from documents.models import Correspondent | ||||||
| from documents.models import Document | from documents.models import Document | ||||||
| from documents.models import DocumentType | from documents.models import DocumentType | ||||||
|  | from documents.models import PaperlessTask | ||||||
| from documents.models import StoragePath | from documents.models import StoragePath | ||||||
| from documents.models import Tag | from documents.models import Tag | ||||||
| from documents.parsers import DocumentParser | from documents.parsers import DocumentParser | ||||||
| @@ -44,6 +47,8 @@ from documents.plugins.helpers import ProgressStatusOptions | |||||||
| from documents.sanity_checker import SanityCheckFailedException | from documents.sanity_checker import SanityCheckFailedException | ||||||
| from documents.signals import document_updated | from documents.signals import document_updated | ||||||
| from documents.signals.handlers import cleanup_document_deletion | from documents.signals.handlers import cleanup_document_deletion | ||||||
|  | from documents.utils import copy_file_with_basic_stats | ||||||
|  | from documents.utils import run_subprocess | ||||||
|  |  | ||||||
| if settings.AUDIT_LOG_ENABLED: | if settings.AUDIT_LOG_ENABLED: | ||||||
|     from auditlog.models import LogEntry |     from auditlog.models import LogEntry | ||||||
| @@ -169,6 +174,48 @@ def consume_file( | |||||||
|     return msg |     return msg | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @shared_task | ||||||
|  | def retry_failed_file(task_id: str, clean: bool = False, skip_ocr: bool = False): | ||||||
|  |     task = PaperlessTask.objects.get(task_id=task_id, status=states.FAILURE) | ||||||
|  |     if task: | ||||||
|  |         failed_file = settings.CONSUMPTION_FAILED_DIR / task.task_file_name | ||||||
|  |         if not failed_file.exists(): | ||||||
|  |             logger.error(f"File {failed_file} not found") | ||||||
|  |             raise FileNotFoundError(f"File {failed_file} not found") | ||||||
|  |         working_copy = settings.SCRATCH_DIR / failed_file.name | ||||||
|  |         copy_file_with_basic_stats(failed_file, working_copy) | ||||||
|  |  | ||||||
|  |         if clean: | ||||||
|  |             try: | ||||||
|  |                 result = run_subprocess( | ||||||
|  |                     [ | ||||||
|  |                         "qpdf", | ||||||
|  |                         "--replace-input", | ||||||
|  |                         "--warning-exit-0", | ||||||
|  |                         working_copy, | ||||||
|  |                     ], | ||||||
|  |                     logger=logger, | ||||||
|  |                 ) | ||||||
|  |                 if result.returncode != 0: | ||||||
|  |                     raise Exception( | ||||||
|  |                         f"qpdf failed with exit code {result.returncode}, error: {result.stderr}", | ||||||
|  |                     ) | ||||||
|  |                 else: | ||||||
|  |                     logger.debug("PDF cleaned successfully") | ||||||
|  |             except Exception as e: | ||||||
|  |                 logger.error(f"Error while cleaning PDF: {e}") | ||||||
|  |                 raise e | ||||||
|  |  | ||||||
|  |         task = consume_file.delay( | ||||||
|  |             ConsumableDocument( | ||||||
|  |                 source=DocumentSource.ConsumeFolder, | ||||||
|  |                 original_file=working_copy, | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         return task.id | ||||||
|  |  | ||||||
|  |  | ||||||
| @shared_task | @shared_task | ||||||
| def sanity_check(): | def sanity_check(): | ||||||
|     messages = sanity_checker.check_sanity() |     messages = sanity_checker.check_sanity() | ||||||
|   | |||||||
							
								
								
									
										
											BIN
										
									
								
								src/documents/tests/samples/corrupted.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/documents/tests/samples/corrupted.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -1,5 +1,8 @@ | |||||||
| import os | import os | ||||||
|  | import shutil | ||||||
|  | import uuid | ||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
|  | from pathlib import Path | ||||||
| from unittest import mock | from unittest import mock | ||||||
|  |  | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| @@ -7,15 +10,22 @@ from django.test import TestCase | |||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
|  |  | ||||||
| from documents import tasks | from documents import tasks | ||||||
|  | from documents.data_models import ConsumableDocument | ||||||
|  | from documents.data_models import DocumentSource | ||||||
| from documents.models import Correspondent | from documents.models import Correspondent | ||||||
| from documents.models import Document | from documents.models import Document | ||||||
| from documents.models import DocumentType | from documents.models import DocumentType | ||||||
|  | from documents.models import PaperlessTask | ||||||
| from documents.models import Tag | from documents.models import Tag | ||||||
| from documents.sanity_checker import SanityCheckFailedException | from documents.sanity_checker import SanityCheckFailedException | ||||||
| from documents.sanity_checker import SanityCheckMessages | from documents.sanity_checker import SanityCheckMessages | ||||||
|  | from documents.signals.handlers import before_task_publish_handler | ||||||
|  | from documents.signals.handlers import task_failure_handler | ||||||
| from documents.tests.test_classifier import dummy_preprocess | from documents.tests.test_classifier import dummy_preprocess | ||||||
| from documents.tests.utils import DirectoriesMixin | from documents.tests.utils import DirectoriesMixin | ||||||
|  | from documents.tests.utils import DummyProgressManager | ||||||
| from documents.tests.utils import FileSystemAssertsMixin | from documents.tests.utils import FileSystemAssertsMixin | ||||||
|  | from documents.tests.utils import SampleDirMixin | ||||||
|  |  | ||||||
|  |  | ||||||
| class TestIndexReindex(DirectoriesMixin, TestCase): | class TestIndexReindex(DirectoriesMixin, TestCase): | ||||||
| @@ -184,3 +194,68 @@ class TestEmptyTrashTask(DirectoriesMixin, FileSystemAssertsMixin, TestCase): | |||||||
|  |  | ||||||
|         tasks.empty_trash() |         tasks.empty_trash() | ||||||
|         self.assertEqual(Document.global_objects.count(), 0) |         self.assertEqual(Document.global_objects.count(), 0) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestRetryConsumeTask( | ||||||
|  |     DirectoriesMixin, | ||||||
|  |     SampleDirMixin, | ||||||
|  |     FileSystemAssertsMixin, | ||||||
|  |     TestCase, | ||||||
|  | ): | ||||||
|  |     def do_failed_task(self, test_file: Path) -> PaperlessTask: | ||||||
|  |         temp_copy = self.dirs.scratch_dir / test_file.name | ||||||
|  |         shutil.copy(test_file, temp_copy) | ||||||
|  |  | ||||||
|  |         headers = { | ||||||
|  |             "id": str(uuid.uuid4()), | ||||||
|  |             "task": "documents.tasks.consume_file", | ||||||
|  |         } | ||||||
|  |         body = ( | ||||||
|  |             # args | ||||||
|  |             ( | ||||||
|  |                 ConsumableDocument( | ||||||
|  |                     source=DocumentSource.ConsumeFolder, | ||||||
|  |                     original_file=str(temp_copy), | ||||||
|  |                 ), | ||||||
|  |                 None, | ||||||
|  |             ), | ||||||
|  |             # kwargs | ||||||
|  |             {}, | ||||||
|  |             # celery stuff | ||||||
|  |             {"callbacks": None, "errbacks": None, "chain": None, "chord": None}, | ||||||
|  |         ) | ||||||
|  |         before_task_publish_handler(headers=headers, body=body) | ||||||
|  |  | ||||||
|  |         with mock.patch("documents.tasks.ProgressManager", DummyProgressManager): | ||||||
|  |             with self.assertRaises(Exception): | ||||||
|  |                 tasks.consume_file( | ||||||
|  |                     ConsumableDocument( | ||||||
|  |                         source=DocumentSource.ConsumeFolder, | ||||||
|  |                         original_file=temp_copy, | ||||||
|  |                     ), | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |         task_failure_handler( | ||||||
|  |             task_id=headers["id"], | ||||||
|  |             exception="Example failure", | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         task = PaperlessTask.objects.first() | ||||||
|  |         # Ensure the file is moved to the failed dir | ||||||
|  |         self.assertIsFile(settings.CONSUMPTION_FAILED_DIR / task.task_file_name) | ||||||
|  |         return task | ||||||
|  |  | ||||||
|  |     @mock.patch("documents.tasks.consume_file.delay") | ||||||
|  |     @mock.patch("documents.tasks.run_subprocess") | ||||||
|  |     def test_retry_consume_clean(self, m_subprocess, m_consume_file): | ||||||
|  |         task = self.do_failed_task(self.SAMPLE_DIR / "corrupted.pdf") | ||||||
|  |         m_subprocess.return_value.returncode = 0 | ||||||
|  |         task_id = tasks.retry_failed_file(task_id=task.task_id, clean=True) | ||||||
|  |         self.assertIsNotNone(task_id) | ||||||
|  |         m_consume_file.assert_called_once() | ||||||
|  |  | ||||||
|  |     def test_cleanup(self): | ||||||
|  |         task = self.do_failed_task(self.SAMPLE_DIR / "corrupted.pdf") | ||||||
|  |         task.acknowledged = True | ||||||
|  |         task.save()  # simulate the task being acknowledged | ||||||
|  |         self.assertIsNotFile(settings.CONSUMPTION_FAILED_DIR / task.task_file_name) | ||||||
|   | |||||||
| @@ -35,6 +35,7 @@ def setup_directories(): | |||||||
|     dirs.scratch_dir = Path(tempfile.mkdtemp()) |     dirs.scratch_dir = Path(tempfile.mkdtemp()) | ||||||
|     dirs.media_dir = Path(tempfile.mkdtemp()) |     dirs.media_dir = Path(tempfile.mkdtemp()) | ||||||
|     dirs.consumption_dir = Path(tempfile.mkdtemp()) |     dirs.consumption_dir = Path(tempfile.mkdtemp()) | ||||||
|  |     dirs.consumption_failed_dir = Path(tempfile.mkdtemp("failed")) | ||||||
|     dirs.static_dir = Path(tempfile.mkdtemp()) |     dirs.static_dir = Path(tempfile.mkdtemp()) | ||||||
|     dirs.index_dir = dirs.data_dir / "index" |     dirs.index_dir = dirs.data_dir / "index" | ||||||
|     dirs.originals_dir = dirs.media_dir / "documents" / "originals" |     dirs.originals_dir = dirs.media_dir / "documents" / "originals" | ||||||
| @@ -56,6 +57,7 @@ def setup_directories(): | |||||||
|         THUMBNAIL_DIR=dirs.thumbnail_dir, |         THUMBNAIL_DIR=dirs.thumbnail_dir, | ||||||
|         ARCHIVE_DIR=dirs.archive_dir, |         ARCHIVE_DIR=dirs.archive_dir, | ||||||
|         CONSUMPTION_DIR=dirs.consumption_dir, |         CONSUMPTION_DIR=dirs.consumption_dir, | ||||||
|  |         CONSUMPTION_FAILED_DIR=dirs.consumption_failed_dir, | ||||||
|         LOGGING_DIR=dirs.logging_dir, |         LOGGING_DIR=dirs.logging_dir, | ||||||
|         INDEX_DIR=dirs.index_dir, |         INDEX_DIR=dirs.index_dir, | ||||||
|         STATIC_ROOT=dirs.static_dir, |         STATIC_ROOT=dirs.static_dir, | ||||||
| @@ -72,6 +74,7 @@ def remove_dirs(dirs): | |||||||
|     shutil.rmtree(dirs.data_dir, ignore_errors=True) |     shutil.rmtree(dirs.data_dir, ignore_errors=True) | ||||||
|     shutil.rmtree(dirs.scratch_dir, ignore_errors=True) |     shutil.rmtree(dirs.scratch_dir, ignore_errors=True) | ||||||
|     shutil.rmtree(dirs.consumption_dir, ignore_errors=True) |     shutil.rmtree(dirs.consumption_dir, ignore_errors=True) | ||||||
|  |     shutil.rmtree(dirs.consumption_failed_dir, ignore_errors=True) | ||||||
|     shutil.rmtree(dirs.static_dir, ignore_errors=True) |     shutil.rmtree(dirs.static_dir, ignore_errors=True) | ||||||
|     dirs.settings_override.disable() |     dirs.settings_override.disable() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -136,6 +136,7 @@ from documents.serialisers import DocumentListSerializer | |||||||
| from documents.serialisers import DocumentSerializer | from documents.serialisers import DocumentSerializer | ||||||
| from documents.serialisers import DocumentTypeSerializer | from documents.serialisers import DocumentTypeSerializer | ||||||
| from documents.serialisers import PostDocumentSerializer | from documents.serialisers import PostDocumentSerializer | ||||||
|  | from documents.serialisers import RetryTaskSerializer | ||||||
| from documents.serialisers import SavedViewSerializer | from documents.serialisers import SavedViewSerializer | ||||||
| from documents.serialisers import SearchResultSerializer | from documents.serialisers import SearchResultSerializer | ||||||
| from documents.serialisers import ShareLinkSerializer | from documents.serialisers import ShareLinkSerializer | ||||||
| @@ -152,6 +153,7 @@ from documents.serialisers import WorkflowTriggerSerializer | |||||||
| from documents.signals import document_updated | from documents.signals import document_updated | ||||||
| from documents.tasks import consume_file | from documents.tasks import consume_file | ||||||
| from documents.tasks import empty_trash | from documents.tasks import empty_trash | ||||||
|  | from documents.tasks import retry_failed_file | ||||||
| from documents.templating.filepath import validate_filepath_template_and_render | from documents.templating.filepath import validate_filepath_template_and_render | ||||||
| from paperless import version | from paperless import version | ||||||
| from paperless.celery import app as celery_app | from paperless.celery import app as celery_app | ||||||
| @@ -1718,6 +1720,25 @@ class TasksViewSet(ReadOnlyModelViewSet): | |||||||
|             queryset = PaperlessTask.objects.filter(task_id=task_id) |             queryset = PaperlessTask.objects.filter(task_id=task_id) | ||||||
|         return queryset |         return queryset | ||||||
|  |  | ||||||
|  |     @action(methods=["post"], detail=True) | ||||||
|  |     def retry(self, request, pk=None): | ||||||
|  |         task = self.get_object() | ||||||
|  |  | ||||||
|  |         serializer = RetryTaskSerializer(data=request.data) | ||||||
|  |         serializer.is_valid(raise_exception=True) | ||||||
|  |         clean = serializer.validated_data.get("clean") | ||||||
|  |  | ||||||
|  |         try: | ||||||
|  |             new_task_id = retry_failed_file(task.task_id, clean) | ||||||
|  |             return Response({"task_id": new_task_id}) | ||||||
|  |         except FileNotFoundError: | ||||||
|  |             return HttpResponseBadRequest("Original file not found") | ||||||
|  |         except Exception as e: | ||||||
|  |             logger.warning(f"An error occurred retrying task: {e!s}") | ||||||
|  |             return HttpResponseBadRequest( | ||||||
|  |                 "Error retrying task, check logs for more detail.", | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class AcknowledgeTasksView(GenericAPIView): | class AcknowledgeTasksView(GenericAPIView): | ||||||
|     permission_classes = (IsAuthenticated,) |     permission_classes = (IsAuthenticated,) | ||||||
|   | |||||||
| @@ -65,6 +65,10 @@ def paths_check(app_configs, **kwargs): | |||||||
|         + path_check("PAPERLESS_EMPTY_TRASH_DIR", settings.EMPTY_TRASH_DIR) |         + path_check("PAPERLESS_EMPTY_TRASH_DIR", settings.EMPTY_TRASH_DIR) | ||||||
|         + path_check("PAPERLESS_MEDIA_ROOT", settings.MEDIA_ROOT) |         + path_check("PAPERLESS_MEDIA_ROOT", settings.MEDIA_ROOT) | ||||||
|         + path_check("PAPERLESS_CONSUMPTION_DIR", settings.CONSUMPTION_DIR) |         + path_check("PAPERLESS_CONSUMPTION_DIR", settings.CONSUMPTION_DIR) | ||||||
|  |         + path_check( | ||||||
|  |             "PAPERLESS_CONSUMPTION_FAILED_DIR", | ||||||
|  |             settings.CONSUMPTION_FAILED_DIR, | ||||||
|  |         ) | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -281,6 +281,11 @@ CONSUMPTION_DIR = __get_path( | |||||||
|     BASE_DIR.parent / "consume", |     BASE_DIR.parent / "consume", | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | CONSUMPTION_FAILED_DIR = __get_path( | ||||||
|  |     "PAPERLESS_CONSUMPTION_FAILED_DIR", | ||||||
|  |     CONSUMPTION_DIR / "failed", | ||||||
|  | ) | ||||||
|  |  | ||||||
| # This will be created if it doesn't exist | # This will be created if it doesn't exist | ||||||
| SCRATCH_DIR = __get_path( | SCRATCH_DIR = __get_path( | ||||||
|     "PAPERLESS_SCRATCH_DIR", |     "PAPERLESS_SCRATCH_DIR", | ||||||
| @@ -890,6 +895,8 @@ CONSUMER_IGNORE_PATTERNS = list( | |||||||
|         ), |         ), | ||||||
|     ), |     ), | ||||||
| ) | ) | ||||||
|  | if CONSUMPTION_DIR in CONSUMPTION_FAILED_DIR.parents: | ||||||
|  |     CONSUMER_IGNORE_PATTERNS.append(CONSUMPTION_FAILED_DIR.name) | ||||||
|  |  | ||||||
| CONSUMER_SUBDIRS_AS_TAGS = __get_boolean("PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS") | CONSUMER_SUBDIRS_AS_TAGS = __get_boolean("PAPERLESS_CONSUMER_SUBDIRS_AS_TAGS") | ||||||
|  |  | ||||||
|   | |||||||
| @@ -29,10 +29,11 @@ class TestChecks(DirectoriesMixin, TestCase): | |||||||
|         MEDIA_ROOT="uuh", |         MEDIA_ROOT="uuh", | ||||||
|         DATA_DIR="whatever", |         DATA_DIR="whatever", | ||||||
|         CONSUMPTION_DIR="idontcare", |         CONSUMPTION_DIR="idontcare", | ||||||
|  |         CONSUMPTION_FAILED_DIR="nope", | ||||||
|     ) |     ) | ||||||
|     def test_paths_check_dont_exist(self): |     def test_paths_check_dont_exist(self): | ||||||
|         msgs = paths_check(None) |         msgs = paths_check(None) | ||||||
|         self.assertEqual(len(msgs), 3, str(msgs)) |         self.assertEqual(len(msgs), 4, str(msgs)) | ||||||
|  |  | ||||||
|         for msg in msgs: |         for msg in msgs: | ||||||
|             self.assertTrue(msg.msg.endswith("is set but doesn't exist.")) |             self.assertTrue(msg.msg.endswith("is set but doesn't exist.")) | ||||||
| @@ -41,13 +42,15 @@ class TestChecks(DirectoriesMixin, TestCase): | |||||||
|         os.chmod(self.dirs.data_dir, 0o000) |         os.chmod(self.dirs.data_dir, 0o000) | ||||||
|         os.chmod(self.dirs.media_dir, 0o000) |         os.chmod(self.dirs.media_dir, 0o000) | ||||||
|         os.chmod(self.dirs.consumption_dir, 0o000) |         os.chmod(self.dirs.consumption_dir, 0o000) | ||||||
|  |         os.chmod(self.dirs.consumption_failed_dir, 0o000) | ||||||
|  |  | ||||||
|         self.addCleanup(os.chmod, self.dirs.data_dir, 0o777) |         self.addCleanup(os.chmod, self.dirs.data_dir, 0o777) | ||||||
|         self.addCleanup(os.chmod, self.dirs.media_dir, 0o777) |         self.addCleanup(os.chmod, self.dirs.media_dir, 0o777) | ||||||
|         self.addCleanup(os.chmod, self.dirs.consumption_dir, 0o777) |         self.addCleanup(os.chmod, self.dirs.consumption_dir, 0o777) | ||||||
|  |         self.addCleanup(os.chmod, self.dirs.consumption_failed_dir, 0o777) | ||||||
|  |  | ||||||
|         msgs = paths_check(None) |         msgs = paths_check(None) | ||||||
|         self.assertEqual(len(msgs), 3) |         self.assertEqual(len(msgs), 4) | ||||||
|  |  | ||||||
|         for msg in msgs: |         for msg in msgs: | ||||||
|             self.assertTrue(msg.msg.endswith("is not writeable")) |             self.assertTrue(msg.msg.endswith("is not writeable")) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user