Merge pull request #1020 from paperless-ngx/feature-frontend-task-queue

Feature: frontend task queue
This commit is contained in:
shamoon 2022-07-08 14:06:24 -07:00 committed by GitHub
commit 1f73ca21bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 722 additions and 4 deletions

View File

@ -0,0 +1,60 @@
describe('tasks', () => {
beforeEach(() => {
this.dismissedTasks = new Set<number>()
cy.fixture('tasks/tasks.json').then((tasksViewsJson) => {
// acknowledge tasks POST
cy.intercept(
'POST',
'http://localhost:8000/api/acknowledge_tasks/',
(req) => {
req.body['tasks'].forEach((t) => this.dismissedTasks.add(t)) // store this for later
req.reply({ result: 'OK' })
}
)
cy.intercept('GET', 'http://localhost:8000/api/tasks/', (req) => {
let response = [...tasksViewsJson]
if (this.dismissedTasks.size) {
response = response.filter((t) => {
return !this.dismissedTasks.has(t.id)
})
}
req.reply(response)
}).as('tasks')
})
cy.visit('/tasks')
cy.wait('@tasks')
})
it('should show a list of dismissable tasks in tabs', () => {
cy.get('tbody').find('tr:visible').its('length').should('eq', 10) // double because collapsible result tr
cy.wait(500) // stabilizes the test, for some reason...
cy.get('tbody')
.find('button:visible')
.contains('Dismiss')
.first()
.click()
.wait('@tasks')
.wait(2000)
.then(() => {
cy.get('tbody').find('tr:visible').its('length').should('eq', 8) // double because collapsible result tr
})
})
it('should allow toggling all tasks in list and warn on dismiss', () => {
cy.get('thead').find('input[type="checkbox"]').first().click()
cy.get('body').find('button').contains('Dismiss selected').first().click()
cy.contains('Confirm')
cy.get('.modal')
.contains('button', 'Dismiss')
.click()
.wait('@tasks')
.wait(2000)
.then(() => {
cy.get('tbody').find('tr:visible').should('not.exist')
})
})
})

File diff suppressed because one or more lines are too long

View File

@ -13,6 +13,7 @@ import { NotFoundComponent } from './components/not-found/not-found.component'
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import { DirtyFormGuard } from './guards/dirty-form.guard'
import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component'
import { TasksComponent } from './components/manage/tasks/tasks.component'
const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
@ -35,6 +36,7 @@ const routes: Routes = [
component: SettingsComponent,
canDeactivate: [DirtyFormGuard],
},
{ path: 'tasks', component: TasksComponent },
],
},

View File

@ -7,6 +7,7 @@ import { ConsumerStatusService } from './services/consumer-status.service'
import { ToastService } from './services/toast.service'
import { NgxFileDropEntry } from 'ngx-file-drop'
import { UploadDocumentsService } from './services/upload-documents.service'
import { TasksService } from './services/tasks.service'
@Component({
selector: 'app-root',
@ -27,7 +28,8 @@ export class AppComponent implements OnInit, OnDestroy {
private consumerStatusService: ConsumerStatusService,
private toastService: ToastService,
private router: Router,
private uploadDocumentsService: UploadDocumentsService
private uploadDocumentsService: UploadDocumentsService,
private tasksService: TasksService
) {
let anyWindow = window as any
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.js'
@ -65,6 +67,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.successSubscription = this.consumerStatusService
.onDocumentConsumptionFinished()
.subscribe((status) => {
this.tasksService.reload()
if (
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
) {
@ -83,6 +86,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.failedSubscription = this.consumerStatusService
.onDocumentConsumptionFailed()
.subscribe((status) => {
this.tasksService.reload()
if (
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)
) {
@ -95,6 +99,7 @@ export class AppComponent implements OnInit, OnDestroy {
this.newDocumentSubscription = this.consumerStatusService
.onDocumentDetected()
.subscribe((status) => {
this.tasksService.reload()
if (
this.showNotification(
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT

View File

@ -90,6 +90,7 @@ import localeZh from '@angular/common/locales/zh'
import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component'
import { StoragePathEditDialogComponent } from './components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { SettingsService } from './services/settings.service'
import { TasksComponent } from './components/manage/tasks/tasks.component'
registerLocaleData(localeBe)
registerLocaleData(localeCs)
@ -171,6 +172,7 @@ function initializeApp(settings: SettingsService) {
DateComponent,
ColorComponent,
DocumentAsnComponent,
TasksComponent,
],
imports: [
BrowserModule,

View File

@ -141,6 +141,13 @@
</svg>&nbsp;<ng-container i18n>Storage paths</ng-container>
</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#list-task"/>
</svg>&nbsp;<ng-container i18n>File Tasks<ng-container *ngIf="tasksService.failedFileTasks.length > 0"><span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></ng-container></ng-container>
</a>
</li>
<li class="nav-item">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()">
<svg class="sidebaricon" fill="currentColor">

View File

@ -1,7 +1,7 @@
import { Component } from '@angular/core'
import { FormControl } from '@angular/forms'
import { ActivatedRoute, Router, Params } from '@angular/router'
import { from, Observable, Subscription, BehaviorSubject } from 'rxjs'
import { from, Observable } from 'rxjs'
import {
debounceTime,
distinctUntilChanged,
@ -15,7 +15,6 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SearchService } from 'src/app/services/rest/search.service'
import { environment } from 'src/environments/environment'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { Meta } from '@angular/platform-browser'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
import {
@ -23,6 +22,7 @@ import {
AppRemoteVersion,
} from 'src/app/services/rest/remote-version.service'
import { SettingsService } from 'src/app/services/settings.service'
import { TasksService } from 'src/app/services/tasks.service'
@Component({
selector: 'app-app-frame',
@ -38,13 +38,15 @@ export class AppFrameComponent {
public savedViewService: SavedViewService,
private remoteVersionService: RemoteVersionService,
private list: DocumentListViewService,
public settingsService: SettingsService
public settingsService: SettingsService,
public tasksService: TasksService
) {
this.remoteVersionService
.checkForUpdates()
.subscribe((appRemoteVersion: AppRemoteVersion) => {
this.appRemoteVersion = appRemoteVersion
})
tasksService.reload()
}
versionString = `${environment.appTitle} ${environment.version}`

View File

@ -0,0 +1,120 @@
<app-page-header title="File Tasks" i18n-title>
<div class="btn-toolbar col col-md-auto">
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size == 0">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg>&nbsp;<ng-container i18n>Clear selection</ng-container>
</button>
<button class="btn btn-sm btn-outline-primary me-4" (click)="dismissTasks()" [disabled]="tasksService.total == 0">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#check2-all"/>
</svg>&nbsp;<ng-container i18n>{{dismissButtonText}}</ng-container>
</button>
<button class="btn btn-sm btn-outline-primary" (click)="tasksService.reload()">
<svg *ngIf="!tasksService.loading" class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-clockwise"/>
</svg>
<ng-container *ngIf="tasksService.loading">
<div class="spinner-border spinner-border-sm fw-normal" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-container>&nbsp;<ng-container i18n>Refresh</ng-container>
</button>
</div>
</app-page-header>
<ng-container *ngIf="!tasksService.completedFileTasks && tasksService.loading">
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-container>
<ng-template let-tasks="tasks" #tasksTemplate>
<table class="table table-striped align-middle border shadow-sm">
<thead>
<tr>
<th scope="col">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length == 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-tasks"></label>
</div>
</th>
<th scope="col" i18n>Name</th>
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th>
<th scope="col" class="d-none d-lg-table-cell" *ngIf="activeTab != 'started' && activeTab != 'queued'" i18n>Results</th>
<th scope="col" class="d-table-cell d-lg-none" i18n>Info</th>
<th scope="col" i18n>Actions</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let task of tasks">
<tr (click)="toggleSelected(task, $event); $event.stopPropagation();">
<th>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="task{{task.id}}" [checked]="selectedTasks.has(task.id)" (click)="toggleSelected(task, $event); $event.stopPropagation();">
<label class="form-check-label" for="task{{task.id}}"></label>
</div>
</th>
<td class="overflow-auto">{{ task.name }}</td>
<td class="d-none d-lg-table-cell">{{ task.created | customDate:'short' }}</td>
<td class="d-none d-lg-table-cell" *ngIf="activeTab != 'incomplete'">
<div *ngIf="task.result.length > 50" class="result" (click)="expandTask(task); $event.stopPropagation();"
[ngbPopover]="resultPopover" popoverClass="shadow small mobile" triggers="mouseenter:mouseleave" container="body">
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result | slice:0:50 }}&hellip;</span>
</div>
<span *ngIf="task.result.length <= 50" class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result }}</span>
<ng-template #resultPopover>
<pre class="small mb-0">{{ task.result | slice:0:300 }}<ng-container *ngIf="task.result.length > 300">&hellip;</ng-container></pre>
<ng-container *ngIf="task.result.length > 300"><br/><em>(<ng-container i18n>click for full output</ng-container>)</em></ng-container>
</ng-template>
</td>
<td class="d-lg-none">
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();">
<svg fill="currentColor" class="" width="1.2em" height="1.2em" style="vertical-align: text-top;" viewBox="0 0 16 16">
<use xlink:href="assets/bootstrap-icons.svg#info-circle" />
</svg>
</button>
</td>
<td scope="row">
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#check"/>
</svg>&nbsp;<ng-container i18n>Dismiss</ng-container>
</button>
</td>
</tr>
<tr>
<td class="p-0" [class.border-0]="expandedTask != task.id" colspan="5">
<pre #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="small mb-0"><div class="small p-1 p-lg-3 ms-lg-3">{{ task.result }}</div></pre>
</td>
</tr>
</ng-container>
</tbody>
</table>
</ng-template>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs">
<li ngbNavItem="failed">
<a ngbNavLink i18n>Failed&nbsp;<span *ngIf="tasksService.failedFileTasks.length > 0" class="badge bg-danger ms-1">{{tasksService.failedFileTasks.length}}</span></a>
<ng-template ngbNavContent>
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.failedFileTasks}"></ng-container>
</ng-template>
</li>
<li ngbNavItem="completed">
<a ngbNavLink i18n>Complete&nbsp;<span *ngIf="tasksService.completedFileTasks.length > 0" class="badge bg-secondary ms-1">{{tasksService.completedFileTasks.length}}</span></a>
<ng-template ngbNavContent>
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.completedFileTasks}"></ng-container>
</ng-template>
</li>
<li ngbNavItem="started">
<a ngbNavLink i18n>Started&nbsp;<span *ngIf="tasksService.startedFileTasks.length > 0" class="badge bg-secondary ms-1">{{tasksService.startedFileTasks.length}}</span></a>
<ng-template ngbNavContent>
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.startedFileTasks}"></ng-container>
</ng-template>
</li>
<li ngbNavItem="queued">
<a ngbNavLink i18n>Queued&nbsp;<span *ngIf="tasksService.queuedFileTasks.length > 0" class="badge bg-secondary ms-1">{{tasksService.queuedFileTasks.length}}</span></a>
<ng-template ngbNavContent>
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:tasksService.queuedFileTasks}"></ng-container>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav"></div>

View File

@ -0,0 +1,22 @@
::ng-deep .popover {
max-width: 350px;
pre {
white-space: pre-wrap;
word-break: break-word;
}
}
pre {
white-space: pre-wrap;
word-break: break-word;
}
.result {
cursor: pointer;
}
.btn .spinner-border-sm {
width: 0.8rem;
height: 0.8rem;
}

View File

@ -0,0 +1,109 @@
import { Component, OnInit, OnDestroy } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { takeUntil, Subject, first } from 'rxjs'
import { PaperlessTask } from 'src/app/data/paperless-task'
import { TasksService } from 'src/app/services/tasks.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
@Component({
selector: 'app-tasks',
templateUrl: './tasks.component.html',
styleUrls: ['./tasks.component.scss'],
})
export class TasksComponent implements OnInit, OnDestroy {
public activeTab: string
public selectedTasks: Set<number> = new Set()
private unsubscribeNotifer = new Subject()
public expandedTask: number
get dismissButtonText(): string {
return this.selectedTasks.size > 0
? $localize`Dismiss selected`
: $localize`Dismiss all`
}
constructor(
public tasksService: TasksService,
private modalService: NgbModal
) {}
ngOnInit() {
this.tasksService.reload()
}
ngOnDestroy() {
this.unsubscribeNotifer.next(true)
}
dismissTask(task: PaperlessTask) {
this.dismissTasks(task)
}
dismissTasks(task: PaperlessTask = undefined) {
let tasks = task ? new Set([task.id]) : this.selectedTasks
if (!task && this.selectedTasks.size == 0)
tasks = new Set(this.tasksService.allFileTasks.map((t) => t.id))
if (tasks.size > 1) {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm Dismiss All`
modal.componentInstance.messageBold =
$localize`Dismiss all` + ` ${tasks.size} ` + $localize`tasks?`
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled = false
modal.close()
this.tasksService.dismissTasks(tasks)
this.selectedTasks.clear()
})
} else {
this.tasksService.dismissTasks(tasks)
this.selectedTasks.clear()
}
}
expandTask(task: PaperlessTask) {
this.expandedTask = this.expandedTask == task.id ? undefined : task.id
}
toggleSelected(task: PaperlessTask) {
this.selectedTasks.has(task.id)
? this.selectedTasks.delete(task.id)
: this.selectedTasks.add(task.id)
}
get currentTasks(): PaperlessTask[] {
let tasks: PaperlessTask[]
switch (this.activeTab) {
case 'queued':
tasks = this.tasksService.queuedFileTasks
break
case 'started':
tasks = this.tasksService.startedFileTasks
break
case 'completed':
tasks = this.tasksService.completedFileTasks
break
case 'failed':
tasks = this.tasksService.failedFileTasks
break
default:
break
}
return tasks
}
toggleAll(event: PointerEvent) {
if ((event.target as HTMLInputElement).checked) {
this.selectedTasks = new Set(this.currentTasks.map((t) => t.id))
} else {
this.clearSelection()
}
}
clearSelection() {
this.selectedTasks.clear()
}
}

View File

@ -0,0 +1,32 @@
import { ObjectWithId } from './object-with-id'
export enum PaperlessTaskType {
// just file tasks, for now
File = 'file',
}
export enum PaperlessTaskStatus {
Queued = 'queued',
Started = 'started',
Complete = 'complete',
Failed = 'failed',
Unknown = 'unknown',
}
export interface PaperlessTask extends ObjectWithId {
type: PaperlessTaskType
status: PaperlessTaskStatus
acknowledged: boolean
task_id: string
name: string
created: Date
started?: Date
result: string
}

View File

@ -0,0 +1,71 @@
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { first, map } from 'rxjs/operators'
import {
PaperlessTask,
PaperlessTaskStatus,
PaperlessTaskType,
} from 'src/app/data/paperless-task'
import { environment } from 'src/environments/environment'
@Injectable({
providedIn: 'root',
})
export class TasksService {
private baseUrl: string = environment.apiBaseUrl
loading: boolean
private fileTasks: PaperlessTask[] = []
public get total(): number {
return this.fileTasks?.length
}
public get allFileTasks(): PaperlessTask[] {
return this.fileTasks.slice(0)
}
public get queuedFileTasks(): PaperlessTask[] {
return this.fileTasks.filter((t) => t.status == PaperlessTaskStatus.Queued)
}
public get startedFileTasks(): PaperlessTask[] {
return this.fileTasks.filter((t) => t.status == PaperlessTaskStatus.Started)
}
public get completedFileTasks(): PaperlessTask[] {
return this.fileTasks.filter(
(t) => t.status == PaperlessTaskStatus.Complete
)
}
public get failedFileTasks(): PaperlessTask[] {
return this.fileTasks.filter((t) => t.status == PaperlessTaskStatus.Failed)
}
constructor(private http: HttpClient) {}
public reload() {
this.loading = true
this.http
.get<PaperlessTask[]>(`${this.baseUrl}tasks/`)
.pipe(first())
.subscribe((r) => {
this.fileTasks = r.filter((t) => t.type == PaperlessTaskType.File) // they're all File tasks, for now
this.loading = false
})
}
public dismissTasks(task_ids: Set<number>) {
this.http
.post(`${this.baseUrl}acknowledge_tasks/`, {
tasks: [...task_ids],
})
.pipe(first())
.subscribe((r) => {
this.reload()
})
}
}

View File

@ -0,0 +1,69 @@
# Generated by Django 4.0.4 on 2022-05-23 07:14
from django.db import migrations, models
import django.db.models.deletion
def init_paperless_tasks(apps, schema_editor):
PaperlessTask = apps.get_model("documents", "PaperlessTask")
Task = apps.get_model("django_q", "Task")
for task in Task.objects.filter(func="documents.tasks.consume_file"):
if not hasattr(task, "paperlesstask"):
paperlesstask = PaperlessTask.objects.create(
attempted_task=task,
task_id=task.id,
name=task.name,
created=task.started,
started=task.started,
acknowledged=True,
)
task.paperlesstask = paperlesstask
task.save()
class Migration(migrations.Migration):
dependencies = [
("django_q", "0014_schedule_cluster"),
("documents", "1021_webp_thumbnail_conversion"),
]
operations = [
migrations.CreateModel(
name="PaperlessTask",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("task_id", models.CharField(max_length=128)),
("name", models.CharField(max_length=256)),
(
"created",
models.DateTimeField(auto_now=True, verbose_name="created"),
),
(
"started",
models.DateTimeField(null=True, verbose_name="started"),
),
("acknowledged", models.BooleanField(default=False)),
(
"attempted_task",
models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="attempted_task",
to="django_q.task",
),
),
],
),
migrations.RunPython(init_paperless_tasks, migrations.RunPython.noop),
]

View File

@ -12,6 +12,7 @@ from django.contrib.auth.models import User
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_q.tasks import Task
from documents.parsers import get_default_file_extension
@ -510,3 +511,19 @@ class UiSettings(models.Model):
def __str__(self):
return self.user.username
class PaperlessTask(models.Model):
task_id = models.CharField(max_length=128)
name = models.CharField(max_length=256)
created = models.DateTimeField(_("created"), auto_now=True)
started = models.DateTimeField(_("started"), null=True)
attempted_task = models.OneToOneField(
Task,
on_delete=models.CASCADE,
related_name="attempted_task",
null=True,
blank=True,
)
acknowledged = models.BooleanField(default=False)

View File

@ -18,6 +18,7 @@ from .models import Correspondent
from .models import Document
from .models import DocumentType
from .models import MatchingModel
from .models import PaperlessTask
from .models import SavedView
from .models import SavedViewFilterRule
from .models import StoragePath
@ -612,3 +613,65 @@ class UiSettingsViewSerializer(serializers.ModelSerializer):
defaults={"settings": validated_data.get("settings", None)},
)
return ui_settings
class TasksViewSerializer(serializers.ModelSerializer):
class Meta:
model = PaperlessTask
depth = 1
fields = "__all__"
type = serializers.SerializerMethodField()
def get_type(self, obj):
# just file tasks, for now
return "file"
result = serializers.SerializerMethodField()
def get_result(self, obj):
result = ""
if hasattr(obj, "attempted_task") and obj.attempted_task:
result = obj.attempted_task.result
return result
status = serializers.SerializerMethodField()
def get_status(self, obj):
if obj.attempted_task is None:
if obj.started:
return "started"
else:
return "queued"
elif obj.attempted_task.success:
return "complete"
elif not obj.attempted_task.success:
return "failed"
else:
return "unknown"
class AcknowledgeTasksViewSerializer(serializers.Serializer):
tasks = serializers.ListField(
required=True,
label="Tasks",
write_only=True,
child=serializers.IntegerField(),
)
def _validate_task_id_list(self, tasks, name="tasks"):
pass
if not type(tasks) == list:
raise serializers.ValidationError(f"{name} must be a list")
if not all([type(i) == int for i in tasks]):
raise serializers.ValidationError(f"{name} must be a list of integers")
count = PaperlessTask.objects.filter(id__in=tasks).count()
if not count == len(tasks):
raise serializers.ValidationError(
f"Some tasks in {name} don't exist or were specified twice.",
)
def validate_tasks(self, tasks):
self._validate_task_id_list(tasks)
return tasks

View File

@ -2,6 +2,7 @@ import logging
import os
import shutil
import django_q
from django.conf import settings
from django.contrib.admin.models import ADDITION
from django.contrib.admin.models import LogEntry
@ -21,6 +22,7 @@ from ..file_handling import delete_empty_directories
from ..file_handling import generate_unique_filename
from ..models import Document
from ..models import MatchingModel
from ..models import PaperlessTask
from ..models import Tag
@ -499,3 +501,36 @@ def add_to_index(sender, document, **kwargs):
from documents import index
index.add_or_update_document(document)
@receiver(django_q.signals.pre_enqueue)
def init_paperless_task(sender, task, **kwargs):
if task["func"] == "documents.tasks.consume_file":
paperless_task, created = PaperlessTask.objects.get_or_create(
task_id=task["id"],
)
paperless_task.name = task["name"]
paperless_task.created = task["started"]
paperless_task.save()
@receiver(django_q.signals.pre_execute)
def paperless_task_started(sender, task, **kwargs):
try:
if task["func"] == "documents.tasks.consume_file":
paperless_task = PaperlessTask.objects.get(task_id=task["id"])
paperless_task.started = timezone.now()
paperless_task.save()
except PaperlessTask.DoesNotExist:
pass
@receiver(models.signals.post_save, sender=django_q.models.Task)
def update_paperless_task(sender, instance, **kwargs):
try:
if instance.func == "documents.tasks.consume_file":
paperless_task = PaperlessTask.objects.get(task_id=instance.id)
paperless_task.attempted_task = instance
paperless_task.save()
except PaperlessTask.DoesNotExist:
pass

View File

@ -5,6 +5,7 @@ import os
import shutil
import tempfile
import urllib.request
import uuid
import zipfile
from unittest import mock
from unittest.mock import MagicMock
@ -19,12 +20,14 @@ from django.conf import settings
from django.contrib.auth.models import User
from django.test import override_settings
from django.utils import timezone
from django_q.models import Task
from documents import bulk_edit
from documents import index
from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
from documents.models import MatchingModel
from documents.models import PaperlessTask
from documents.models import SavedView
from documents.models import StoragePath
from documents.models import Tag
@ -2645,3 +2648,55 @@ class TestApiStoragePaths(DirectoriesMixin, APITestCase):
)
self.assertEqual(response.status_code, 400)
self.assertEqual(StoragePath.objects.count(), 1)
class TestTasks(APITestCase):
ENDPOINT = "/api/tasks/"
ENDPOINT_ACKOWLEDGE = "/api/acknowledge_tasks/"
def setUp(self):
super().setUp()
self.user = User.objects.create_superuser(username="temp_admin")
self.client.force_authenticate(user=self.user)
def test_get_tasks(self):
task_id1 = str(uuid.uuid4())
PaperlessTask.objects.create(task_id=task_id1)
Task.objects.create(
id=task_id1,
started=timezone.now() - datetime.timedelta(seconds=30),
stopped=timezone.now(),
func="documents.tasks.consume_file",
)
task_id2 = str(uuid.uuid4())
PaperlessTask.objects.create(task_id=task_id2)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 2)
returned_task1 = response.data[1]
returned_task2 = response.data[0]
self.assertEqual(returned_task1["task_id"], task_id1)
self.assertEqual(returned_task1["status"], "complete")
self.assertIsNotNone(returned_task1["attempted_task"])
self.assertEqual(returned_task2["task_id"], task_id2)
self.assertEqual(returned_task2["status"], "queued")
self.assertIsNone(returned_task2["attempted_task"])
def test_acknowledge_tasks(self):
task_id = str(uuid.uuid4())
task = PaperlessTask.objects.create(task_id=task_id)
response = self.client.get(self.ENDPOINT)
self.assertEqual(len(response.data), 1)
response = self.client.post(
self.ENDPOINT_ACKOWLEDGE,
{"tasks": [task.id]},
)
self.assertEqual(response.status_code, 200)
response = self.client.get(self.ENDPOINT)
self.assertEqual(len(response.data), 0)

View File

@ -46,6 +46,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet
from rest_framework.viewsets import ModelViewSet
from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework.viewsets import ViewSet
from .bulk_download import ArchiveOnlyStrategy
@ -64,10 +65,12 @@ from .matching import match_tags
from .models import Correspondent
from .models import Document
from .models import DocumentType
from .models import PaperlessTask
from .models import SavedView
from .models import StoragePath
from .models import Tag
from .parsers import get_parser_class_for_mime_type
from .serialisers import AcknowledgeTasksViewSerializer
from .serialisers import BulkDownloadSerializer
from .serialisers import BulkEditSerializer
from .serialisers import CorrespondentSerializer
@ -79,6 +82,7 @@ from .serialisers import SavedViewSerializer
from .serialisers import StoragePathSerializer
from .serialisers import TagSerializer
from .serialisers import TagSerializerVersion1
from .serialisers import TasksViewSerializer
from .serialisers import UiSettingsViewSerializer
logger = logging.getLogger("paperless.api")
@ -796,3 +800,37 @@ class UiSettingsView(GenericAPIView):
"success": True,
},
)
class TasksViewSet(ReadOnlyModelViewSet):
permission_classes = (IsAuthenticated,)
serializer_class = TasksViewSerializer
queryset = (
PaperlessTask.objects.filter(
acknowledged=False,
)
.order_by("created")
.reverse()
)
class AcknowledgeTasksView(GenericAPIView):
permission_classes = (IsAuthenticated,)
serializer_class = AcknowledgeTasksViewSerializer
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
tasks = serializer.validated_data.get("tasks")
try:
result = PaperlessTask.objects.filter(id__in=tasks).update(
acknowledged=True,
)
return Response({"result": result})
except Exception:
return HttpResponseBadRequest()

View File

@ -7,6 +7,7 @@ from django.urls import re_path
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import RedirectView
from documents.views import AcknowledgeTasksView
from documents.views import BulkDownloadView
from documents.views import BulkEditView
from documents.views import CorrespondentViewSet
@ -21,6 +22,7 @@ from documents.views import SelectionDataView
from documents.views import StatisticsView
from documents.views import StoragePathViewSet
from documents.views import TagViewSet
from documents.views import TasksViewSet
from documents.views import UiSettingsView
from documents.views import UnifiedSearchViewSet
from paperless.consumers import StatusConsumer
@ -36,6 +38,7 @@ api_router.register(r"logs", LogViewSet, basename="logs")
api_router.register(r"tags", TagViewSet)
api_router.register(r"saved_views", SavedViewViewSet)
api_router.register(r"storage_paths", StoragePathViewSet)
api_router.register(r"tasks", TasksViewSet, basename="tasks")
urlpatterns = [
@ -86,6 +89,11 @@ urlpatterns = [
UiSettingsView.as_view(),
name="ui_settings",
),
re_path(
r"^acknowledge_tasks/",
AcknowledgeTasksView.as_view(),
name="acknowledge_tasks",
),
path("token/", views.obtain_auth_token),
]
+ api_router.urls,