mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-21 23:39:28 +00:00
Compare commits
8 Commits
feature-up
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89a9e7f190 | ||
|
|
c669c3416e | ||
|
|
88430c8ab7 | ||
|
|
edfebcbe44 | ||
|
|
a89cd2d5d9 | ||
|
|
02e913b475 | ||
|
|
6017b11c42 | ||
|
|
ffaa2bb77a |
@@ -4428,23 +4428,23 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">176</context>
|
||||
<context context-type="linenumber">172</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">210</context>
|
||||
<context context-type="linenumber">206</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">244</context>
|
||||
<context context-type="linenumber">240</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">254</context>
|
||||
<context context-type="linenumber">250</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">292</context>
|
||||
<context context-type="linenumber">288</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
|
||||
@@ -6256,7 +6256,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">311</context>
|
||||
<context context-type="linenumber">307</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||
@@ -6983,75 +6983,75 @@
|
||||
<source>Last Updated</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">174</context>
|
||||
<context context-type="linenumber">170</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="46628344485199198" datatype="html">
|
||||
<source>Classifier</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">179</context>
|
||||
<context context-type="linenumber">175</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="9127131074422113272" datatype="html">
|
||||
<source>Run Task</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">201</context>
|
||||
<context context-type="linenumber">197</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">235</context>
|
||||
<context context-type="linenumber">231</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">283</context>
|
||||
<context context-type="linenumber">279</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6096684179126491743" datatype="html">
|
||||
<source>Last Trained</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">208</context>
|
||||
<context context-type="linenumber">204</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6427836860962380759" datatype="html">
|
||||
<source>Sanity Checker</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">213</context>
|
||||
<context context-type="linenumber">209</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6578747070254776938" datatype="html">
|
||||
<source>Last Run</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">242</context>
|
||||
<context context-type="linenumber">238</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">290</context>
|
||||
<context context-type="linenumber">286</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5921685253729220446" datatype="html">
|
||||
<source>WebSocket Connection</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">247</context>
|
||||
<context context-type="linenumber">243</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8998179362936748717" datatype="html">
|
||||
<source>OK</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">251</context>
|
||||
<context context-type="linenumber">247</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3804349597565969872" datatype="html">
|
||||
<source>AI Index</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
|
||||
<context context-type="linenumber">260</context>
|
||||
<context context-type="linenumber">256</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6732151329960766506" datatype="html">
|
||||
|
||||
@@ -219,7 +219,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
{
|
||||
anchorId: 'tour.file-tasks',
|
||||
content: $localize`Tasks helps you track background work, what needs attention, and what recently completed.`,
|
||||
content: $localize`File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process.`,
|
||||
route: '/tasks',
|
||||
backdropConfig: {
|
||||
offset: 0,
|
||||
|
||||
@@ -1,16 +1,41 @@
|
||||
<pngx-page-header
|
||||
title="Tasks"
|
||||
title="File Tasks"
|
||||
i18n-title
|
||||
info="Tasks shows detailed information about document consumption and system tasks."
|
||||
info="File Tasks shows you documents that have been consumed, are waiting to be, or may have failed during the process."
|
||||
i18n-info
|
||||
>
|
||||
<div class="btn-toolbar col col-md-auto align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
|
||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="tasksService.total === 0">
|
||||
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
|
||||
</button>
|
||||
<div class="form-inline d-flex align-items-center">
|
||||
<div class="input-group input-group-sm flex-fill w-auto flex-nowrap">
|
||||
<span class="input-group-text text-muted" i18n>Filter by</span>
|
||||
@if (filterTargets.length > 1) {
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{filterTargetName}}</button>
|
||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||
@for (t of filterTargets; track t.id) {
|
||||
<button ngbDropdownItem [class.active]="filterTargetID === t.id" (click)="filterTargetID = t.id">{{t.name}}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="input-group-text">{{filterTargetName}}</span>
|
||||
}
|
||||
@if (filterText?.length) {
|
||||
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
</button>
|
||||
}
|
||||
<input #filterInput class="form-control form-control-sm" type="text"
|
||||
(keyup)="filterInputKeyup($event)"
|
||||
[(ngModel)]="filterText">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-0 ms-2">
|
||||
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||
@@ -23,250 +48,133 @@
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
}
|
||||
|
||||
<div class="task-controls mb-3 btn-toolbar">
|
||||
<div class="task-view-scope btn-group btn-group-sm me-3" role="group">
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
[checked]="selectedSection === TaskSection.All"
|
||||
id="section-all"
|
||||
(click)="setSection(TaskSection.All)"
|
||||
(keydown)="setSection(TaskSection.All)" />
|
||||
<label class="btn btn-outline-primary" for="section-all">
|
||||
<ng-container i18n>All</ng-container>
|
||||
</label>
|
||||
@for (section of sections; track section) {
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
[checked]="selectedSection === section"
|
||||
id="section-{{section}}"
|
||||
(click)="setSection(section)"
|
||||
(keydown)="setSection(section)" />
|
||||
<label class="btn btn-outline-primary" for="section-{{section}}">
|
||||
{{ sectionLabel(section) }}
|
||||
@if (sectionCount(section) > 0) {
|
||||
<span class="badge ms-2" [class.bg-danger]="section === TaskSection.NeedsAttention" [class.bg-secondary]="section !== TaskSection.NeedsAttention">{{sectionCount(section)}}</span>
|
||||
<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" [(ngModel)]="togggleAll" (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>
|
||||
@if (activeTab !== 'started' && activeTab !== 'queued') {
|
||||
<th scope="col" class="d-none d-lg-table-cell" i18n>Results</th>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary me-3" ngbDropdownToggle>{{selectedTaskTypeLabel}}</button>
|
||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||
<button ngbDropdownItem [class.active]="selectedTaskType === null" (click)="setTaskType(null)" i18n>All types</button>
|
||||
@for (option of taskTypeOptions; track option.value) {
|
||||
<button ngbDropdownItem [class.active]="selectedTaskType === option.value" [disabled]="isTaskTypeOptionDisabled(option.value)" (click)="setTaskType(option.value)">{{option.label}}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary me-3" ngbDropdownToggle>{{selectedTriggerSourceLabel}}</button>
|
||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||
<button ngbDropdownItem [class.active]="selectedTriggerSource === null" (click)="setTriggerSource(null)" i18n>All sources</button>
|
||||
@for (option of triggerSourceOptions; track option.value) {
|
||||
<button ngbDropdownItem [class.active]="selectedTriggerSource === option.value" [disabled]="isTriggerSourceOptionDisabled(option.value)" (click)="setTriggerSource(option.value)">{{option.label}}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-inline d-flex align-items-center flex-grow-1 task-search">
|
||||
<div class="input-group input-group-sm flex-fill w-auto flex-nowrap">
|
||||
<span class="input-group-text text-muted" i18n>Filter by</span>
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{filterTargetName}}</button>
|
||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||
@for (t of filterTargets; track t.id) {
|
||||
<button ngbDropdownItem [class.active]="filterTargetID === t.id" (click)="filterTargetID = t.id">{{t.name}}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (filterText?.length) {
|
||||
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
</button>
|
||||
}
|
||||
<input #filterInput class="form-control form-control-sm" type="text"
|
||||
(keyup)="filterInputKeyup($event)"
|
||||
[(ngModel)]="filterText">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isFiltered) {
|
||||
<button class="btn btn-link py-0 ms-md-auto" (click)="resetFilters()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ng-template let-tasks="tasks" let-section="section" #tasksTemplate>
|
||||
<div class="section-header d-flex align-items-center justify-content-between mb-2">
|
||||
<div>
|
||||
<h5 class="mb-0">{{ sectionLabel(section) }}</h5>
|
||||
<div class="small text-muted">
|
||||
<ng-container i18n>{tasks.length, plural, =1 {1 task} other {{{tasks.length}} tasks}}</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border table-responsive mb-3">
|
||||
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<th scope="col" class="d-table-cell d-lg-none" i18n>Info</th>
|
||||
<th scope="col" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (task of tasks | slice: (page-1) * pageSize : page * pageSize; track task.id) {
|
||||
<tr (click)="toggleSelected(task, $event); $event.stopPropagation();">
|
||||
<td>
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
[id]="'all-tasks-' + section"
|
||||
[disabled]="tasks.length === 0"
|
||||
[checked]="areAllSelected(tasks)"
|
||||
(click)="toggleSection(section, $event); $event.stopPropagation();"
|
||||
(keydown)="toggleSection(section, $event); $event.stopPropagation();" />
|
||||
<label class="form-check-label" for="all-tasks-{{section}}"><span class="visually-hidden">Check all</span></label>
|
||||
<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>
|
||||
<th scope="col" i18n>Name</th>
|
||||
<th scope="col" class="d-none d-lg-table-cell" i18n>Created</th>
|
||||
@if (sectionShowsResults(section)) {
|
||||
<th scope="col" class="d-none d-lg-table-cell" 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>
|
||||
@for (task of tasks; track task.id) {
|
||||
<tr (click)="toggleSelected(task); $event.stopPropagation();" (keydown)="toggleSelected(task); $event.stopPropagation();">
|
||||
<td>
|
||||
<div class="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-check-input"
|
||||
id="task{{task.id}}"
|
||||
[checked]="selectedTasks.has(task.id)"
|
||||
(click)="toggleSelected(task); $event.stopPropagation();"
|
||||
(keydown)="toggleSelected(task); $event.stopPropagation();" />
|
||||
<label class="form-check-label" for="task{{task.id}}"></label>
|
||||
</div>
|
||||
</td>
|
||||
<td class="overflow-auto name-col">
|
||||
<div>{{ taskDisplayName(task) }}</div>
|
||||
<div class="small text-muted">
|
||||
@if (taskShowsSeparateTypeLabel(task)) {
|
||||
<span>{{ task.task_type_display }}</span>
|
||||
<span class="mx-1">•</span>
|
||||
}
|
||||
<span>{{ task.trigger_source_display }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
|
||||
@if (sectionShowsResults(section)) {
|
||||
<td class="d-none d-lg-table-cell">
|
||||
@if (taskHasLongResultMessage(task)) {
|
||||
<div 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">{{ taskResultPreview(task) }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (taskHasResultMessage(task) && !taskHasLongResultMessage(task)) {
|
||||
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ taskResultMessage(task) }}</span>
|
||||
}
|
||||
@if (duplicateDocumentId(task)) {
|
||||
<div class="small text-warning-emphasis d-flex align-items-center gap-1 mt-1">
|
||||
<i-bs class="lh-1" width="1em" height="1em" name="exclamation-triangle"></i-bs>
|
||||
<span>{{ duplicateTaskLabel(task) }}</span>
|
||||
</div>
|
||||
}
|
||||
<ng-template #resultPopover>
|
||||
<pre class="small mb-0">{{ taskResultPopoverMessage(task) }}@if (taskResultMessageOverflowsPopover(task)) {
|
||||
…
|
||||
}</pre>
|
||||
@if (taskResultMessageOverflowsPopover(task)) {
|
||||
<br/><em>(<ng-container i18n>click for full output</ng-container>)</em>
|
||||
}
|
||||
</ng-template>
|
||||
</td>
|
||||
}
|
||||
<td class="d-lg-none">
|
||||
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();">
|
||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||
</button>
|
||||
</td>
|
||||
<td scope="row">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
||||
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
|
||||
</button>
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
@if (task.related_document_ids?.[0]) {
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();">
|
||||
<i-bs name="file-text" class="me-1"></i-bs><ng-container i18n>Open Document</ng-container>
|
||||
</button>
|
||||
}
|
||||
</ng-container>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p-0" [class.border-0]="expandedTask !== task.id" [attr.colspan]="sectionShowsResults(section) ? 5 : 4">
|
||||
<div #collapse="ngbCollapse" [ngbCollapse]="expandedTask !== task.id" class="task-detail-panel bg-darker small mb-0">
|
||||
<div class="p-2 p-lg-3 ms-lg-3">
|
||||
@if (taskHasResultMessage(task)) {
|
||||
<div class="detail-section mb-3">
|
||||
<div class="detail-label fs-7 fw-bold text-uppercase text-muted mb-1" i18n>Result message</div>
|
||||
<pre class="detail-block border border-dark bg-body p-3 rounded-2 mb-0">{{ taskResultMessage(task) }}</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (duplicateDocumentId(task); as duplicateDocumentId) {
|
||||
<div class="detail-section mb-3">
|
||||
<div class="detail-label fs-7 fw-bold text-uppercase text-muted mb-1" i18n>Duplicate</div>
|
||||
<div class="detail-block border border-dark bg-body p-3 rounded-2 mb-0">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
||||
<div class="text-break">{{ duplicateTaskLabel(task) }}</div>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
type="button"
|
||||
(click)="openDuplicateDocument(duplicateDocumentId)">
|
||||
<ng-container i18n>Open</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="detail-section h-100">
|
||||
<div class="detail-label fs-7 fw-bold text-uppercase text-muted mb-1" i18n>Input data</div>
|
||||
<pre class="detail-block border border-dark bg-body p-3 rounded-2 mb-0">{{ task.input_data | json }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="detail-section h-100">
|
||||
<div class="detail-label fs-7 fw-bold text-uppercase text-muted mb-1" i18n>Result data</div>
|
||||
<pre class="detail-block border border-dark bg-body p-3 rounded-2 mb-0">{{ (task.result_data ?? {}) | json }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="overflow-auto name-col">{{ task.input_data?.filename }}</td>
|
||||
<td class="d-none d-lg-table-cell">{{ task.date_created | customDate:'short' }}</td>
|
||||
@if (activeTab !== 'started' && activeTab !== 'queued') {
|
||||
<td class="d-none d-lg-table-cell">
|
||||
@if (task.result_message?.length > 50) {
|
||||
<div 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_message | slice:0:50 }}…</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (task.result_message?.length <= 50) {
|
||||
<span class="small d-none d-md-inline-block font-monospace text-muted">{{ task.result_message }}</span>
|
||||
}
|
||||
<ng-template #resultPopover>
|
||||
<pre class="small mb-0">{{ task.result_message | slice:0:300 }}@if (task.result_message.length > 300) {
|
||||
…
|
||||
}</pre>
|
||||
@if (task.result_message?.length > 300) {
|
||||
<br/><em>(<ng-container i18n>click for full output</ng-container>)</em>
|
||||
}
|
||||
</ng-template>
|
||||
</td>
|
||||
}
|
||||
<td class="d-lg-none">
|
||||
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();">
|
||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||
</button>
|
||||
</td>
|
||||
<td scope="row">
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
||||
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
|
||||
</button>
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
@if (task.related_document_ids?.[0]) {
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();">
|
||||
<i-bs name="file-text" class="me-1"></i-bs><ng-container i18n>Open Document</ng-container>
|
||||
</button>
|
||||
}
|
||||
</ng-container>
|
||||
</div>
|
||||
</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_message }}</div></pre>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pb-3 d-sm-flex justify-content-between align-items-center">
|
||||
@if (tasks.length > 0) {
|
||||
<div class="pb-2 pb-sm-0">
|
||||
<ng-container i18n>{tasks.length, plural, =1 {One {{this.activeTabLocalized}} task} other {{{tasks.length || 0}} total {{this.activeTabLocalized}} tasks}}</ng-container>
|
||||
@if (selectedTasks.size > 0) {
|
||||
<ng-container i18n> ({{selectedTasks.size}} selected)</ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (tasks.length > pageSize) {
|
||||
<ngb-pagination [(page)]="page" [pageSize]="pageSize" [collectionSize]="tasks.length" maxSize="8" size="sm"></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@if (visibleSections.length > 0) {
|
||||
@for (section of visibleSections; track section) {
|
||||
<div class="mb-4">
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks: tasksForSection(section), section: section}"></ng-container>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="alert alert-secondary fst-italic" i18n>No tasks match the current filters.</div>
|
||||
}
|
||||
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-tabs" (hidden)="duringTabChange()" (navChange)="beforeTabChange()">
|
||||
<li ngbNavItem="failed">
|
||||
<a ngbNavLink i18n>Failed@if (tasksService.failedFileTasks.length > 0) {
|
||||
<span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span>
|
||||
}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li ngbNavItem="completed">
|
||||
<a ngbNavLink i18n>Complete@if (tasksService.completedFileTasks.length > 0) {
|
||||
<span class="badge bg-secondary ms-2">{{tasksService.completedFileTasks.length}}</span>
|
||||
}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li ngbNavItem="started">
|
||||
<a ngbNavLink i18n>Started@if (tasksService.startedFileTasks.length > 0) {
|
||||
<span class="badge bg-secondary ms-2">{{tasksService.startedFileTasks.length}}</span>
|
||||
}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
<li ngbNavItem="queued">
|
||||
<a ngbNavLink i18n>Queued@if (tasksService.queuedFileTasks.length > 0) {
|
||||
<span class="badge bg-secondary ms-2">{{tasksService.queuedFileTasks.length}}</span>
|
||||
}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks:currentTasks}"></ng-container>
|
||||
</ng-template>
|
||||
</li>
|
||||
</ul>
|
||||
<div [ngbNavOutlet]="nav"></div>
|
||||
|
||||
@@ -37,7 +37,3 @@ pre {
|
||||
.z-10 {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
tbody tr:nth-last-child(2) td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ import { FormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbModalRef,
|
||||
NgbModule,
|
||||
NgbNavItem,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
@@ -28,7 +33,7 @@ import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { TasksComponent, TaskSection } from './tasks.component'
|
||||
import { TasksComponent, TaskTab } from './tasks.component'
|
||||
|
||||
const tasks: PaperlessTask[] = [
|
||||
{
|
||||
@@ -43,10 +48,8 @@ const tasks: PaperlessTask[] = [
|
||||
trigger_source_display: 'Folder Consume',
|
||||
status: PaperlessTaskStatus.Failure,
|
||||
status_display: 'Failure',
|
||||
result_data: {
|
||||
error_message:
|
||||
'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)',
|
||||
},
|
||||
result_message:
|
||||
'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)',
|
||||
acknowledged: false,
|
||||
related_document_ids: [],
|
||||
},
|
||||
@@ -62,7 +65,8 @@ const tasks: PaperlessTask[] = [
|
||||
trigger_source_display: 'Folder Consume',
|
||||
status: PaperlessTaskStatus.Failure,
|
||||
status_display: 'Failure',
|
||||
result_data: { duplicate_of: 311 },
|
||||
result_message:
|
||||
'191092.pd: Not consuming 191092.pdf: It is a duplicate of 191092 (#311)',
|
||||
acknowledged: false,
|
||||
related_document_ids: [],
|
||||
},
|
||||
@@ -78,7 +82,7 @@ const tasks: PaperlessTask[] = [
|
||||
trigger_source_display: 'Folder Consume',
|
||||
status: PaperlessTaskStatus.Pending,
|
||||
status_display: 'Pending',
|
||||
result_data: null,
|
||||
result_message: null,
|
||||
acknowledged: false,
|
||||
related_document_ids: [],
|
||||
},
|
||||
@@ -94,7 +98,7 @@ const tasks: PaperlessTask[] = [
|
||||
trigger_source_display: 'Email Consume',
|
||||
status: PaperlessTaskStatus.Success,
|
||||
status_display: 'Success',
|
||||
result_data: { document_id: 422, duplicate_of: 99 },
|
||||
result_message: 'Success. New document id 422 created',
|
||||
acknowledged: false,
|
||||
related_document_ids: [422],
|
||||
},
|
||||
@@ -110,7 +114,7 @@ const tasks: PaperlessTask[] = [
|
||||
trigger_source_display: 'Folder Consume',
|
||||
status: PaperlessTaskStatus.Success,
|
||||
status_display: 'Success',
|
||||
result_data: { document_id: 421 },
|
||||
result_message: 'Success. New document id 421 created',
|
||||
acknowledged: false,
|
||||
related_document_ids: [421],
|
||||
},
|
||||
@@ -126,23 +130,7 @@ const tasks: PaperlessTask[] = [
|
||||
trigger_source_display: 'Email Consume',
|
||||
status: PaperlessTaskStatus.Started,
|
||||
status_display: 'Started',
|
||||
result_data: null,
|
||||
acknowledged: false,
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
id: 461,
|
||||
task_id: 'bb79efb3-1e78-4f31-b4be-0966620b0ce1',
|
||||
input_data: { dry_run: false, scope: 'global' },
|
||||
date_created: new Date('2023-06-07T03:54:35.694916Z'),
|
||||
date_done: null,
|
||||
task_type: PaperlessTaskType.SanityCheck,
|
||||
task_type_display: 'Sanity Check',
|
||||
trigger_source: PaperlessTaskTriggerSource.System,
|
||||
trigger_source_display: 'System',
|
||||
status: PaperlessTaskStatus.Started,
|
||||
status_display: 'Started',
|
||||
result_data: { issues_found: 0 },
|
||||
result_message: null,
|
||||
acknowledged: false,
|
||||
related_document_ids: [],
|
||||
},
|
||||
@@ -197,142 +185,59 @@ describe('TasksComponent', () => {
|
||||
jest.useFakeTimers()
|
||||
fixture.detectChanges()
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}tasks/?acknowledged=false`)
|
||||
.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false`
|
||||
)
|
||||
.flush(tasks)
|
||||
})
|
||||
|
||||
it('should display task sections with counts', () => {
|
||||
expect(component.selectedSection).toBe(TaskSection.All)
|
||||
expect(component.selectedTaskType).toBeNull()
|
||||
expect(component.selectedTriggerSource).toBeNull()
|
||||
it('should display file tasks in 4 tabs by status', () => {
|
||||
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavItem))
|
||||
|
||||
let currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Failure
|
||||
).length
|
||||
component.activeTab = TaskTab.Failed
|
||||
fixture.detectChanges()
|
||||
expect(tabButtons[0].nativeElement.textContent).toEqual(
|
||||
`Failed${currentTasksLength}`
|
||||
)
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('table input[type="checkbox"]'))
|
||||
).toHaveLength(currentTasksLength + 1)
|
||||
|
||||
const viewScope = fixture.debugElement.query(By.css('.task-view-scope'))
|
||||
const text = viewScope.nativeElement.textContent
|
||||
|
||||
expect(text).toContain('All')
|
||||
expect(text).toContain('Needs attention')
|
||||
expect(text).toContain('2')
|
||||
expect(text).toContain('In progress')
|
||||
expect(text).toContain('3')
|
||||
expect(text).toContain('Recently completed')
|
||||
})
|
||||
|
||||
it('should filter visible sections by selected status', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Success
|
||||
).length
|
||||
component.activeTab = TaskTab.Completed
|
||||
fixture.detectChanges()
|
||||
expect(tabButtons[1].nativeElement.textContent).toEqual(
|
||||
`Complete${currentTasksLength}`
|
||||
)
|
||||
|
||||
expect(component.visibleSections).toEqual([TaskSection.InProgress])
|
||||
expect(fixture.nativeElement.textContent).toContain('In progress')
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Recent completed')
|
||||
})
|
||||
currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Started
|
||||
).length
|
||||
component.activeTab = TaskTab.Started
|
||||
fixture.detectChanges()
|
||||
expect(tabButtons[2].nativeElement.textContent).toEqual(
|
||||
`Started${currentTasksLength}`
|
||||
)
|
||||
|
||||
it('should filter tasks by task type', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.setTaskType(PaperlessTaskType.SanityCheck)
|
||||
|
||||
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
|
||||
expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe(
|
||||
PaperlessTaskType.SanityCheck
|
||||
currentTasksLength = tasks.filter(
|
||||
(t) => t.status === PaperlessTaskStatus.Pending
|
||||
).length
|
||||
component.activeTab = TaskTab.Queued
|
||||
fixture.detectChanges()
|
||||
expect(tabButtons[3].nativeElement.textContent).toEqual(
|
||||
`Queued${currentTasksLength}`
|
||||
)
|
||||
})
|
||||
|
||||
it('should filter tasks by trigger source', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.setTriggerSource(PaperlessTaskTriggerSource.EmailConsume)
|
||||
|
||||
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
|
||||
expect(
|
||||
component.tasksForSection(TaskSection.InProgress)[0].trigger_source
|
||||
).toBe(PaperlessTaskTriggerSource.EmailConsume)
|
||||
})
|
||||
|
||||
it('should reset all active filters together', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.setTaskType(PaperlessTaskType.SanityCheck)
|
||||
component.setTriggerSource(PaperlessTaskTriggerSource.System)
|
||||
component.filterText = 'system'
|
||||
jest.advanceTimersByTime(150)
|
||||
|
||||
expect(component.isFiltered).toBe(true)
|
||||
|
||||
component.resetFilters()
|
||||
|
||||
expect(component.selectedSection).toBe(TaskSection.InProgress)
|
||||
expect(component.selectedTaskType).toBeNull()
|
||||
expect(component.selectedTriggerSource).toBeNull()
|
||||
expect(component.filterText).toBe('')
|
||||
expect(component.isFiltered).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep header controls focused on actions and auto refresh', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
const header = fixture.debugElement.query(By.css('pngx-page-header'))
|
||||
const headerText = header.nativeElement.textContent
|
||||
|
||||
expect(headerText).toContain('Dismiss visible')
|
||||
expect(headerText).toContain('Auto refresh')
|
||||
expect(headerText).not.toContain('All types')
|
||||
expect(headerText).not.toContain('All sources')
|
||||
expect(headerText).not.toContain('Reset filters')
|
||||
})
|
||||
|
||||
it('should render the view scope row above the filter bar', () => {
|
||||
fixture.detectChanges()
|
||||
|
||||
const controls = fixture.debugElement.query(By.css('.task-controls'))
|
||||
const viewScope = controls.query(By.css('.task-view-scope'))
|
||||
const search = controls.query(By.css('.task-search'))
|
||||
|
||||
expect(viewScope).not.toBeNull()
|
||||
expect(search).not.toBeNull()
|
||||
expect(
|
||||
viewScope.nativeElement.compareDocumentPosition(search.nativeElement) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should expose stable task type options and disable empty ones', () => {
|
||||
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
|
||||
PaperlessTaskType.TrainClassifier
|
||||
)
|
||||
expect(
|
||||
component.isTaskTypeOptionDisabled(PaperlessTaskType.TrainClassifier)
|
||||
).toBe(true)
|
||||
expect(
|
||||
component.isTaskTypeOptionDisabled(PaperlessTaskType.ConsumeFile)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('should fall back to the raw selected task type label when no option matches', () => {
|
||||
component.selectedTaskType = 'unknown_task_type' as PaperlessTaskType
|
||||
|
||||
expect(component.selectedTaskTypeLabel).toBe('unknown_task_type')
|
||||
})
|
||||
|
||||
it('should expose stable trigger source options and disable empty ones', () => {
|
||||
expect(
|
||||
component.triggerSourceOptions.map((option) => option.value)
|
||||
).toContain(PaperlessTaskTriggerSource.ApiUpload)
|
||||
expect(
|
||||
component.isTriggerSourceOptionDisabled(
|
||||
PaperlessTaskTriggerSource.ApiUpload
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
component.isTriggerSourceOptionDisabled(
|
||||
PaperlessTaskTriggerSource.EmailConsume
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('should fall back to the raw selected trigger source label when no option matches', () => {
|
||||
component.selectedTriggerSource =
|
||||
'unknown_trigger_source' as PaperlessTaskTriggerSource
|
||||
|
||||
expect(component.selectedTriggerSourceLabel).toBe('unknown_trigger_source')
|
||||
it('should to go page 1 between tab switch', () => {
|
||||
component.page = 10
|
||||
component.duringTabChange()
|
||||
expect(component.page).toEqual(1)
|
||||
})
|
||||
|
||||
it('should support expanding / collapsing one task at a time', () => {
|
||||
@@ -344,31 +249,6 @@ describe('TasksComponent', () => {
|
||||
expect(component.expandedTask).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should show structured task details when expanded', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.expandTask(tasks[6])
|
||||
fixture.detectChanges()
|
||||
|
||||
const detailText = fixture.nativeElement.textContent
|
||||
|
||||
expect(detailText).toContain('Input data')
|
||||
expect(detailText).toContain('Result data')
|
||||
expect(detailText).toContain('"scope": "global"')
|
||||
expect(detailText).toContain('"issues_found": 0')
|
||||
})
|
||||
|
||||
it('should show duplicate warnings and duplicate details when present', () => {
|
||||
component.setSection(TaskSection.Completed)
|
||||
component.expandTask(tasks[3])
|
||||
fixture.detectChanges()
|
||||
|
||||
const content = fixture.nativeElement.textContent
|
||||
|
||||
expect(content).toContain('Duplicate of document #99')
|
||||
expect(content).toContain('Duplicate')
|
||||
expect(content).toContain('Open')
|
||||
})
|
||||
|
||||
it('should support dismiss single task', () => {
|
||||
const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
|
||||
component.dismissTask(tasks[0])
|
||||
@@ -379,7 +259,7 @@ describe('TasksComponent', () => {
|
||||
component.toggleSelected(tasks[0])
|
||||
component.toggleSelected(tasks[1])
|
||||
component.toggleSelected(tasks[3])
|
||||
component.toggleSelected(tasks[3])
|
||||
component.toggleSelected(tasks[3]) // uncheck, for coverage
|
||||
const selected = new Set([tasks[0].id, tasks[1].id])
|
||||
expect(component.selectedTasks).toEqual(selected)
|
||||
let modal: NgbModalRef
|
||||
@@ -428,50 +308,31 @@ describe('TasksComponent', () => {
|
||||
expect(component.selectedTasks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should support dismiss visible tasks', () => {
|
||||
component.setSection(TaskSection.NeedsAttention)
|
||||
it('should support dismiss all tasks', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
|
||||
component.dismissTasks()
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.confirmClicked.emit()
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([467, 466]))
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set(tasks.map((t) => t.id)))
|
||||
})
|
||||
|
||||
it('should dismiss the currently visible scoped and filtered tasks', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.setTaskType(PaperlessTaskType.SanityCheck)
|
||||
component.setTriggerSource(PaperlessTaskTriggerSource.System)
|
||||
|
||||
const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
|
||||
|
||||
component.dismissTasks()
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([461]))
|
||||
})
|
||||
|
||||
it('should support toggling a full section', () => {
|
||||
component.setSection(TaskSection.NeedsAttention)
|
||||
fixture.detectChanges()
|
||||
|
||||
it('should support toggle all tasks', () => {
|
||||
const toggleCheck = fixture.debugElement.query(
|
||||
By.css('#all-tasks-needs_attention')
|
||||
By.css('table input[type=checkbox]')
|
||||
)
|
||||
toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
fixture.detectChanges()
|
||||
expect(component.selectedTasks).toEqual(
|
||||
new Set(
|
||||
tasks
|
||||
.filter((t) => t.status === PaperlessTaskStatus.Failure)
|
||||
.map((t) => t.id)
|
||||
)
|
||||
)
|
||||
expect(toggleCheck).not.toBeNull()
|
||||
toggleCheck.nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
fixture.detectChanges()
|
||||
expect(component.selectedTasks).toEqual(new Set([467, 466]))
|
||||
})
|
||||
|
||||
it('should remove a full section from selection when toggled off', () => {
|
||||
component.setSection(TaskSection.NeedsAttention)
|
||||
component.selectedTasks = new Set([467, 466])
|
||||
|
||||
component.toggleSection(TaskSection.NeedsAttention, {
|
||||
target: { checked: false },
|
||||
} as PointerEvent)
|
||||
|
||||
expect(component.selectedTasks).toEqual(new Set())
|
||||
})
|
||||
|
||||
@@ -494,127 +355,57 @@ describe('TasksComponent', () => {
|
||||
})
|
||||
|
||||
it('should filter tasks by file name', () => {
|
||||
fixture.detectChanges()
|
||||
const input = fixture.debugElement.query(
|
||||
By.css('.task-search input[type=text]')
|
||||
By.css('pngx-page-header input[type=text]')
|
||||
)
|
||||
expect(input).not.toBeNull()
|
||||
input.nativeElement.value = '191092'
|
||||
input.nativeElement.dispatchEvent(new Event('input'))
|
||||
jest.advanceTimersByTime(150)
|
||||
jest.advanceTimersByTime(150) // debounce time
|
||||
fixture.detectChanges()
|
||||
expect(component.filterText).toEqual('191092')
|
||||
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
|
||||
1
|
||||
)
|
||||
})
|
||||
|
||||
it('should match task type and source in name filtering', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
component.filterText = 'system'
|
||||
jest.advanceTimersByTime(150)
|
||||
|
||||
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
|
||||
expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe(
|
||||
PaperlessTaskType.SanityCheck
|
||||
)
|
||||
})
|
||||
|
||||
it('should fall back to task type when filename is unavailable', () => {
|
||||
component.setSection(TaskSection.InProgress)
|
||||
fixture.detectChanges()
|
||||
|
||||
const nameColumn = fixture.debugElement.queryAll(
|
||||
By.css('tbody td.name-col')
|
||||
)
|
||||
const sanityTaskRow = nameColumn.find((cell) =>
|
||||
cell.nativeElement.textContent.includes('Sanity Check')
|
||||
)
|
||||
|
||||
expect(sanityTaskRow.nativeElement.textContent).toContain('Sanity Check')
|
||||
expect(sanityTaskRow.nativeElement.textContent).toContain('System')
|
||||
expect(
|
||||
fixture.debugElement.queryAll(By.css('table tbody tr')).length
|
||||
).toEqual(2) // 1 task x 2 lines
|
||||
})
|
||||
|
||||
it('should filter tasks by result', () => {
|
||||
component.setSection(TaskSection.NeedsAttention)
|
||||
component.filterTargetID = 1
|
||||
component.activeTab = TaskTab.Failed
|
||||
fixture.detectChanges()
|
||||
component.filterTargetID = 1
|
||||
const input = fixture.debugElement.query(
|
||||
By.css('.task-search input[type=text]')
|
||||
By.css('pngx-page-header input[type=text]')
|
||||
)
|
||||
expect(input).not.toBeNull()
|
||||
input.nativeElement.value = 'duplicate'
|
||||
input.nativeElement.dispatchEvent(new Event('input'))
|
||||
jest.advanceTimersByTime(150)
|
||||
jest.advanceTimersByTime(150) // debounce time
|
||||
fixture.detectChanges()
|
||||
expect(component.filterText).toEqual('duplicate')
|
||||
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefer explicit reason in the result message', () => {
|
||||
expect(
|
||||
component.taskResultMessage({
|
||||
...tasks[0],
|
||||
result_data: { reason: 'Manual review required', duplicate_of: 311 },
|
||||
})
|
||||
).toBe('Manual review required')
|
||||
})
|
||||
|
||||
it('should return null preview and popover text when there is no result message', () => {
|
||||
expect(component.taskResultPreview(tasks[2])).toBeNull()
|
||||
expect(component.taskResultPopoverMessage(tasks[2])).toBe('')
|
||||
expect(component.taskResultMessageOverflowsPopover(tasks[2])).toBe(false)
|
||||
})
|
||||
|
||||
it('should navigate to a duplicate document details page', () => {
|
||||
const routerSpy = jest.spyOn(router, 'navigate')
|
||||
|
||||
component.openDuplicateDocument(99)
|
||||
|
||||
expect(routerSpy).toHaveBeenCalledWith(['documents', 99, 'details'])
|
||||
})
|
||||
|
||||
it('should report when a result message overflows the popover limit', () => {
|
||||
const longMessage = 'x'.repeat(350)
|
||||
const task = {
|
||||
...tasks[0],
|
||||
result_data: { error_message: longMessage },
|
||||
}
|
||||
|
||||
expect(component.taskResultPopoverMessage(task)).toBe(
|
||||
longMessage.slice(0, 300)
|
||||
)
|
||||
expect(component.taskResultMessageOverflowsPopover(task)).toBe(true)
|
||||
fixture.debugElement.queryAll(By.css('table tbody tr')).length
|
||||
).toEqual(4) // 2 tasks x 2 lines
|
||||
})
|
||||
|
||||
it('should support keyboard events for filtering', () => {
|
||||
fixture.detectChanges()
|
||||
const input = fixture.debugElement.query(
|
||||
By.css('.task-search input[type=text]')
|
||||
By.css('pngx-page-header input[type=text]')
|
||||
)
|
||||
expect(input).not.toBeNull()
|
||||
input.nativeElement.value = '191092'
|
||||
input.nativeElement.dispatchEvent(
|
||||
new KeyboardEvent('keyup', { key: 'Enter' })
|
||||
)
|
||||
expect(component.filterText).toEqual('191092')
|
||||
expect(component.filterText).toEqual('191092') // no debounce needed
|
||||
input.nativeElement.dispatchEvent(
|
||||
new KeyboardEvent('keyup', { key: 'Escape' })
|
||||
)
|
||||
expect(component.filterText).toEqual('')
|
||||
})
|
||||
|
||||
it('should keep clearing selection independent from resetting filters', () => {
|
||||
component.setTaskType(PaperlessTaskType.ConsumeFile)
|
||||
component.toggleSelected(tasks[0])
|
||||
expect(component.selectedTasks.size).toBe(1)
|
||||
|
||||
component.clearSelection()
|
||||
|
||||
expect(component.selectedTasks.size).toBe(0)
|
||||
expect(component.selectedTaskType).toBe(PaperlessTaskType.ConsumeFile)
|
||||
expect(component.isFiltered).toBe(true)
|
||||
it('should reset filter and target on tab switch', () => {
|
||||
component.filterText = '191092'
|
||||
component.filterTargetID = 1
|
||||
component.activeTab = TaskTab.Completed
|
||||
component.beforeTabChange()
|
||||
expect(component.filterText).toEqual('')
|
||||
expect(component.filterTargetID).toEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
|
||||
import { NgTemplateOutlet, SlicePipe } from '@angular/common'
|
||||
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { Router } from '@angular/router'
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
NgbCollapseModule,
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbNavModule,
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
@@ -18,12 +20,7 @@ import {
|
||||
takeUntil,
|
||||
timer,
|
||||
} from 'rxjs'
|
||||
import {
|
||||
PaperlessTask,
|
||||
PaperlessTaskStatus,
|
||||
PaperlessTaskTriggerSource,
|
||||
PaperlessTaskType,
|
||||
} from 'src/app/data/paperless-task'
|
||||
import { PaperlessTask } from 'src/app/data/paperless-task'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
@@ -32,11 +29,11 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
|
||||
export enum TaskSection {
|
||||
All = 'all',
|
||||
NeedsAttention = 'needs_attention',
|
||||
InProgress = 'in_progress',
|
||||
export enum TaskTab {
|
||||
Queued = 'queued',
|
||||
Started = 'started',
|
||||
Completed = 'completed',
|
||||
Failed = 'failed',
|
||||
}
|
||||
|
||||
enum TaskFilterTargetID {
|
||||
@@ -49,82 +46,6 @@ const FILTER_TARGETS = [
|
||||
{ id: TaskFilterTargetID.Result, name: $localize`Result` },
|
||||
]
|
||||
|
||||
const SECTION_LABELS = {
|
||||
[TaskSection.All]: $localize`All`,
|
||||
[TaskSection.NeedsAttention]: $localize`Needs attention`,
|
||||
[TaskSection.InProgress]: $localize`In progress`,
|
||||
[TaskSection.Completed]: $localize`Recently completed`,
|
||||
}
|
||||
|
||||
const TASK_TYPE_OPTIONS: Array<{
|
||||
value: PaperlessTaskType
|
||||
label: string
|
||||
}> = [
|
||||
{
|
||||
value: PaperlessTaskType.ConsumeFile,
|
||||
label: $localize`Consume File`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.TrainClassifier,
|
||||
label: $localize`Train Classifier`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.SanityCheck,
|
||||
label: $localize`Sanity Check`,
|
||||
},
|
||||
{ value: PaperlessTaskType.MailFetch, label: $localize`Mail Fetch` },
|
||||
{ value: PaperlessTaskType.LlmIndex, label: $localize`LLM Index` },
|
||||
{
|
||||
value: PaperlessTaskType.EmptyTrash,
|
||||
label: $localize`Empty Trash`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.CheckWorkflows,
|
||||
label: $localize`Check Workflows`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.BulkUpdate,
|
||||
label: $localize`Bulk Update`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.ReprocessDocument,
|
||||
label: $localize`Reprocess Document`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.BuildShareLink,
|
||||
label: $localize`Build Share Link`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskType.BulkDelete,
|
||||
label: $localize`Bulk Delete`,
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGER_SOURCE_OPTIONS: Array<{
|
||||
value: PaperlessTaskTriggerSource
|
||||
label: string
|
||||
}> = [
|
||||
{
|
||||
value: PaperlessTaskTriggerSource.Scheduled,
|
||||
label: $localize`Scheduled`,
|
||||
},
|
||||
{ value: PaperlessTaskTriggerSource.WebUI, label: $localize`Web UI` },
|
||||
{
|
||||
value: PaperlessTaskTriggerSource.ApiUpload,
|
||||
label: $localize`API Upload`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskTriggerSource.FolderConsume,
|
||||
label: $localize`Folder Consume`,
|
||||
},
|
||||
{
|
||||
value: PaperlessTaskTriggerSource.EmailConsume,
|
||||
label: $localize`Email Consume`,
|
||||
},
|
||||
{ value: PaperlessTaskTriggerSource.System, label: $localize`System` },
|
||||
{ value: PaperlessTaskTriggerSource.Manual, label: $localize`Manual` },
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-tasks',
|
||||
templateUrl: './tasks.component.html',
|
||||
@@ -133,12 +54,14 @@ const TRIGGER_SOURCE_OPTIONS: Array<{
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
CustomDatePipe,
|
||||
JsonPipe,
|
||||
SlicePipe,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgTemplateOutlet,
|
||||
NgbCollapseModule,
|
||||
NgbDropdownModule,
|
||||
NgbNavModule,
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
@@ -152,18 +75,15 @@ export class TasksComponent
|
||||
private readonly router = inject(Router)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
readonly TaskSection = TaskSection
|
||||
readonly sections = [
|
||||
TaskSection.NeedsAttention,
|
||||
TaskSection.InProgress,
|
||||
TaskSection.Completed,
|
||||
]
|
||||
public activeTab: TaskTab
|
||||
public selectedTasks: Set<number> = new Set()
|
||||
public togggleAll: boolean = false
|
||||
public expandedTask: number
|
||||
|
||||
public pageSize: number = 25
|
||||
public page: number = 1
|
||||
|
||||
public autoRefreshEnabled: boolean = true
|
||||
public selectedSection: TaskSection = TaskSection.All
|
||||
public selectedTaskType: PaperlessTaskType | null = null
|
||||
public selectedTriggerSource: PaperlessTaskTriggerSource | null = null
|
||||
|
||||
private _filterText: string = ''
|
||||
get filterText() {
|
||||
@@ -175,81 +95,20 @@ export class TasksComponent
|
||||
|
||||
public filterTargetID: TaskFilterTargetID = TaskFilterTargetID.Name
|
||||
public get filterTargetName(): string {
|
||||
return FILTER_TARGETS.find((t) => t.id == this.filterTargetID).name
|
||||
return this.filterTargets.find((t) => t.id == this.filterTargetID).name
|
||||
}
|
||||
private filterDebounce: Subject<string> = new Subject<string>()
|
||||
|
||||
public get filterTargets(): Array<{ id: number; name: string }> {
|
||||
return FILTER_TARGETS
|
||||
}
|
||||
|
||||
public get taskTypeOptions(): Array<{
|
||||
value: PaperlessTaskType
|
||||
label: string
|
||||
}> {
|
||||
return TASK_TYPE_OPTIONS
|
||||
}
|
||||
|
||||
public get triggerSourceOptions(): Array<{
|
||||
value: PaperlessTaskTriggerSource
|
||||
label: string
|
||||
}> {
|
||||
return TRIGGER_SOURCE_OPTIONS
|
||||
}
|
||||
|
||||
public get selectedTaskTypeLabel(): string {
|
||||
if (this.selectedTaskType === null) {
|
||||
return $localize`All types`
|
||||
}
|
||||
|
||||
return (
|
||||
this.taskTypeOptions.find(
|
||||
(option) => option.value === this.selectedTaskType
|
||||
)?.label ?? this.selectedTaskType
|
||||
)
|
||||
}
|
||||
|
||||
public get selectedTriggerSourceLabel(): string {
|
||||
if (this.selectedTriggerSource === null) {
|
||||
return $localize`All sources`
|
||||
}
|
||||
|
||||
return (
|
||||
this.triggerSourceOptions.find(
|
||||
(option) => option.value === this.selectedTriggerSource
|
||||
)?.label ?? this.selectedTriggerSource
|
||||
)
|
||||
return [TaskTab.Failed, TaskTab.Completed].includes(this.activeTab)
|
||||
? FILTER_TARGETS
|
||||
: FILTER_TARGETS.slice(0, 1)
|
||||
}
|
||||
|
||||
get dismissButtonText(): string {
|
||||
return this.selectedTasks.size > 0
|
||||
? $localize`Dismiss selected`
|
||||
: $localize`Dismiss visible`
|
||||
}
|
||||
|
||||
get visibleSections(): TaskSection[] {
|
||||
const sections =
|
||||
this.selectedSection === TaskSection.All
|
||||
? this.sections
|
||||
: [this.selectedSection]
|
||||
|
||||
return sections.filter(
|
||||
(section) => this.tasksForSection(section).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
get visibleTasks(): PaperlessTask[] {
|
||||
return this.visibleSections.flatMap((section) =>
|
||||
this.tasksForSection(section)
|
||||
)
|
||||
}
|
||||
|
||||
get isFiltered(): boolean {
|
||||
return (
|
||||
this.selectedTaskType !== null ||
|
||||
this.selectedTriggerSource !== null ||
|
||||
this._filterText.length > 0
|
||||
)
|
||||
: $localize`Dismiss all`
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -284,16 +143,14 @@ export class TasksComponent
|
||||
|
||||
dismissTasks(task: PaperlessTask = undefined) {
|
||||
let tasks = task ? new Set([task.id]) : new Set(this.selectedTasks.values())
|
||||
if (!task && tasks.size == 0) {
|
||||
tasks = new Set(this.visibleTasks.map((t) => t.id))
|
||||
}
|
||||
|
||||
if (!task && tasks.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`
|
||||
modal.componentInstance.messageBold = $localize`Dismiss ${tasks.size} tasks?`
|
||||
modal.componentInstance.title = $localize`Confirm Dismiss All`
|
||||
modal.componentInstance.messageBold = $localize`Dismiss all ${tasks.size} tasks?`
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
@@ -307,7 +164,7 @@ export class TasksComponent
|
||||
})
|
||||
this.clearSelection()
|
||||
})
|
||||
} else if (tasks.size === 1) {
|
||||
} else {
|
||||
this.tasksService.dismissTasks(tasks).subscribe({
|
||||
error: (e) =>
|
||||
this.toastService.showError($localize`Error dismissing task`, e),
|
||||
@@ -331,167 +188,77 @@ export class TasksComponent
|
||||
: this.selectedTasks.add(task.id)
|
||||
}
|
||||
|
||||
toggleSection(section: TaskSection, event: PointerEvent) {
|
||||
const sectionTasks = this.tasksForSection(section)
|
||||
get currentTasks(): PaperlessTask[] {
|
||||
let tasks: PaperlessTask[] = []
|
||||
switch (this.activeTab) {
|
||||
case TaskTab.Queued:
|
||||
tasks = this.tasksService.queuedFileTasks
|
||||
break
|
||||
case TaskTab.Started:
|
||||
tasks = this.tasksService.startedFileTasks
|
||||
break
|
||||
case TaskTab.Completed:
|
||||
tasks = this.tasksService.completedFileTasks
|
||||
break
|
||||
case TaskTab.Failed:
|
||||
tasks = this.tasksService.failedFileTasks
|
||||
break
|
||||
}
|
||||
if (this._filterText.length) {
|
||||
tasks = tasks.filter((t) => {
|
||||
if (this.filterTargetID == TaskFilterTargetID.Name) {
|
||||
return (t.input_data?.filename as string)
|
||||
?.toLowerCase()
|
||||
.includes(this._filterText.toLowerCase())
|
||||
} else if (this.filterTargetID == TaskFilterTargetID.Result) {
|
||||
return t.result_message
|
||||
?.toLowerCase()
|
||||
.includes(this._filterText.toLowerCase())
|
||||
}
|
||||
})
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
toggleAll(event: PointerEvent) {
|
||||
if ((event.target as HTMLInputElement).checked) {
|
||||
sectionTasks.forEach((task) => this.selectedTasks.add(task.id))
|
||||
this.selectedTasks = new Set(this.currentTasks.map((t) => t.id))
|
||||
} else {
|
||||
sectionTasks.forEach((task) => this.selectedTasks.delete(task.id))
|
||||
this.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
areAllSelected(tasks: PaperlessTask[]): boolean {
|
||||
return (
|
||||
tasks.length > 0 && tasks.every((task) => this.selectedTasks.has(task.id))
|
||||
)
|
||||
}
|
||||
|
||||
taskDisplayName(task: PaperlessTask): string {
|
||||
return task.input_data?.filename?.toString() || task.task_type_display
|
||||
}
|
||||
|
||||
taskShowsSeparateTypeLabel(task: PaperlessTask): boolean {
|
||||
return this.taskDisplayName(task) !== task.task_type_display
|
||||
}
|
||||
|
||||
taskResultMessage(task: PaperlessTask): string | null {
|
||||
if (!task.result_data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const documentId = task.result_data?.['document_id']
|
||||
if (typeof documentId === 'number') {
|
||||
return `Success. New document id ${documentId} created`
|
||||
}
|
||||
|
||||
const reason = task.result_data?.['reason']
|
||||
if (typeof reason === 'string') {
|
||||
return reason
|
||||
}
|
||||
|
||||
const duplicateOf = task.result_data?.['duplicate_of']
|
||||
if (typeof duplicateOf === 'number') {
|
||||
return `Duplicate of document #${duplicateOf}`
|
||||
}
|
||||
|
||||
const errorMessage = task.result_data?.['error_message']
|
||||
if (typeof errorMessage === 'string') {
|
||||
return errorMessage
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
taskResultPreview(task: PaperlessTask): string | null {
|
||||
const message = this.taskResultMessage(task)
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
|
||||
return message.length > 50 ? `${message.slice(0, 50)}...` : message
|
||||
}
|
||||
|
||||
taskHasLongResultMessage(task: PaperlessTask): boolean {
|
||||
return (this.taskResultMessage(task)?.length ?? 0) > 50
|
||||
}
|
||||
|
||||
taskHasResultMessage(task: PaperlessTask): boolean {
|
||||
return !!this.taskResultMessage(task)
|
||||
}
|
||||
|
||||
duplicateDocumentId(task: PaperlessTask): number | null {
|
||||
const duplicateOf = task.result_data?.['duplicate_of']
|
||||
return typeof duplicateOf === 'number' ? duplicateOf : null
|
||||
}
|
||||
|
||||
duplicateTaskLabel(task: PaperlessTask): string {
|
||||
return $localize`Duplicate of document #${this.duplicateDocumentId(task)}`
|
||||
}
|
||||
|
||||
openDuplicateDocument(documentId: number) {
|
||||
this.router.navigate(['documents', documentId, 'details'])
|
||||
}
|
||||
|
||||
taskResultPopoverMessage(task: PaperlessTask): string {
|
||||
return this.taskResultMessage(task)?.slice(0, 300) ?? ''
|
||||
}
|
||||
|
||||
taskResultMessageOverflowsPopover(task: PaperlessTask): boolean {
|
||||
return (this.taskResultMessage(task)?.length ?? 0) > 300
|
||||
}
|
||||
|
||||
tasksForSection(section: TaskSection): PaperlessTask[] {
|
||||
let tasks = this.tasksService.allFileTasks.filter((task) =>
|
||||
this.taskBelongsToSection(task, section)
|
||||
)
|
||||
|
||||
return tasks.filter((task) => this.taskMatchesCurrentFilters(task))
|
||||
}
|
||||
|
||||
sectionLabel(section: TaskSection): string {
|
||||
return SECTION_LABELS[section]
|
||||
}
|
||||
|
||||
sectionCount(section: TaskSection): number {
|
||||
return this.tasksService.allFileTasks.filter((task) =>
|
||||
this.taskBelongsToSection(task, section)
|
||||
).length
|
||||
}
|
||||
|
||||
sectionShowsResults(section: TaskSection): boolean {
|
||||
return section !== TaskSection.InProgress
|
||||
}
|
||||
|
||||
setSection(section: TaskSection) {
|
||||
this.selectedSection = section
|
||||
this.clearSelection()
|
||||
}
|
||||
|
||||
setTaskType(taskType: PaperlessTaskType | null) {
|
||||
this.selectedTaskType = taskType
|
||||
this.clearSelection()
|
||||
}
|
||||
|
||||
setTriggerSource(triggerSource: PaperlessTaskTriggerSource | null) {
|
||||
this.selectedTriggerSource = triggerSource
|
||||
this.clearSelection()
|
||||
}
|
||||
|
||||
taskTypeOptionCount(taskType: PaperlessTaskType | null): number {
|
||||
return this.tasksForOptionCounts({ taskType }).length
|
||||
}
|
||||
|
||||
triggerSourceOptionCount(
|
||||
triggerSource: PaperlessTaskTriggerSource | null
|
||||
): number {
|
||||
return this.tasksForOptionCounts({ triggerSource }).length
|
||||
}
|
||||
|
||||
isTaskTypeOptionDisabled(taskType: PaperlessTaskType | null): boolean {
|
||||
return this.taskTypeOptionCount(taskType) === 0
|
||||
}
|
||||
|
||||
isTriggerSourceOptionDisabled(
|
||||
triggerSource: PaperlessTaskTriggerSource | null
|
||||
): boolean {
|
||||
return this.triggerSourceOptionCount(triggerSource) === 0
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.togggleAll = false
|
||||
this.selectedTasks.clear()
|
||||
}
|
||||
|
||||
duringTabChange() {
|
||||
this.page = 1
|
||||
}
|
||||
|
||||
beforeTabChange() {
|
||||
this.resetFilter()
|
||||
this.filterTargetID = TaskFilterTargetID.Name
|
||||
}
|
||||
|
||||
get activeTabLocalized(): string {
|
||||
switch (this.activeTab) {
|
||||
case TaskTab.Queued:
|
||||
return $localize`queued`
|
||||
case TaskTab.Started:
|
||||
return $localize`started`
|
||||
case TaskTab.Completed:
|
||||
return $localize`completed`
|
||||
case TaskTab.Failed:
|
||||
return $localize`failed`
|
||||
}
|
||||
}
|
||||
|
||||
public resetFilter() {
|
||||
this._filterText = ''
|
||||
}
|
||||
|
||||
public resetFilters() {
|
||||
this.selectedTaskType = null
|
||||
this.selectedTriggerSource = null
|
||||
this.resetFilter()
|
||||
this.clearSelection()
|
||||
}
|
||||
|
||||
filterInputKeyup(event: KeyboardEvent) {
|
||||
if (event.key == 'Enter') {
|
||||
this._filterText = (event.target as HTMLInputElement).value
|
||||
@@ -499,87 +266,4 @@ export class TasksComponent
|
||||
this.resetFilter()
|
||||
}
|
||||
}
|
||||
|
||||
private taskBelongsToSection(
|
||||
task: PaperlessTask,
|
||||
section: TaskSection
|
||||
): boolean {
|
||||
switch (section) {
|
||||
case TaskSection.NeedsAttention:
|
||||
return [
|
||||
PaperlessTaskStatus.Failure,
|
||||
PaperlessTaskStatus.Revoked,
|
||||
].includes(task.status)
|
||||
case TaskSection.InProgress:
|
||||
return [
|
||||
PaperlessTaskStatus.Pending,
|
||||
PaperlessTaskStatus.Started,
|
||||
].includes(task.status)
|
||||
case TaskSection.Completed:
|
||||
return task.status === PaperlessTaskStatus.Success
|
||||
}
|
||||
}
|
||||
|
||||
private taskMatchesCurrentFilters(task: PaperlessTask): boolean {
|
||||
return this.taskMatchesFilters(task, {
|
||||
taskType: this.selectedTaskType,
|
||||
triggerSource: this.selectedTriggerSource,
|
||||
})
|
||||
}
|
||||
|
||||
private taskMatchesFilters(
|
||||
task: PaperlessTask,
|
||||
{
|
||||
taskType,
|
||||
triggerSource,
|
||||
}: {
|
||||
taskType: PaperlessTaskType | null
|
||||
triggerSource: PaperlessTaskTriggerSource | null
|
||||
}
|
||||
): boolean {
|
||||
if (taskType !== null && task.task_type !== taskType) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (triggerSource !== null && task.trigger_source !== triggerSource) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this._filterText.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
const query = this._filterText.toLowerCase()
|
||||
|
||||
if (this.filterTargetID == TaskFilterTargetID.Name) {
|
||||
return [
|
||||
this.taskDisplayName(task),
|
||||
task.task_type_display,
|
||||
task.trigger_source_display,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
return this.taskResultMessage(task)?.toLowerCase().includes(query) ?? false
|
||||
}
|
||||
|
||||
private tasksForOptionCounts({
|
||||
taskType = this.selectedTaskType,
|
||||
triggerSource = this.selectedTriggerSource,
|
||||
}: {
|
||||
taskType?: PaperlessTaskType | null
|
||||
triggerSource?: PaperlessTaskTriggerSource | null
|
||||
}): PaperlessTask[] {
|
||||
const sections =
|
||||
this.selectedSection === TaskSection.All
|
||||
? this.sections
|
||||
: [this.selectedSection]
|
||||
|
||||
return this.tasksService.allFileTasks.filter(
|
||||
(task) =>
|
||||
sections.some((section) => this.taskBelongsToSection(task, section)) &&
|
||||
this.taskMatchesFilters(task, { taskType, triggerSource })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,13 +294,13 @@
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||
tourAnchor="tour.file-tasks">
|
||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
|
||||
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
|
||||
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
|
||||
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
|
||||
}</span>
|
||||
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
|
||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
|
||||
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
|
||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -94,12 +94,18 @@ main {
|
||||
}
|
||||
|
||||
.sidebar.slim:not(.animating) {
|
||||
transition: none;
|
||||
|
||||
li.nav-item span,
|
||||
.sidebar-heading span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar.slim:not(.animating) ~ main.col-slim {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sidebar.animating {
|
||||
li.nav-item span,
|
||||
.sidebar-heading span {
|
||||
|
||||
@@ -36,7 +36,6 @@ import { RemoteVersionService } from 'src/app/services/rest/remote-version.servi
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
@@ -98,7 +97,6 @@ describe('AppFrameComponent', () => {
|
||||
let savedViewSpy
|
||||
let modalService: NgbModal
|
||||
let maybeRefreshSpy
|
||||
let tasksService: TasksService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -176,7 +174,6 @@ describe('AppFrameComponent', () => {
|
||||
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
router = TestBed.inject(Router)
|
||||
tasksService = TestBed.inject(TasksService)
|
||||
|
||||
jest
|
||||
.spyOn(settingsService, 'displayName', 'get')
|
||||
@@ -447,16 +444,6 @@ describe('AppFrameComponent', () => {
|
||||
expect(maybeRefreshSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show tasks badge for needs-attention tasks', () => {
|
||||
jest
|
||||
.spyOn(tasksService, 'needsAttentionTasks', 'get')
|
||||
.mockReturnValue([{} as any, {} as any])
|
||||
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain('Tasks2')
|
||||
})
|
||||
|
||||
it('should indicate attributes management availability when any permission is granted', () => {
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserCan')
|
||||
|
||||
@@ -159,11 +159,7 @@
|
||||
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="indexStatus" triggers="click mouseenter:mouseleave">
|
||||
{{status.tasks.index_status}}
|
||||
@if (status.tasks.index_status === 'OK') {
|
||||
@if (isStale(status.tasks.index_last_modified)) {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
||||
}
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
||||
} @else {
|
||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
|
||||
}
|
||||
|
||||
@@ -45,8 +45,9 @@ export interface PaperlessTask extends ObjectWithId {
|
||||
date_done?: Date
|
||||
duration_seconds?: number
|
||||
wait_time_seconds?: number
|
||||
input_data: Record<string, any>
|
||||
result_data?: Record<string, any>
|
||||
input_data: Record<string, unknown>
|
||||
result_data?: Record<string, unknown>
|
||||
result_message?: string
|
||||
related_document_ids: number[]
|
||||
acknowledged: boolean
|
||||
owner?: number
|
||||
|
||||
@@ -5,11 +5,7 @@ import {
|
||||
} from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import {
|
||||
PaperlessTaskStatus,
|
||||
PaperlessTaskTriggerSource,
|
||||
PaperlessTaskType,
|
||||
} from '../data/paperless-task'
|
||||
import { PaperlessTaskStatus, PaperlessTaskType } from '../data/paperless-task'
|
||||
import { TasksService } from './tasks.service'
|
||||
|
||||
describe('TasksService', () => {
|
||||
@@ -37,7 +33,7 @@ describe('TasksService', () => {
|
||||
it('calls tasks api endpoint on reload', () => {
|
||||
tasksService.reload()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/?acknowledged=false`
|
||||
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false`
|
||||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
})
|
||||
@@ -46,7 +42,7 @@ describe('TasksService', () => {
|
||||
tasksService.loading = true
|
||||
tasksService.reload()
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}tasks/?acknowledged=false`
|
||||
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -62,16 +58,17 @@ describe('TasksService', () => {
|
||||
req.flush([])
|
||||
// reload is then called
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}tasks/?acknowledged=false`)
|
||||
.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false`
|
||||
)
|
||||
.flush([])
|
||||
})
|
||||
|
||||
it('groups mixed task types by status when reloading', () => {
|
||||
it('sorts tasks returned from api', () => {
|
||||
expect(tasksService.total).toEqual(0)
|
||||
const mockTasks = [
|
||||
{
|
||||
task_type: PaperlessTaskType.ConsumeFile,
|
||||
trigger_source: PaperlessTaskTriggerSource.FolderConsume,
|
||||
status: PaperlessTaskStatus.Success,
|
||||
acknowledged: false,
|
||||
task_id: '1234',
|
||||
@@ -80,42 +77,38 @@ describe('TasksService', () => {
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.SanityCheck,
|
||||
trigger_source: PaperlessTaskTriggerSource.System,
|
||||
task_type: PaperlessTaskType.ConsumeFile,
|
||||
status: PaperlessTaskStatus.Failure,
|
||||
acknowledged: false,
|
||||
task_id: '1235',
|
||||
input_data: {},
|
||||
input_data: { filename: 'file2.pdf' },
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.MailFetch,
|
||||
trigger_source: PaperlessTaskTriggerSource.Scheduled,
|
||||
task_type: PaperlessTaskType.ConsumeFile,
|
||||
status: PaperlessTaskStatus.Pending,
|
||||
acknowledged: false,
|
||||
task_id: '1236',
|
||||
input_data: {},
|
||||
input_data: { filename: 'file3.pdf' },
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.LlmIndex,
|
||||
trigger_source: PaperlessTaskTriggerSource.WebUI,
|
||||
task_type: PaperlessTaskType.ConsumeFile,
|
||||
status: PaperlessTaskStatus.Started,
|
||||
acknowledged: false,
|
||||
task_id: '1237',
|
||||
input_data: {},
|
||||
input_data: { filename: 'file4.pdf' },
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.EmptyTrash,
|
||||
trigger_source: PaperlessTaskTriggerSource.Manual,
|
||||
task_type: PaperlessTaskType.ConsumeFile,
|
||||
status: PaperlessTaskStatus.Success,
|
||||
acknowledged: false,
|
||||
task_id: '1238',
|
||||
input_data: {},
|
||||
input_data: { filename: 'file5.pdf' },
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
@@ -124,7 +117,7 @@ describe('TasksService', () => {
|
||||
tasksService.reload()
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/?acknowledged=false`
|
||||
`${environment.apiBaseUrl}tasks/?task_type=consume_file&acknowledged=false`
|
||||
)
|
||||
|
||||
req.flush(mockTasks)
|
||||
@@ -136,57 +129,6 @@ describe('TasksService', () => {
|
||||
expect(tasksService.startedFileTasks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('includes revoked tasks in needs attention', () => {
|
||||
const mockTasks = [
|
||||
{
|
||||
task_type: PaperlessTaskType.SanityCheck,
|
||||
trigger_source: PaperlessTaskTriggerSource.System,
|
||||
status: PaperlessTaskStatus.Failure,
|
||||
acknowledged: false,
|
||||
task_id: '1235',
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.MailFetch,
|
||||
trigger_source: PaperlessTaskTriggerSource.Scheduled,
|
||||
status: PaperlessTaskStatus.Revoked,
|
||||
acknowledged: false,
|
||||
task_id: '1236',
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
{
|
||||
task_type: PaperlessTaskType.EmptyTrash,
|
||||
trigger_source: PaperlessTaskTriggerSource.Manual,
|
||||
status: PaperlessTaskStatus.Success,
|
||||
acknowledged: false,
|
||||
task_id: '1238',
|
||||
input_data: {},
|
||||
date_created: new Date(),
|
||||
related_document_ids: [],
|
||||
},
|
||||
]
|
||||
|
||||
tasksService.reload()
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/?acknowledged=false`
|
||||
)
|
||||
|
||||
req.flush(mockTasks)
|
||||
|
||||
expect(tasksService.needsAttentionTasks).toHaveLength(2)
|
||||
expect(tasksService.needsAttentionTasks.map((task) => task.status)).toEqual(
|
||||
expect.arrayContaining([
|
||||
PaperlessTaskStatus.Failure,
|
||||
PaperlessTaskStatus.Revoked,
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('supports running tasks', () => {
|
||||
tasksService.run(PaperlessTaskType.SanityCheck).subscribe((res) => {
|
||||
expect(res).toEqual({
|
||||
|
||||
@@ -56,21 +56,13 @@ export class TasksService {
|
||||
)
|
||||
}
|
||||
|
||||
public get needsAttentionTasks(): PaperlessTask[] {
|
||||
return this.fileTasks.filter((t) =>
|
||||
[PaperlessTaskStatus.Failure, PaperlessTaskStatus.Revoked].includes(
|
||||
t.status
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public reload() {
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
|
||||
this.http
|
||||
.get<PaperlessTask[]>(
|
||||
`${this.baseUrl}${this.endpoint}/?acknowledged=false`
|
||||
`${this.baseUrl}${this.endpoint}/?task_type=${PaperlessTaskType.ConsumeFile}&acknowledged=false`
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifer), first())
|
||||
.subscribe((r) => {
|
||||
|
||||
@@ -119,10 +119,6 @@ table .btn-link {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.fs-7 {
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
|
||||
.bg-body {
|
||||
background-color: var(--bs-body-bg);
|
||||
}
|
||||
@@ -132,10 +128,6 @@ table .btn-link {
|
||||
color: var(--pngx-primary-text-contrast);
|
||||
}
|
||||
|
||||
.bg-darker {
|
||||
background-color: var(--pngx-bg-darker) !important;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--pngx-primary-text-contrast) !important;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import json
|
||||
import logging
|
||||
import operator
|
||||
from contextlib import contextmanager
|
||||
from decimal import Decimal
|
||||
from decimal import InvalidOperation
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
@@ -291,6 +293,34 @@ class MimeTypeFilter(Filter):
|
||||
return qs
|
||||
|
||||
|
||||
class MonetaryAmountField(serializers.Field):
|
||||
"""
|
||||
Accepts either a plain decimal string ("100", "100.00") or a currency-prefixed
|
||||
string ("USD100.00") and returns the numeric amount as a Decimal.
|
||||
|
||||
Mirrors the logic of the value_monetary_amount generated field: if the value
|
||||
starts with a non-digit, the first 3 characters are treated as a currency code
|
||||
(ISO 4217) and stripped before parsing. This preserves backwards compatibility
|
||||
with saved views that stored a currency-prefixed string as the filter value.
|
||||
"""
|
||||
|
||||
default_error_messages = {"invalid": "A valid number is required."}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if not isinstance(data, str | int | float):
|
||||
self.fail("invalid")
|
||||
value = str(data).strip()
|
||||
if value and not value[0].isdigit() and value[0] != "-":
|
||||
value = value[3:] # strip 3-char ISO 4217 currency code
|
||||
try:
|
||||
return Decimal(value)
|
||||
except InvalidOperation:
|
||||
self.fail("invalid")
|
||||
|
||||
def to_representation(self, value):
|
||||
return str(value)
|
||||
|
||||
|
||||
class SelectField(serializers.CharField):
|
||||
def __init__(self, custom_field: CustomField) -> None:
|
||||
self._options = custom_field.extra_data["select_options"]
|
||||
@@ -516,9 +546,8 @@ class CustomFieldQueryParser:
|
||||
value_field_name = CustomFieldInstance.get_value_field_name(
|
||||
custom_field.data_type,
|
||||
)
|
||||
if (
|
||||
custom_field.data_type == CustomField.FieldDataType.MONETARY
|
||||
and op in self.EXPR_BY_CATEGORY["arithmetic"]
|
||||
if custom_field.data_type == CustomField.FieldDataType.MONETARY and (
|
||||
op in self.EXPR_BY_CATEGORY["arithmetic"] or op in {"exact", "in"}
|
||||
):
|
||||
value_field_name = "value_monetary_amount"
|
||||
has_field = Q(custom_fields__field=custom_field)
|
||||
@@ -628,6 +657,13 @@ class CustomFieldQueryParser:
|
||||
elif custom_field.data_type == CustomField.FieldDataType.URL:
|
||||
# For URL fields we don't need to be strict about validation (e.g., for istartswith).
|
||||
field = serializers.CharField()
|
||||
elif custom_field.data_type == CustomField.FieldDataType.MONETARY and (
|
||||
op in self.EXPR_BY_CATEGORY["arithmetic"] or op in {"exact", "in"}
|
||||
):
|
||||
# These ops compare against value_monetary_amount (a DecimalField).
|
||||
# MonetaryAmountField accepts both "100" and "USD100.00" for backwards
|
||||
# compatibility with saved views that stored currency-prefixed values.
|
||||
field = MonetaryAmountField()
|
||||
else:
|
||||
# The general case: inferred from the corresponding field in CustomFieldInstance.
|
||||
value_field_name = CustomFieldInstance.get_value_field_name(
|
||||
|
||||
@@ -3352,13 +3352,13 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
||||
ManyToMany fields dont support e.g. on_delete so we need to discard unattached
|
||||
triggers and actions manually
|
||||
"""
|
||||
for trigger in WorkflowTrigger.objects.all():
|
||||
if trigger.workflows.all().count() == 0:
|
||||
trigger.delete()
|
||||
WorkflowTrigger.objects.annotate(
|
||||
workflow_count=Count("workflows"),
|
||||
).filter(workflow_count=0).delete()
|
||||
|
||||
for action in WorkflowAction.objects.all():
|
||||
if action.workflows.all().count() == 0:
|
||||
action.delete()
|
||||
WorkflowAction.objects.annotate(
|
||||
workflow_count=Count("workflows"),
|
||||
).filter(workflow_count=0).delete()
|
||||
|
||||
WorkflowActionEmail.objects.filter(action=None).delete()
|
||||
WorkflowActionWebhook.objects.filter(action=None).delete()
|
||||
@@ -3387,16 +3387,6 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
||||
|
||||
return instance
|
||||
|
||||
def to_representation(self, instance: Workflow) -> dict[str, Any]:
|
||||
data = super().to_representation(instance)
|
||||
actions = instance.actions.order_by("order", "pk")
|
||||
data["actions"] = WorkflowActionSerializer(
|
||||
actions,
|
||||
many=True,
|
||||
context=self.context,
|
||||
).data
|
||||
return data
|
||||
|
||||
|
||||
class TrashSerializer(SerializerWithPerms):
|
||||
documents = serializers.ListField(
|
||||
|
||||
@@ -9,6 +9,8 @@ from rest_framework.test import APITestCase
|
||||
|
||||
from documents.models import CustomField
|
||||
from documents.models import Document
|
||||
from documents.models import SavedView
|
||||
from documents.models import SavedViewFilterRule
|
||||
from documents.serialisers import DocumentSerializer
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
|
||||
@@ -453,6 +455,111 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
),
|
||||
)
|
||||
|
||||
def test_exact_monetary(self) -> None:
|
||||
# "exact" should match by numeric amount, ignoring currency code prefix.
|
||||
self._assert_query_match_predicate(
|
||||
["monetary_field", "exact", "100"],
|
||||
lambda document: (
|
||||
"monetary_field" in document
|
||||
and document["monetary_field"] == "USD100.00"
|
||||
),
|
||||
)
|
||||
self._assert_query_match_predicate(
|
||||
["monetary_field", "exact", "101"],
|
||||
lambda document: (
|
||||
"monetary_field" in document and document["monetary_field"] == "101.00"
|
||||
),
|
||||
)
|
||||
|
||||
def test_in_monetary(self) -> None:
|
||||
# "in" should match by numeric amount, ignoring currency code prefix.
|
||||
self._assert_query_match_predicate(
|
||||
["monetary_field", "in", ["100", "50"]],
|
||||
lambda document: (
|
||||
"monetary_field" in document
|
||||
and document["monetary_field"] in {"USD100.00", "EUR50.00"}
|
||||
),
|
||||
)
|
||||
|
||||
def test_exact_monetary_with_currency_prefix(self) -> None:
|
||||
# Providing a currency-prefixed string like "USD100.00" for an exact monetary
|
||||
# filter should work for backwards compatibility with saved views. The currency
|
||||
# code is stripped and the numeric amount is used for comparison.
|
||||
self._assert_query_match_predicate(
|
||||
["monetary_field", "exact", "USD100.00"],
|
||||
lambda document: (
|
||||
"monetary_field" in document
|
||||
and document["monetary_field"] == "USD100.00"
|
||||
),
|
||||
)
|
||||
self._assert_query_match_predicate(
|
||||
["monetary_field", "in", ["USD100.00", "EUR50.00"]],
|
||||
lambda document: (
|
||||
"monetary_field" in document
|
||||
and document["monetary_field"] in {"USD100.00", "EUR50.00"}
|
||||
),
|
||||
)
|
||||
self._assert_query_match_predicate(
|
||||
["monetary_field", "gt", "USD99.00"],
|
||||
lambda document: (
|
||||
"monetary_field" in document
|
||||
and document["monetary_field"] is not None
|
||||
and (
|
||||
document["monetary_field"] == "USD100.00"
|
||||
or document["monetary_field"] == "101.00"
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def test_saved_view_with_currency_prefixed_monetary_filter(self) -> None:
|
||||
"""
|
||||
A saved view created before the exact-monetary fix stored currency-prefixed
|
||||
values like '["monetary_field", "exact", "USD100.00"]' as the filter rule value
|
||||
(rule_type=42). Those saved views must continue to return correct results.
|
||||
"""
|
||||
saved_view = SavedView.objects.create(name="test view", owner=self.user)
|
||||
SavedViewFilterRule.objects.create(
|
||||
saved_view=saved_view,
|
||||
rule_type=42, # FILTER_CUSTOM_FIELDS_QUERY
|
||||
value=json.dumps(["monetary_field", "exact", "USD100.00"]),
|
||||
)
|
||||
# The frontend translates rule_type=42 to the custom_field_query URL param;
|
||||
# simulate that here using the stored filter rule value directly.
|
||||
rule = saved_view.filter_rules.get(rule_type=42)
|
||||
query_string = quote(rule.value, safe="")
|
||||
response = self.client.get(
|
||||
"/api/documents/?"
|
||||
+ "&".join(
|
||||
(
|
||||
f"custom_field_query={query_string}",
|
||||
"ordering=archive_serial_number",
|
||||
"page=1",
|
||||
f"page_size={len(self.documents)}",
|
||||
"truncate_content=true",
|
||||
),
|
||||
),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, msg=str(response.json()))
|
||||
result_ids = {doc["id"] for doc in response.json()["results"]}
|
||||
# Should match the single document with monetary_field = "USD100.00"
|
||||
expected_ids = {
|
||||
doc.id
|
||||
for doc in self.documents
|
||||
if doc.custom_fields.filter(
|
||||
field__name="monetary_field",
|
||||
value_monetary="USD100.00",
|
||||
).exists()
|
||||
}
|
||||
self.assertEqual(result_ids, expected_ids)
|
||||
|
||||
def test_monetary_amount_with_invalid_value(self) -> None:
|
||||
# A value that has a currency prefix but no valid number after it should fail.
|
||||
self._assert_validation_error(
|
||||
json.dumps(["monetary_field", "exact", "USDnotanumber"]),
|
||||
["custom_field_query", "2"],
|
||||
"valid number",
|
||||
)
|
||||
|
||||
# ==========================================================#
|
||||
# Subset check (document link field only) #
|
||||
# ==========================================================#
|
||||
|
||||
@@ -99,6 +99,40 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
self.action.assign_correspondent.pk,
|
||||
)
|
||||
|
||||
def test_api_get_workflow_actions_ordered(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A workflow with two actions added in reverse order (order=1 before order=0)
|
||||
WHEN:
|
||||
- API is called to get workflows
|
||||
THEN:
|
||||
- Actions are returned sorted by order ascending
|
||||
"""
|
||||
# Created before action_first so its pk is lower — ensures pk order
|
||||
# disagrees with the order field, catching regressions if order_by is removed.
|
||||
action_second = WorkflowAction.objects.create(
|
||||
assign_title="Second action",
|
||||
order=1,
|
||||
)
|
||||
action_first = WorkflowAction.objects.create(
|
||||
assign_title="First action",
|
||||
order=0,
|
||||
)
|
||||
self.workflow.actions.add(action_second)
|
||||
self.workflow.actions.add(action_first)
|
||||
|
||||
response = self.client.get(self.ENDPOINT, format="json")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
resp_actions = response.data["results"][0]["actions"]
|
||||
action_ids = [a["id"] for a in resp_actions]
|
||||
self.assertIn(action_first.id, action_ids)
|
||||
self.assertIn(action_second.id, action_ids)
|
||||
self.assertLess(
|
||||
action_ids.index(action_first.id),
|
||||
action_ids.index(action_second.id),
|
||||
)
|
||||
|
||||
def test_api_create_workflow(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -968,7 +968,10 @@ class DocumentViewSet(
|
||||
),
|
||||
),
|
||||
"tags",
|
||||
"custom_fields",
|
||||
Prefetch(
|
||||
"custom_fields",
|
||||
queryset=CustomFieldInstance.objects.select_related("field"),
|
||||
),
|
||||
"notes",
|
||||
)
|
||||
)
|
||||
@@ -4482,8 +4485,44 @@ class WorkflowViewSet(ModelViewSet[Workflow]):
|
||||
Workflow.objects.all()
|
||||
.order_by("order")
|
||||
.prefetch_related(
|
||||
"triggers",
|
||||
"actions",
|
||||
Prefetch(
|
||||
"triggers",
|
||||
queryset=WorkflowTrigger.objects.prefetch_related(
|
||||
"filter_has_tags",
|
||||
"filter_has_all_tags",
|
||||
"filter_has_not_tags",
|
||||
"filter_has_any_correspondents",
|
||||
"filter_has_not_correspondents",
|
||||
"filter_has_any_document_types",
|
||||
"filter_has_not_document_types",
|
||||
"filter_has_any_storage_paths",
|
||||
"filter_has_not_storage_paths",
|
||||
),
|
||||
),
|
||||
Prefetch(
|
||||
"actions",
|
||||
queryset=WorkflowAction.objects.order_by(
|
||||
"order",
|
||||
"pk",
|
||||
).prefetch_related(
|
||||
"assign_tags",
|
||||
"assign_view_users",
|
||||
"assign_view_groups",
|
||||
"assign_change_users",
|
||||
"assign_change_groups",
|
||||
"assign_custom_fields",
|
||||
"remove_tags",
|
||||
"remove_correspondents",
|
||||
"remove_document_types",
|
||||
"remove_storage_paths",
|
||||
"remove_custom_fields",
|
||||
"remove_owners",
|
||||
"remove_view_users",
|
||||
"remove_view_groups",
|
||||
"remove_change_users",
|
||||
"remove_change_groups",
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-20 20:20+0000\n"
|
||||
"POT-Creation-Date: 2026-04-21 18:02+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -21,39 +21,39 @@ msgstr ""
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:433
|
||||
#: documents/filters.py:463
|
||||
msgid "Value must be valid JSON."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:452
|
||||
#: documents/filters.py:482
|
||||
msgid "Invalid custom field query expression"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:462
|
||||
#: documents/filters.py:492
|
||||
msgid "Invalid expression list. Must be nonempty."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:483
|
||||
#: documents/filters.py:513
|
||||
msgid "Invalid logical operator {op!r}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:497
|
||||
#: documents/filters.py:527
|
||||
msgid "Maximum number of query conditions exceeded."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:562
|
||||
#: documents/filters.py:591
|
||||
msgid "{name!r} is not a valid custom field."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:599
|
||||
#: documents/filters.py:628
|
||||
msgid "{data_type} does not support query expr {expr!r}."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:707 documents/models.py:136
|
||||
#: documents/filters.py:743 documents/models.py:136
|
||||
msgid "Maximum nesting depth exceeded."
|
||||
msgstr ""
|
||||
|
||||
#: documents/filters.py:954
|
||||
#: documents/filters.py:990
|
||||
msgid "Custom field not found"
|
||||
msgstr ""
|
||||
|
||||
@@ -1352,8 +1352,8 @@ msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:463 documents/serialisers.py:815
|
||||
#: documents/serialisers.py:2681 documents/views.py:2255
|
||||
#: documents/views.py:2324 paperless_mail/serialisers.py:143
|
||||
#: documents/serialisers.py:2681 documents/views.py:2258
|
||||
#: documents/views.py:2327 paperless_mail/serialisers.py:143
|
||||
msgid "Insufficient permissions."
|
||||
msgstr ""
|
||||
|
||||
@@ -1393,7 +1393,7 @@ msgstr ""
|
||||
msgid "Duplicate document identifiers are not allowed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2767 documents/views.py:4104
|
||||
#: documents/serialisers.py:2767 documents/views.py:4107
|
||||
#, python-format
|
||||
msgid "Documents not found: %(ids)s"
|
||||
msgstr ""
|
||||
@@ -1661,28 +1661,28 @@ msgstr ""
|
||||
msgid "Unable to parse URI {value}"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:2135
|
||||
#: documents/views.py:2138
|
||||
msgid "Specify only one of text, title_search, query, or more_like_id."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:2248 documents/views.py:2321
|
||||
#: documents/views.py:2251 documents/views.py:2324
|
||||
msgid "Invalid more_like_id"
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4116
|
||||
#: documents/views.py:4119
|
||||
#, python-format
|
||||
msgid "Insufficient permissions to share document %(id)s."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4162
|
||||
#: documents/views.py:4165
|
||||
msgid "Bundle is already being processed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4222
|
||||
#: documents/views.py:4225
|
||||
msgid "The share link bundle is still being prepared. Please try again later."
|
||||
msgstr ""
|
||||
|
||||
#: documents/views.py:4232
|
||||
#: documents/views.py:4235
|
||||
msgid "The share link bundle is unavailable."
|
||||
msgstr ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user