Compare commits
8 Commits
dev
...
feature-re
Author | SHA1 | Date | |
---|---|---|---|
|
ec12e71487 | ||
|
62b470f691 | ||
|
a2e4977201 | ||
|
0fcd69b739 | ||
|
af1c64e969 | ||
|
85c661dff2 | ||
|
3a7eee2c2e | ||
|
bc4d3925cc |
@ -18,6 +18,7 @@
|
||||
# Paths and folders
|
||||
|
||||
#PAPERLESS_CONSUMPTION_DIR=../consume
|
||||
#PAPERLESS_CONSUMPTION_FAILED_DIR=../consume/failed
|
||||
#PAPERLESS_DATA_DIR=../data
|
||||
#PAPERLESS_EMPTY_TRASH_DIR=
|
||||
#PAPERLESS_MEDIA_ROOT=../media
|
||||
|
@ -1994,120 +1994,141 @@
|
||||
<context context-type="linenumber">72</context>
|
||||
</context-group>
|
||||
</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">
|
||||
<source>Dismiss</source>
|
||||
<context-group purpose="location">
|
||||
<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 purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="2134950584701094962" datatype="html">
|
||||
<source>Open Document</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="1943508481059904274" datatype="html">
|
||||
<source> (<x id="INTERPOLATION" equiv-text="{{selectedTasks.size}}"/> selected)</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<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>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="5404910960991552159" datatype="html">
|
||||
<source>Dismiss selected</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="8829078752502782653" datatype="html">
|
||||
<source>Dismiss all</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="1323591410517879795" datatype="html">
|
||||
<source>Confirm Dismiss All</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="4157200209636243740" datatype="html">
|
||||
<source>Dismiss all <x id="PH" equiv-text="tasks.size"/> tasks?</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="9011556615675272238" datatype="html">
|
||||
<source>queued</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="6415892379431855826" datatype="html">
|
||||
<source>started</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="7510279840486540181" datatype="html">
|
||||
<source>completed</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="4083337005045748464" datatype="html">
|
||||
<source>failed</source>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="3418677553313974490" datatype="html">
|
||||
|
@ -81,6 +81,9 @@
|
||||
</td>
|
||||
<td scope="row">
|
||||
<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 }">
|
||||
<i-bs name="check"></i-bs> <ng-container i18n>Dismiss</ng-container>
|
||||
</button>
|
||||
@ -153,3 +156,25 @@
|
||||
</li>
|
||||
</ul>
|
||||
<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;
|
||||
}
|
||||
}
|
||||
|
||||
.retry-dropdown {
|
||||
width: 300px;
|
||||
}
|
||||
|
@ -31,6 +31,9 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
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[] = [
|
||||
{
|
||||
@ -115,6 +118,7 @@ describe('TasksComponent', () => {
|
||||
let modalService: NgbModal
|
||||
let router: Router
|
||||
let httpTestingController: HttpTestingController
|
||||
let toastService: ToastService
|
||||
let reloadSpy
|
||||
|
||||
beforeEach(async () => {
|
||||
@ -125,6 +129,7 @@ describe('TasksComponent', () => {
|
||||
IfPermissionsDirective,
|
||||
CustomDatePipe,
|
||||
ConfirmDialogComponent,
|
||||
CheckComponent,
|
||||
],
|
||||
imports: [
|
||||
NgbModule,
|
||||
@ -152,6 +157,7 @@ describe('TasksComponent', () => {
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
router = TestBed.inject(Router)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
fixture = TestBed.createComponent(TasksComponent)
|
||||
component = fixture.componentInstance
|
||||
jest.useFakeTimers()
|
||||
@ -173,8 +179,10 @@ describe('TasksComponent', () => {
|
||||
`Failed${currentTasksLength}`
|
||||
)
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('table input[type="checkbox"]'))
|
||||
).toHaveLength(currentTasksLength + 1)
|
||||
fixture.debugElement.queryAll(
|
||||
By.css('table td > .form-check input[type="checkbox"]')
|
||||
)
|
||||
).toHaveLength(currentTasksLength)
|
||||
|
||||
currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Complete
|
||||
@ -289,4 +297,20 @@ describe('TasksComponent', () => {
|
||||
jest.advanceTimersByTime(6000)
|
||||
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 { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
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 { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-tasks',
|
||||
@ -16,6 +17,7 @@ export class TasksComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
public PaperlessTaskStatus = PaperlessTaskStatus
|
||||
public activeTab: string
|
||||
public selectedTasks: Set<number> = new Set()
|
||||
public togggleAll: boolean = false
|
||||
@ -26,6 +28,8 @@ export class TasksComponent
|
||||
|
||||
public autoRefreshInterval: any
|
||||
|
||||
public retryClean: boolean = false
|
||||
|
||||
get dismissButtonText(): string {
|
||||
return this.selectedTasks.size > 0
|
||||
? $localize`Dismiss selected`
|
||||
@ -35,6 +39,7 @@ export class TasksComponent
|
||||
constructor(
|
||||
public tasksService: TasksService,
|
||||
private modalService: NgbModal,
|
||||
private toastService: ToastService,
|
||||
private readonly router: Router
|
||||
) {
|
||||
super()
|
||||
@ -83,6 +88,17 @@ export class TasksComponent
|
||||
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) {
|
||||
this.expandedTask = this.expandedTask == task.id ? undefined : task.id
|
||||
}
|
||||
|
@ -118,4 +118,29 @@ describe('TasksService', () => {
|
||||
expect(tasksService.queuedFileTasks).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 { Injectable } from '@angular/core'
|
||||
import { Subject } from 'rxjs'
|
||||
import { first, takeUntil } from 'rxjs/operators'
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { first, takeUntil, tap } from 'rxjs/operators'
|
||||
import {
|
||||
PaperlessTask,
|
||||
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 {
|
||||
this.unsubscribeNotifer.next(true)
|
||||
}
|
||||
|
@ -148,6 +148,17 @@ class ConsumerPlugin(
|
||||
):
|
||||
self._send_progress(100, 100, ProgressStatusOptions.FAILED, message)
|
||||
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
|
||||
|
||||
def pre_check_file_exists(self):
|
||||
|
@ -679,24 +679,28 @@ class PaperlessTask(models.Model):
|
||||
verbose_name=_("Task State"),
|
||||
help_text=_("Current state of the task being run"),
|
||||
)
|
||||
|
||||
date_created = models.DateTimeField(
|
||||
null=True,
|
||||
default=timezone.now,
|
||||
verbose_name=_("Created DateTime"),
|
||||
help_text=_("Datetime field when the task result was created in UTC"),
|
||||
)
|
||||
|
||||
date_started = models.DateTimeField(
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("Started DateTime"),
|
||||
help_text=_("Datetime field when the task was started in UTC"),
|
||||
)
|
||||
|
||||
date_done = models.DateTimeField(
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("Completed DateTime"),
|
||||
help_text=_("Datetime field when the task was completed in UTC"),
|
||||
)
|
||||
|
||||
result = models.TextField(
|
||||
null=True,
|
||||
default=None,
|
||||
|
@ -1585,6 +1585,14 @@ class TasksViewSerializer(serializers.ModelSerializer):
|
||||
return result
|
||||
|
||||
|
||||
class RetryTaskSerializer(serializers.Serializer):
|
||||
clean = serializers.BooleanField(
|
||||
default=False,
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
||||
class AcknowledgeTasksViewSerializer(serializers.Serializer):
|
||||
tasks = serializers.ListField(
|
||||
required=True,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from celery import states
|
||||
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):
|
||||
ct = ContentType.objects.get(model="document")
|
||||
user = User.objects.get(username="consumer")
|
||||
|
@ -9,6 +9,7 @@ from tempfile import TemporaryDirectory
|
||||
import tqdm
|
||||
from celery import Task
|
||||
from celery import shared_task
|
||||
from celery import states
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db import transaction
|
||||
@ -27,12 +28,14 @@ from documents.consumer import ConsumerPlugin
|
||||
from documents.consumer import WorkflowTriggerPlugin
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.double_sided import CollatePlugin
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import generate_unique_filename
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.parsers import DocumentParser
|
||||
@ -44,6 +47,8 @@ from documents.plugins.helpers import ProgressStatusOptions
|
||||
from documents.sanity_checker import SanityCheckFailedException
|
||||
from documents.signals import document_updated
|
||||
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:
|
||||
from auditlog.models import LogEntry
|
||||
@ -169,6 +174,48 @@ def consume_file(
|
||||
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
|
||||
def sanity_check():
|
||||
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 shutil
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
@ -7,15 +10,22 @@ from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
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 Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import PaperlessTask
|
||||
from documents.models import Tag
|
||||
from documents.sanity_checker import SanityCheckFailedException
|
||||
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.utils import DirectoriesMixin
|
||||
from documents.tests.utils import DummyProgressManager
|
||||
from documents.tests.utils import FileSystemAssertsMixin
|
||||
from documents.tests.utils import SampleDirMixin
|
||||
|
||||
|
||||
class TestIndexReindex(DirectoriesMixin, TestCase):
|
||||
@ -184,3 +194,68 @@ class TestEmptyTrashTask(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
|
||||
tasks.empty_trash()
|
||||
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.media_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.index_dir = dirs.data_dir / "index"
|
||||
dirs.originals_dir = dirs.media_dir / "documents" / "originals"
|
||||
@ -56,6 +57,7 @@ def setup_directories():
|
||||
THUMBNAIL_DIR=dirs.thumbnail_dir,
|
||||
ARCHIVE_DIR=dirs.archive_dir,
|
||||
CONSUMPTION_DIR=dirs.consumption_dir,
|
||||
CONSUMPTION_FAILED_DIR=dirs.consumption_failed_dir,
|
||||
LOGGING_DIR=dirs.logging_dir,
|
||||
INDEX_DIR=dirs.index_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.scratch_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)
|
||||
dirs.settings_override.disable()
|
||||
|
||||
|
@ -136,6 +136,7 @@ from documents.serialisers import DocumentListSerializer
|
||||
from documents.serialisers import DocumentSerializer
|
||||
from documents.serialisers import DocumentTypeSerializer
|
||||
from documents.serialisers import PostDocumentSerializer
|
||||
from documents.serialisers import RetryTaskSerializer
|
||||
from documents.serialisers import SavedViewSerializer
|
||||
from documents.serialisers import SearchResultSerializer
|
||||
from documents.serialisers import ShareLinkSerializer
|
||||
@ -152,6 +153,7 @@ from documents.serialisers import WorkflowTriggerSerializer
|
||||
from documents.signals import document_updated
|
||||
from documents.tasks import consume_file
|
||||
from documents.tasks import empty_trash
|
||||
from documents.tasks import retry_failed_file
|
||||
from documents.templating.filepath import validate_filepath_template_and_render
|
||||
from paperless import version
|
||||
from paperless.celery import app as celery_app
|
||||
@ -1718,6 +1720,25 @@ class TasksViewSet(ReadOnlyModelViewSet):
|
||||
queryset = PaperlessTask.objects.filter(task_id=task_id)
|
||||
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):
|
||||
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_MEDIA_ROOT", settings.MEDIA_ROOT)
|
||||
+ 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",
|
||||
)
|
||||
|
||||
CONSUMPTION_FAILED_DIR = __get_path(
|
||||
"PAPERLESS_CONSUMPTION_FAILED_DIR",
|
||||
CONSUMPTION_DIR / "failed",
|
||||
)
|
||||
|
||||
# This will be created if it doesn't exist
|
||||
SCRATCH_DIR = __get_path(
|
||||
"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")
|
||||
|
||||
|
@ -29,10 +29,11 @@ class TestChecks(DirectoriesMixin, TestCase):
|
||||
MEDIA_ROOT="uuh",
|
||||
DATA_DIR="whatever",
|
||||
CONSUMPTION_DIR="idontcare",
|
||||
CONSUMPTION_FAILED_DIR="nope",
|
||||
)
|
||||
def test_paths_check_dont_exist(self):
|
||||
msgs = paths_check(None)
|
||||
self.assertEqual(len(msgs), 3, str(msgs))
|
||||
self.assertEqual(len(msgs), 4, str(msgs))
|
||||
|
||||
for msg in msgs:
|
||||
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.media_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.media_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)
|
||||
self.assertEqual(len(msgs), 3)
|
||||
self.assertEqual(len(msgs), 4)
|
||||
|
||||
for msg in msgs:
|
||||
self.assertTrue(msg.msg.endswith("is not writeable"))
|
||||
|
Loading…
x
Reference in New Issue
Block a user