Compare commits

...

24 Commits

Author SHA1 Message Date
shamoon
a0a9e0c6c8 Update views.py
[ci ckip]
2025-02-25 09:50:27 -08:00
shamoon
1c7c703e5f Merge migrations 2025-02-21 08:35:36 -08:00
shamoon
53e9e910d8 Merge branch 'dev' into feature-improve-paperless-task 2025-02-21 08:33:40 -08:00
shamoon
9fe611a24c
Update views.py 2025-02-20 12:11:55 -08:00
shamoon
31e71aab83 Fix migrations merge 2025-02-17 08:19:11 -08:00
shamoon
7e7ce97d10 merge migrations 2025-02-17 08:19:11 -08:00
shamoon
e06adc58c7 Update tasks.service.ts 2025-02-17 08:19:11 -08:00
shamoon
7170ac31b7 Update test_api_tasks.py 2025-02-17 08:19:11 -08:00
shamoon
a0aa78c788 Translations 2025-02-17 08:19:11 -08:00
shamoon
f3438914cc Support acknowledged param 2025-02-17 08:19:11 -08:00
shamoon
e1b944ce6b Use choices for task name, rework task type 2025-02-17 08:19:11 -08:00
shamoon
0add5aab0e Styling, celery url 2025-02-17 08:19:11 -08:00
shamoon
c9adc74fa9 Styling, 4th column 2025-02-17 08:19:11 -08:00
shamoon
32abfbfc0a Health 2025-02-17 08:19:11 -08:00
shamoon
7f02f782f4 Fix warning 2025-02-17 08:19:11 -08:00
shamoon
7c3f011e84 Couple more test fixes 2025-02-17 08:19:11 -08:00
shamoon
5c68177960 Update tasks.py 2025-02-17 08:19:11 -08:00
shamoon
7a4666783e Fix tests, warning 2025-02-17 08:19:11 -08:00
shamoon
372825c271 Update translation strings 2025-02-17 08:19:11 -08:00
shamoon
abfddd6931 Fix tests 2025-02-17 08:19:11 -08:00
shamoon
b3d49dbf12 Add sanity check to system status 2025-02-17 08:19:11 -08:00
shamoon
673839265d Update system status to use classifier paperlesstask 2025-02-17 08:19:11 -08:00
shamoon
f31df22ab6 Revert "Tweak: more accurate classifier last trained time (#9004)"
This reverts commit 3314c5982859609eea1635bfdb8545b7df1a7c07.
2025-02-17 08:19:11 -08:00
shamoon
f897447a65 Create paperlesstasks for sanity, classifier
[ci skip]
2025-02-17 08:19:11 -08:00
26 changed files with 829 additions and 461 deletions

View File

@ -4099,6 +4099,18 @@
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">111</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">165</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">189</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">213</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
<context context-type="linenumber">30</context>
@ -5548,7 +5560,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">156</context>
<context context-type="linenumber">231</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
@ -5943,77 +5955,98 @@
<source>Migration Status</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">56</context>
<context context-type="linenumber">65</context>
</context-group>
</trans-unit>
<trans-unit id="7489316373554112115" datatype="html">
<source>Up to date</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">59</context>
<context context-type="linenumber">69</context>
</context-group>
</trans-unit>
<trans-unit id="7881311375431899727" datatype="html">
<source>Latest Migration</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">64</context>
<context context-type="linenumber">74</context>
</context-group>
</trans-unit>
<trans-unit id="4632965004151576238" datatype="html">
<source>Pending Migrations</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">66</context>
<context context-type="linenumber">76</context>
</context-group>
</trans-unit>
<trans-unit id="6904866445262015585" datatype="html">
<source>Tasks</source>
<trans-unit id="2790343143501919450" datatype="html">
<source>Tasks Queue</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">83</context>
<context context-type="linenumber">94</context>
</context-group>
</trans-unit>
<trans-unit id="6911698235105017958" datatype="html">
<source>Redis Status</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">87</context>
<context context-type="linenumber">98</context>
</context-group>
</trans-unit>
<trans-unit id="5349496739889768589" datatype="html">
<source>Celery Status</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">96</context>
<context context-type="linenumber">116</context>
</context-group>
</trans-unit>
<trans-unit id="2041675390931385838" datatype="html">
<source>Health</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">142</context>
</context-group>
</trans-unit>
<trans-unit id="31377277941774469" datatype="html">
<source>Search 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">105</context>
<context context-type="linenumber">146</context>
</context-group>
</trans-unit>
<trans-unit id="4089509911694721896" datatype="html">
<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">119</context>
<context context-type="linenumber">163</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">121</context>
<context context-type="linenumber">168</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">139</context>
<context context-type="linenumber">187</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">192</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">211</context>
</context-group>
</trans-unit>
<trans-unit id="6732151329960766506" datatype="html">

View File

@ -303,12 +303,17 @@ describe('SettingsComponent', () => {
redis_error:
'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR,
celery_url: 'celery@localhost',
celery_error: 'Error connecting to celery@localhost',
index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(),
index_error: null,
classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(),
classifier_error: null,
sanity_check_status: SystemStatusItemStatus.ERROR,
sanity_check_last_run: new Date().toISOString(),
sanity_check_error: 'Error running sanity check.',
},
}
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))

View File

@ -19,6 +19,7 @@ import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { routes } from 'src/app/app-routing.module'
import {
PaperlessTask,
PaperlessTaskName,
PaperlessTaskStatus,
PaperlessTaskType,
} from 'src/app/data/paperless-task'
@ -39,7 +40,8 @@ const tasks: PaperlessTask[] = [
task_file_name: 'test.pdf',
date_created: new Date('2023-03-01T10:26:03.093116Z'),
date_done: new Date('2023-03-01T10:26:07.223048Z'),
type: PaperlessTaskType.File,
type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Failed,
result: 'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)',
acknowledged: false,
@ -51,7 +53,8 @@ const tasks: PaperlessTask[] = [
task_file_name: '191092.pdf',
date_created: new Date('2023-03-01T09:26:03.093116Z'),
date_done: new Date('2023-03-01T09:26:07.223048Z'),
type: PaperlessTaskType.File,
type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Failed,
result:
'191092.pd: Not consuming 191092.pdf: It is a duplicate of 191092 (#311)',
@ -64,7 +67,8 @@ const tasks: PaperlessTask[] = [
task_file_name: 'Scan Jun 6, 2023 at 3.19 PM.pdf',
date_created: new Date('2023-06-06T15:22:05.722323-07:00'),
date_done: new Date('2023-06-06T15:22:14.564305-07:00'),
type: PaperlessTaskType.File,
type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Pending,
result: null,
acknowledged: false,
@ -76,7 +80,8 @@ const tasks: PaperlessTask[] = [
task_file_name: 'paperless-mail-l4dkg8ir',
date_created: new Date('2023-06-04T11:24:32.898089-07:00'),
date_done: new Date('2023-06-04T11:24:44.678605-07:00'),
type: PaperlessTaskType.File,
type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Complete,
result: 'Success. New document id 422 created',
acknowledged: false,
@ -88,7 +93,8 @@ const tasks: PaperlessTask[] = [
task_file_name: 'onlinePaymentSummary.pdf',
date_created: new Date('2023-06-01T13:49:51.631305-07:00'),
date_done: new Date('2023-06-01T13:49:54.190220-07:00'),
type: PaperlessTaskType.File,
type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Complete,
result: 'Success. New document id 421 created',
acknowledged: false,
@ -100,7 +106,8 @@ const tasks: PaperlessTask[] = [
task_file_name: 'paperless-mail-_rrpmqk6',
date_created: new Date('2023-06-07T02:54:35.694916Z'),
date_done: null,
type: PaperlessTaskType.File,
type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Started,
result: null,
acknowledged: false,
@ -155,7 +162,9 @@ describe('TasksComponent', () => {
jest.useFakeTimers()
fixture.detectChanges()
httpTestingController
.expectOne(`${environment.apiBaseUrl}tasks/`)
.expectOne(
`${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
)
.flush(tasks)
})

View File

@ -1,5 +1,5 @@
<div class="modal-header">
<h5 class="modal-title" id="modal-basic-title" i18n>System Status</h5>
<h6 class="modal-title" id="modal-basic-title" i18n>System Status</h6>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
@ -11,11 +11,11 @@
</div>
</div>
} @else {
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="row row-cols-1 row-cols-md-4 g-3">
<div class="col-4">
<div class="card bg-light h-100">
<div class="card-header">
<h5 class="card-title mb-0" i18n>Environment</h5>
<h6 class="card-title mb-0" i18n>Environment</h6>
</div>
<div class="card-body">
<dl class="card-text">
@ -38,27 +38,37 @@
<div class="col">
<div class="card bg-light h-100">
<div class="card-header">
<h5 class="card-title mb-0" i18n>Database</h5>
<h6 class="card-title mb-0" i18n>Database</h6>
</div>
<div class="card-body">
<dl class="card-text">
<dt i18n>Type</dt>
<dd>{{status.database.type}}</dd>
<dt i18n>Status</dt>
<dd class="d-flex align-items-center">
<dd>
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="databaseStatus" triggers="mouseenter:mouseleave">
{{status.database.status}}
@if (status.database.status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.database.url}}" triggers="mouseenter:mouseleave"></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" ngbPopover="{{status.database.url}}: {{status.database.error}}" triggers="mouseenter:mouseleave"></i-bs>
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</div>
<ng-template #databaseStatus>
@if (status.database.status === 'OK') {
{{status.database.url}}
} @else {
{{status.database.url}}: {{status.database.error}}
}
</ng-template>
</dd>
<dt i18n>Migration Status</dt>
<dd class="d-flex align-items-center">
<dd>
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave">
@if (status.database.migration_status.unapplied_migrations.length === 0) {
<ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
<ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<ng-container>{{status.database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
<ng-container>{{status.database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
}
<ng-template #migrationStatus>
<h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span>
@ -71,6 +81,7 @@
</ul>
}
</ng-template>
</div>
</dd>
</dl>
</div>
@ -80,63 +91,127 @@
<div class="col">
<div class="card bg-light h-100">
<div class="card-header">
<h5 class="card-title mb-0" i18n>Tasks</h5>
<h6 class="card-title mb-0" i18n>Tasks Queue</h6>
</div>
<div class="card-body">
<dl class="card-text">
<dt i18n>Redis Status</dt>
<dd class="d-flex align-items-center">
<dd>
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="redisStatus" triggers="mouseenter:mouseleave">
{{status.tasks.redis_status}}
@if (status.tasks.redis_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}" triggers="mouseenter:mouseleave"></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" ngbPopover="{{status.tasks.redis_url}}: {{status.tasks.redis_error}}" triggers="mouseenter:mouseleave"></i-bs>
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</div>
<ng-template #redisStatus>
@if (status.tasks.redis_status === 'OK') {
{{status.tasks.redis_url}}
} @else {
{{status.tasks.redis_url}}: {{status.tasks.redis_error}}
}
</ng-template>
</dd>
<dt i18n>Celery Status</dt>
<dd class="d-flex align-items-center">
<dd>
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="celeryStatus" triggers="mouseenter:mouseleave">
{{status.tasks.celery_status}}
@if (status.tasks.celery_status === 'OK') {
<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>
}
</div>
<ng-template #celeryStatus>
@if (status.tasks.celery_status === 'OK') {
{{status.tasks.celery_url}}
} @else {
{{status.tasks.celery_error}}
}
</ng-template>
</dd>
</dl>
</div>
</div>
</div>
<div class="col">
<div class="card bg-light h-100">
<div class="card-header">
<h6 class="card-title mb-0" i18n>Health</h6>
</div>
<div class="card-body">
<dl class="card-text">
<dt i18n>Search Index</dt>
<dd class="d-flex align-items-center">
<dd>
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="indexStatus" triggers="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" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs>
<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" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></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" ngbPopover="{{status.tasks.index_error}}" triggers="mouseenter:mouseleave"></i-bs>
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</div>
</dd>
<ng-template #indexStatus>
@if (status.tasks.index_status === 'OK') {
<h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_last_modified | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_error}}</span>
}
</ng-template>
<dt i18n>Classifier</dt>
<dd class="d-flex align-items-center">
<dd>
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave">
{{status.tasks.classifier_status}}
@if (status.tasks.classifier_status === 'OK') {
@if (isStale(status.tasks.classifier_last_trained)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs>
<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" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></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="ms-2 lh-1"
[class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"
ngbPopover="{{status.tasks.classifier_error}}"
triggers="mouseenter:mouseleave"></i-bs>
[class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs>
}
</div>
</dd>
<ng-template #classifierStatus>
@if (status.tasks.classifier_status === 'OK') {
<h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_last_trained | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_error}}</span>
}
</ng-template>
<dt i18n>Sanity Checker</dt>
<dd>
<div class="badge text-uppercase bg-info text-dark" [ngbPopover]="sanityCheckerStatus" triggers="mouseenter:mouseleave">
{{status.tasks.sanity_check_status}}
@if (status.tasks.sanity_check_status === 'OK') {
@if (isStale(status.tasks.sanity_check_last_run)) {
<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>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs>
}
</div>
</dd>
<ng-template #sanityCheckerStatus>
@if (status.tasks.sanity_check_status === 'OK') {
<h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_last_run | customDate:'medium'}}</span>
} @else {
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_error}}</span>
}
</ng-template>
</dl>
</div>

View File

@ -0,0 +1,3 @@
.border-primary {
--bs-border-color: var(--bs-primary);
}

View File

@ -36,12 +36,17 @@ const status: SystemStatus = {
redis_status: SystemStatusItemStatus.ERROR,
redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR,
celery_url: 'celery@localhost',
celery_error: 'Error connecting to celery@localhost',
index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(),
index_error: null,
classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(),
classifier_error: null,
sanity_check_status: SystemStatusItemStatus.OK,
sanity_check_last_run: new Date().toISOString(),
sanity_check_error: null,
},
}

View File

@ -1,8 +1,15 @@
import { ObjectWithId } from './object-with-id'
export enum PaperlessTaskType {
// just file tasks, for now
File = 'file',
Auto = 'auto_task',
ScheduledTask = 'scheduled_task',
ManualTask = 'manual_task',
}
export enum PaperlessTaskName {
ConsumeFile = 'consume_file',
TrainClassifier = 'train_classifier',
SanityCheck = 'check_sanity',
}
export enum PaperlessTaskStatus {
@ -23,6 +30,8 @@ export interface PaperlessTask extends ObjectWithId {
task_file_name: string
task_name: PaperlessTaskName
date_created: Date
date_done?: Date

View File

@ -32,11 +32,16 @@ export interface SystemStatus {
redis_status: SystemStatusItemStatus
redis_error: string
celery_status: SystemStatusItemStatus
celery_url: string
celery_error: string
index_status: SystemStatusItemStatus
index_last_modified: string // ISO date string
index_error: string
classifier_status: SystemStatusItemStatus
classifier_last_trained: string // ISO date string
classifier_error: string
sanity_check_status: SystemStatusItemStatus
sanity_check_last_run: string // ISO date string
sanity_check_error: string
}
}

View File

@ -5,7 +5,11 @@ import {
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment'
import { PaperlessTaskStatus, PaperlessTaskType } from '../data/paperless-task'
import {
PaperlessTaskName,
PaperlessTaskStatus,
PaperlessTaskType,
} from '../data/paperless-task'
import { TasksService } from './tasks.service'
describe('TasksService', () => {
@ -33,7 +37,7 @@ describe('TasksService', () => {
it('calls tasks api endpoint on reload', () => {
tasksService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}tasks/`
`${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
)
expect(req.request.method).toEqual('GET')
})
@ -41,7 +45,9 @@ describe('TasksService', () => {
it('does not call tasks api endpoint on reload if already loading', () => {
tasksService.loading = true
tasksService.reload()
httpTestingController.expectNone(`${environment.apiBaseUrl}tasks/`)
httpTestingController.expectNone(
`${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
)
})
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
@ -55,14 +61,19 @@ describe('TasksService', () => {
})
req.flush([])
// reload is then called
httpTestingController.expectOne(`${environment.apiBaseUrl}tasks/`).flush([])
httpTestingController
.expectOne(
`${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
)
.flush([])
})
it('sorts tasks returned from api', () => {
expect(tasksService.total).toEqual(0)
const mockTasks = [
{
type: PaperlessTaskType.File,
type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Complete,
acknowledged: false,
task_id: '1234',
@ -70,7 +81,8 @@ describe('TasksService', () => {
date_created: new Date(),
},
{
type: PaperlessTaskType.File,
type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Failed,
acknowledged: false,
task_id: '1235',
@ -78,7 +90,8 @@ describe('TasksService', () => {
date_created: new Date(),
},
{
type: PaperlessTaskType.File,
type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Pending,
acknowledged: false,
task_id: '1236',
@ -86,7 +99,8 @@ describe('TasksService', () => {
date_created: new Date(),
},
{
type: PaperlessTaskType.File,
type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Started,
acknowledged: false,
task_id: '1237',
@ -94,7 +108,8 @@ describe('TasksService', () => {
date_created: new Date(),
},
{
type: PaperlessTaskType.File,
type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Complete,
acknowledged: false,
task_id: '1238',
@ -106,7 +121,7 @@ describe('TasksService', () => {
tasksService.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}tasks/`
`${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
)
req.flush(mockTasks)

View File

@ -4,8 +4,8 @@ import { Subject } from 'rxjs'
import { first, takeUntil } from 'rxjs/operators'
import {
PaperlessTask,
PaperlessTaskName,
PaperlessTaskStatus,
PaperlessTaskType,
} from 'src/app/data/paperless-task'
import { environment } from 'src/environments/environment'
@ -54,10 +54,14 @@ export class TasksService {
this.loading = true
this.http
.get<PaperlessTask[]>(`${this.baseUrl}tasks/`)
.get<PaperlessTask[]>(
`${this.baseUrl}tasks/?task_name=consume_file&acknowledged=false`
)
.pipe(takeUntil(this.unsubscribeNotifer), first())
.subscribe((r) => {
this.fileTasks = r.filter((t) => t.type == PaperlessTaskType.File) // they're all File tasks, for now
this.fileTasks = r.filter(
(t) => t.task_name == PaperlessTaskName.ConsumeFile
)
this.loading = false
})
}

View File

@ -21,10 +21,12 @@
--pngx-success-darken-10: hsl(152, 69%, 11%); // based on success #198754
--pngx-bg-alt: #fff;
--pngx-bg-darker: var(--bs-gray-100);
--pngx-bg-alt2: var(--bs-gray-200);
--pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
--pngx-bg-disabled: #f7f7f7;
--pngx-focus-alpha: 0.3;
--pngx-toast-max-width: 360px;
--bs-info: var(--pngx-bg-alt2);
--bs-info-rgb: 233, 236, 239;
@media screen and (min-width: 1024px) {
--pngx-toast-max-width: 450px;
}
@ -71,8 +73,15 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
}
@mixin dark-mode {
--bs-body-color: #{$text-color-dark-bg};
--pngx-body-color-accent: #{$text-color-dark-bg-accent};
--pngx-bg-alt: #242529;
--pngx-bg-alt2: #232323;
--pngx-bg-darker: #101216;
--pngx-bg-disabled: var(--pngx-bg-alt);
--pngx-focus-alpha: 0.6;
--pngx-primary-faded: var(--pngx-primary-darken-15);
--pngx-primary-text-contrast: var(--bs-body-color);
--bs-body-color: #{$text-color-dark-bg};
--bs-secondary-color: #6c757d;
--bs-danger: #b71631;
--bs-danger-rgb: 183, 22, 49;
@ -80,15 +89,10 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
--bs-body-bg-rgb: 22, 22, 24;
--bs-light: #1c1c1f;
--bs-light-rgb: 28, 28, 31;
--bs-info: var(--pngx-bg-alt);
--bs-info-rgb: 36, 36, 39;
--bs-border-color: #47494f;
--pngx-bg-alt2: #232323;
--pngx-bg-darker: #101216;
--bs-tertiary-bg: var(--pngx-bg-darker);
--pngx-bg-alt: #242529;
--pngx-bg-disabled: var(--pngx-bg-alt);
--pngx-focus-alpha: 0.6;
--pngx-primary-faded: var(--pngx-primary-darken-15);
--pngx-primary-text-contrast: var(--bs-body-color);
--bs-dark-border-subtle: var(--pngx-bg-darker);
--bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs

View File

@ -1,7 +1,6 @@
import logging
import pickle
import re
import time
import warnings
from collections.abc import Iterator
from hashlib import sha256
@ -142,19 +141,6 @@ class DocumentClassifier:
):
raise IncompatibleClassifierVersionError("sklearn version update")
def set_last_checked(self) -> None:
# save a timestamp of the last time we checked for retraining to a file
with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("w") as f:
f.write(str(time.time()))
def get_last_checked(self) -> float | None:
# load the timestamp of the last time we checked for retraining
try:
with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("r") as f:
return float(f.read())
except FileNotFoundError: # pragma: no cover
return None
def save(self) -> None:
target_file: Path = settings.MODEL_FILE
target_file_temp: Path = target_file.with_suffix(".pickle.part")
@ -175,7 +161,6 @@ class DocumentClassifier:
pickle.dump(self.storage_path_classifier, f)
target_file_temp.rename(target_file)
self.set_last_checked()
def train(self) -> bool:
# Get non-inbox documents
@ -244,7 +229,6 @@ class DocumentClassifier:
and self.last_doc_change_time >= latest_doc_change
) and self.last_auto_type_hash == hasher.digest():
logger.info("No updates since last training")
self.set_last_checked()
# Set the classifier information into the cache
# Caching for 50 minutes, so slightly less than the normal retrain time
cache.set(

View File

@ -35,6 +35,7 @@ from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import Log
from documents.models import PaperlessTask
from documents.models import ShareLink
from documents.models import StoragePath
from documents.models import Tag
@ -770,6 +771,21 @@ class ShareLinkFilterSet(FilterSet):
}
class PaperlessTaskFilterSet(FilterSet):
acknowledged = BooleanFilter(
label="Acknowledged",
field_name="acknowledged",
)
class Meta:
model = PaperlessTask
fields = {
"type": ["exact"],
"task_name": ["exact"],
"status": ["exact"],
}
class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
"""
A filter backend that limits results to those where the requesting user

View File

@ -10,4 +10,4 @@ class Command(BaseCommand):
)
def handle(self, *args, **options):
train_classifier()
train_classifier(scheduled=False)

View File

@ -12,6 +12,6 @@ class Command(ProgressBarMixin, BaseCommand):
def handle(self, *args, **options):
self.handle_progress_bar_mixin(**options)
messages = check_sanity(progress=self.use_progress_bar)
messages = check_sanity(progress=self.use_progress_bar, scheduled=False)
messages.log_messages()

View File

@ -1,4 +1,4 @@
# Generated by Django 5.1.6 on 2025-02-20 04:55
# Generated by Django 5.1.6 on 2025-02-21 16:34
import multiselectfield.db.fields
from django.db import migrations
@ -16,12 +16,51 @@ def update_workflow_sources(apps, schema_editor):
trigger.save()
def make_existing_tasks_consume_auto(apps, schema_editor):
PaperlessTask = apps.get_model("documents", "PaperlessTask")
PaperlessTask.objects.all().update(type="auto_task", task_name="consume_file")
class Migration(migrations.Migration):
dependencies = [
("documents", "1062_alter_savedviewfilterrule_rule_type"),
]
operations = [
migrations.AddField(
model_name="paperlesstask",
name="type",
field=models.CharField(
choices=[
("auto_task", "Auto Task"),
("scheduled_task", "Scheduled Task"),
("manual_task", "Manual Task"),
],
default="auto_task",
help_text="The type of task that was run",
max_length=30,
verbose_name="Task Type",
),
),
migrations.AlterField(
model_name="paperlesstask",
name="task_name",
field=models.CharField(
choices=[
("consume_file", "Consume File"),
("train_classifier", "Train Classifier"),
("check_sanity", "Check Sanity"),
],
help_text="Name of the task that was run",
max_length=255,
null=True,
verbose_name="Task Name",
),
),
migrations.RunPython(
code=make_existing_tasks_consume_auto,
reverse_code=migrations.RunPython.noop,
),
migrations.AlterField(
model_name="workflowactionwebhook",
name="url",

View File

@ -650,6 +650,16 @@ class PaperlessTask(ModelWithOwner):
ALL_STATES = sorted(states.ALL_STATES)
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
class TaskType(models.TextChoices):
AUTO = ("auto_task", _("Auto Task"))
SCHEDULED_TASK = ("scheduled_task", _("Scheduled Task"))
MANUAL_TASK = ("manual_task", _("Manual Task"))
class TaskName(models.TextChoices):
CONSUME_FILE = ("consume_file", _("Consume File"))
TRAIN_CLASSIFIER = ("train_classifier", _("Train Classifier"))
CHECK_SANITY = ("check_sanity", _("Check Sanity"))
task_id = models.CharField(
max_length=255,
unique=True,
@ -673,8 +683,9 @@ class PaperlessTask(ModelWithOwner):
task_name = models.CharField(
null=True,
max_length=255,
choices=TaskName.choices,
verbose_name=_("Task Name"),
help_text=_("Name of the Task which was run"),
help_text=_("Name of the task that was run"),
)
status = models.CharField(
@ -684,24 +695,28 @@ class PaperlessTask(ModelWithOwner):
verbose_name=_("Task State"),
help_text=_("Current state of the task being run"),
)
date_created = models.DateTimeField(
null=True,
default=timezone.now,
verbose_name=_("Created DateTime"),
help_text=_("Datetime field when the task result was created in UTC"),
)
date_started = models.DateTimeField(
null=True,
default=None,
verbose_name=_("Started DateTime"),
help_text=_("Datetime field when the task was started in UTC"),
)
date_done = models.DateTimeField(
null=True,
default=None,
verbose_name=_("Completed DateTime"),
help_text=_("Datetime field when the task was completed in UTC"),
)
result = models.TextField(
null=True,
default=None,
@ -711,6 +726,14 @@ class PaperlessTask(ModelWithOwner):
),
)
type = models.CharField(
max_length=30,
choices=TaskType.choices,
default=TaskType.AUTO,
verbose_name=_("Task Type"),
help_text=_("The type of task that was run"),
)
def __str__(self) -> str:
return f"Task {self.task_id}"

View File

@ -1,13 +1,17 @@
import hashlib
import logging
import uuid
from collections import defaultdict
from pathlib import Path
from typing import Final
from celery import states
from django.conf import settings
from django.utils import timezone
from tqdm import tqdm
from documents.models import Document
from documents.models import PaperlessTask
class SanityCheckMessages:
@ -57,7 +61,17 @@ class SanityCheckFailedException(Exception):
pass
def check_sanity(*, progress=False) -> SanityCheckMessages:
def check_sanity(*, progress=False, scheduled=True) -> SanityCheckMessages:
paperless_task = PaperlessTask.objects.create(
task_id=uuid.uuid4(),
type=PaperlessTask.TaskType.SCHEDULED_TASK
if scheduled
else PaperlessTask.TaskType.MANUAL_TASK,
task_name=PaperlessTask.TaskName.CHECK_SANITY,
status=states.STARTED,
date_created=timezone.now(),
date_started=timezone.now(),
)
messages = SanityCheckMessages()
present_files = {
@ -142,4 +156,11 @@ def check_sanity(*, progress=False) -> SanityCheckMessages:
for extra_file in present_files:
messages.warning(None, f"Orphaned file in media dir: {extra_file}")
paperless_task.status = states.SUCCESS if not messages.has_error else states.FAILURE
# result is concatenated messages
paperless_task.result = f"{len(messages)} issues found."
if messages.has_error:
paperless_task.result += " Check logs for details."
paperless_task.date_done = timezone.now()
paperless_task.save(update_fields=["status", "result", "date_done"])
return messages

View File

@ -1704,6 +1704,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
fields = (
"id",
"task_id",
"task_name",
"task_file_name",
"date_created",
"date_done",
@ -1715,12 +1716,6 @@ class TasksViewSerializer(OwnedObjectSerializer):
"owner",
)
type = serializers.SerializerMethodField()
def get_type(self, obj) -> str:
# just file tasks, for now
return "file"
related_document = serializers.SerializerMethodField()
created_doc_re = re.compile(r"New document id (\d+) created")
duplicate_doc_re = re.compile(r"It is a duplicate of .* \(#(\d+)\)")
@ -1728,6 +1723,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
def get_related_document(self, obj) -> str | None:
result = None
re = None
if obj.result:
match obj.status:
case states.SUCCESS:
re = self.created_doc_re

View File

@ -1221,10 +1221,11 @@ def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
user_id = overrides.owner_id if overrides else None
PaperlessTask.objects.create(
type=PaperlessTask.TaskType.AUTO,
task_id=headers["id"],
status=states.PENDING,
task_file_name=task_file_name,
task_name=headers["task"],
task_name=PaperlessTask.TaskName.CONSUME_FILE,
result=None,
date_created=timezone.now(),
date_started=None,

View File

@ -9,6 +9,7 @@ from tempfile import TemporaryDirectory
import tqdm
from celery import Task
from celery import shared_task
from celery import states
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import models
@ -35,6 +36,7 @@ from documents.models import Correspondent
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentType
from documents.models import PaperlessTask
from documents.models import StoragePath
from documents.models import Tag
from documents.models import Workflow
@ -74,19 +76,34 @@ def index_reindex(*, progress_bar_disable=False):
@shared_task
def train_classifier():
def train_classifier(*, scheduled=True):
task = PaperlessTask.objects.create(
type=PaperlessTask.TaskType.SCHEDULED_TASK
if scheduled
else PaperlessTask.TaskType.MANUAL_TASK,
task_id=uuid.uuid4(),
task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
status=states.STARTED,
date_created=timezone.now(),
date_started=timezone.now(),
)
if (
not Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
and not DocumentType.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
and not Correspondent.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
and not StoragePath.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
):
logger.info("No automatic matching items, not training")
result = "No automatic matching items, not training"
logger.info(result)
# Special case, items were once auto and trained, so remove the model
# and prevent its use again
if settings.MODEL_FILE.exists():
logger.info(f"Removing {settings.MODEL_FILE} so it won't be used")
settings.MODEL_FILE.unlink()
task.status = states.SUCCESS
task.result = result
task.date_done = timezone.now()
task.save()
return
classifier = load_classifier()
@ -100,11 +117,19 @@ def train_classifier():
f"Saving updated classifier model to {settings.MODEL_FILE}...",
)
classifier.save()
task.result = "Training completed successfully"
else:
logger.debug("Training data unchanged.")
task.result = "Training data unchanged"
task.status = states.SUCCESS
task.date_done = timezone.now()
task.save(update_fields=["status", "result", "date_done"])
except Exception as e:
logger.warning("Classifier error: " + str(e))
task.status = states.FAILURE
task.result = str(e)
@shared_task(bind=True)

View File

@ -1,18 +1,14 @@
import os
import tempfile
from pathlib import Path
from unittest import mock
from celery import states
from django.contrib.auth.models import User
from django.test import override_settings
from rest_framework import status
from rest_framework.test import APITestCase
from documents.classifier import ClassifierModelCorruptError
from documents.classifier import DocumentClassifier
from documents.classifier import load_classifier
from documents.models import Document
from documents.models import Tag
from documents.models import PaperlessTask
from paperless import version
@ -193,7 +189,6 @@ class TestSystemStatus(APITestCase):
self.assertEqual(response.data["tasks"]["index_status"], "ERROR")
self.assertIsNotNone(response.data["tasks"]["index_error"])
@override_settings(DATA_DIR=Path("/tmp/does_not_exist/data/"))
def test_system_status_classifier_ok(self):
"""
GIVEN:
@ -203,9 +198,11 @@ class TestSystemStatus(APITestCase):
THEN:
- The response contains an OK classifier status
"""
load_classifier()
test_classifier = DocumentClassifier()
test_classifier.save()
PaperlessTask.objects.create(
type=PaperlessTask.TaskType.SCHEDULED_TASK,
status=states.SUCCESS,
task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
)
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@ -215,51 +212,34 @@ class TestSystemStatus(APITestCase):
def test_system_status_classifier_warning(self):
"""
GIVEN:
- The classifier does not exist yet
- > 0 documents and tags with auto matching exist
- No classifier task is found
WHEN:
- The user requests the system status
THEN:
- The response contains an WARNING classifier status
- The response contains a WARNING classifier status
"""
with override_settings(MODEL_FILE=Path("does_not_exist")):
Document.objects.create(
title="Test Document",
)
Tag.objects.create(name="Test Tag", matching_algorithm=Tag.MATCH_AUTO)
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["classifier_status"], "WARNING")
self.assertIsNotNone(response.data["tasks"]["classifier_error"])
@mock.patch(
"documents.classifier.load_classifier",
side_effect=ClassifierModelCorruptError(),
self.assertEqual(
response.data["tasks"]["classifier_status"],
"WARNING",
)
def test_system_status_classifier_error(self, mock_load_classifier):
def test_system_status_classifier_error(self):
"""
GIVEN:
- The classifier does exist but is corrupt
- > 0 documents and tags with auto matching exist
- An error occurred while loading the classifier
WHEN:
- The user requests the system status
THEN:
- The response contains an ERROR classifier status
"""
with (
tempfile.NamedTemporaryFile(
dir="/tmp",
delete=False,
) as does_exist,
override_settings(MODEL_FILE=Path(does_exist.name)),
):
Document.objects.create(
title="Test Document",
)
Tag.objects.create(
name="Test Tag",
matching_algorithm=Tag.MATCH_AUTO,
PaperlessTask.objects.create(
type=PaperlessTask.TaskType.SCHEDULED_TASK,
status=states.FAILURE,
task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
result="Classifier training failed",
)
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
@ -270,18 +250,63 @@ class TestSystemStatus(APITestCase):
)
self.assertIsNotNone(response.data["tasks"]["classifier_error"])
def test_system_status_classifier_ok_no_objects(self):
def test_system_status_sanity_check_ok(self):
"""
GIVEN:
- The classifier does not exist (and should not)
- No documents nor objects with auto matching exist
- The sanity check is successful
WHEN:
- The user requests the system status
THEN:
- The response contains an OK classifier status
- The response contains an OK sanity check status
"""
with override_settings(MODEL_FILE=Path("does_not_exist")):
PaperlessTask.objects.create(
type=PaperlessTask.TaskType.SCHEDULED_TASK,
status=states.SUCCESS,
task_name=PaperlessTask.TaskName.CHECK_SANITY,
)
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["tasks"]["classifier_status"], "OK")
self.assertEqual(response.data["tasks"]["sanity_check_status"], "OK")
self.assertIsNone(response.data["tasks"]["sanity_check_error"])
def test_system_status_sanity_check_warning(self):
"""
GIVEN:
- No sanity check task is found
WHEN:
- The user requests the system status
THEN:
- The response contains a WARNING sanity check status
"""
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data["tasks"]["sanity_check_status"],
"WARNING",
)
def test_system_status_sanity_check_error(self):
"""
GIVEN:
- The sanity check failed
WHEN:
- The user requests the system status
THEN:
- The response contains an ERROR sanity check status
"""
PaperlessTask.objects.create(
type=PaperlessTask.TaskType.SCHEDULED_TASK,
status=states.FAILURE,
task_name=PaperlessTask.TaskName.CHECK_SANITY,
result="5 issues found.",
)
self.client.force_login(self.user)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data["tasks"]["sanity_check_status"],
"ERROR",
)
self.assertIsNotNone(response.data["tasks"]["sanity_check_error"])

View File

@ -130,7 +130,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self.client.get(self.ENDPOINT)
response = self.client.get(self.ENDPOINT + "?acknowledged=false")
self.assertEqual(len(response.data), 0)
def test_tasks_owner_aware(self):
@ -246,7 +246,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_file_name="test.pdf",
task_name="documents.tasks.some_task",
task_name=PaperlessTask.TaskName.CONSUME_FILE,
status=celery.states.SUCCESS,
)
@ -272,7 +272,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
PaperlessTask.objects.create(
task_id=str(uuid.uuid4()),
task_file_name="anothertest.pdf",
task_name="documents.tasks.some_task",
task_name=PaperlessTask.TaskName.CONSUME_FILE,
status=celery.states.SUCCESS,
)

View File

@ -68,7 +68,7 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
self.assertIsNotNone(task)
self.assertEqual(headers["id"], task.task_id)
self.assertEqual("hello-999.pdf", task.task_file_name)
self.assertEqual("documents.tasks.consume_file", task.task_name)
self.assertEqual(PaperlessTask.TaskName.CONSUME_FILE, task.task_name)
self.assertEqual(1, task.owner_id)
self.assertEqual(celery.states.PENDING, task.status)

View File

@ -15,6 +15,7 @@ from urllib.parse import quote
from urllib.parse import urlparse
import pathvalidate
from celery import states
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
@ -103,6 +104,7 @@ from documents.filters import DocumentsOrderingFilter
from documents.filters import DocumentTypeFilterSet
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
from documents.filters import ObjectOwnedPermissionsFilter
from documents.filters import PaperlessTaskFilterSet
from documents.filters import ShareLinkFilterSet
from documents.filters import StoragePathFilterSet
from documents.filters import TagFilterSet
@ -2224,16 +2226,15 @@ class RemoteVersionView(GenericAPIView):
class TasksViewSet(ReadOnlyModelViewSet):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = TasksViewSerializer
filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = PaperlessTaskFilterSet
def get_queryset(self):
queryset = (
PaperlessTask.objects.filter(
acknowledged=False,
)
.order_by("date_created")
.reverse()
)
queryset = PaperlessTask.objects.all().order_by("-date_created")
task_id = self.request.query_params.get("task_id")
if task_id is not None:
queryset = PaperlessTask.objects.filter(task_id=task_id)
@ -2562,6 +2563,14 @@ class CustomFieldViewSet(ModelViewSet):
"last_trained": serializers.DateTimeField(),
},
),
"sanity_check": inline_serializer(
name="SanityCheck",
fields={
"status": serializers.CharField(),
"error": serializers.CharField(),
"last_run": serializers.DateTimeField(),
},
),
},
),
},
@ -2570,6 +2579,17 @@ class CustomFieldViewSet(ModelViewSet):
class SystemStatusView(PassUserMixin):
permission_classes = (IsAuthenticated,)
def _get_next_scheduled_task_schedule(
self,
schedule: dict,
task_name: str,
last_run,
) -> datetime | None:
# example: {'Check all e-mail accounts': {'task': 'paperless_mail.tasks.process_mail_accounts', 'schedule': <crontab: */10 * * * * (m/h/dM/MY/d)>, 'options': {'expires': 540.0}}, 'Train the classifier': {'task': 'documents.tasks.train_classifier', 'schedule': <crontab: 5 */1 * * * (m/h/dM/MY/d)>, 'options': {'expires': 3540.0}}, 'Optimize the index': {'task': 'documents.tasks.index_optimize', 'schedule': <crontab: 0 0 * * * (m/h/dM/MY/d)>, 'options': {'expires': 82800.0}}, 'Perform sanity check': {'task': 'documents.tasks.sanity_check', 'schedule': <crontab: 30 0 * * sun (m/h/dM/MY/d)>, 'options': {'expires': 601200.0}}, 'Empty trash': {'task': 'documents.tasks.empty_trash', 'schedule': <crontab: 0 1 * * * (m/h/dM/MY/d)>, 'options': {'expires': 82800.0}}, 'Check and run scheduled workflows': {'task': 'documents.tasks.check_scheduled_workflows', 'schedule': <crontab: 5 */1 * * * (m/h/dM/MY/d)>, 'options': {'expires': 3540.0}}}
for _, task_data in schedule.items():
if task_data["task"] and task_data["task"].find(task_name) != -1:
return task_data["schedule"]
def get(self, request, format=None):
if not request.user.is_staff:
return HttpResponseForbidden("Insufficient permissions")
@ -2622,13 +2642,22 @@ class SystemStatusView(PassUserMixin):
)
redis_error = "Error connecting to redis, check logs for more detail."
celery_error = None
celery_url = None
schedule = None
try:
celery_ping = celery_app.control.inspect().ping()
first_worker_ping = celery_ping[next(iter(celery_ping.keys()))]
celery_url = next(iter(celery_ping.keys()))
first_worker_ping = celery_ping[celery_url]
schedule = celery_app.conf.beat_schedule
if first_worker_ping["ok"] == "pong":
celery_active = "OK"
except Exception:
except Exception as e:
celery_active = "ERROR"
logger.exception(
f"System status detected a possible problem while connecting to celery: {e}",
)
celery_error = "Error connecting to celery, check logs for more detail."
index_error = None
try:
@ -2645,54 +2674,72 @@ class SystemStatusView(PassUserMixin):
)
index_last_modified = None
classifier_error = None
classifier_status = None
try:
classifier = load_classifier(raise_exception=True)
if classifier is None:
# Make sure classifier should exist
docs_queryset = Document.objects.exclude(
tags__is_inbox_tag=True,
last_trained_task = (
PaperlessTask.objects.filter(
task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
)
if (
docs_queryset.count() > 0
and (
Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
or DocumentType.objects.filter(
matching_algorithm=Tag.MATCH_AUTO,
).exists()
or Correspondent.objects.filter(
matching_algorithm=Tag.MATCH_AUTO,
).exists()
or StoragePath.objects.filter(
matching_algorithm=Tag.MATCH_AUTO,
).exists()
.order_by("-date_done")
.first()
)
and not settings.MODEL_FILE.exists()
):
# if classifier file doesn't exist just classify as a warning
classifier_error = "Classifier file does not exist (yet). Re-training may be pending."
classifier_status = "WARNING"
raise FileNotFoundError(classifier_error)
classifier_status = "OK"
classifier_last_trained = (
make_aware(
datetime.fromtimestamp(classifier.get_last_checked()),
)
if settings.MODEL_FILE.exists()
and classifier.get_last_checked() is not None
else None
)
except Exception as e:
if classifier_status is None:
classifier_error = None
classifier_next_training = None
if last_trained_task is None:
classifier_status = "WARNING"
classifier_error = "No classifier training tasks found"
elif last_trained_task and last_trained_task.status == states.FAILURE:
classifier_status = "ERROR"
classifier_last_trained = None
if classifier_error is None:
classifier_error = (
"Unable to load classifier, check logs for more detail."
classifier_error = last_trained_task.result
classifier_last_trained = (
last_trained_task.date_done if last_trained_task else None
)
logger.exception(
f"System status detected a possible problem while loading the classifier: {e}",
last_scheduled_trained_task = (
PaperlessTask.objects.filter(
task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
type=PaperlessTask.TaskType.SCHEDULED_TASK,
)
.order_by("-date_done")
.first()
)
if last_scheduled_trained_task and schedule:
classifier_next_training: datetime = self._get_next_scheduled_task_schedule(
schedule=schedule,
task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
last_run=last_trained_task.date_done,
)
last_sanity_check = (
PaperlessTask.objects.filter(
task_name=PaperlessTask.TaskName.CHECK_SANITY,
)
.order_by("-date_done")
.first()
)
sanity_check_status = "OK"
sanity_check_error = None
sanity_check_next_run = None
if last_sanity_check is None:
sanity_check_status = "WARNING"
sanity_check_error = "No sanity check tasks found"
elif last_sanity_check and last_sanity_check.status == states.FAILURE:
sanity_check_status = "ERROR"
sanity_check_error = last_sanity_check.result
sanity_check_last_run = (
last_sanity_check.date_done if last_sanity_check else None
)
last_scheduled_sanity_check = (
PaperlessTask.objects.filter(
task_name=PaperlessTask.TaskName.CHECK_SANITY,
type=PaperlessTask.TaskType.SCHEDULED_TASK,
)
.order_by("-date_done")
.first()
)
if last_scheduled_sanity_check and schedule:
sanity_check_next_run: datetime = self._get_next_scheduled_task_schedule(
schedule=schedule,
task_name=PaperlessTask.TaskName.CHECK_SANITY,
last_run=last_sanity_check.date_done,
)
return Response(
@ -2721,12 +2768,19 @@ class SystemStatusView(PassUserMixin):
"redis_status": redis_status,
"redis_error": redis_error,
"celery_status": celery_active,
"celery_url": celery_url,
"celery_error": celery_error,
"index_status": index_status,
"index_last_modified": index_last_modified,
"index_error": index_error,
"classifier_status": classifier_status,
"classifier_last_trained": classifier_last_trained,
"classifier_next_training": classifier_next_training,
"classifier_error": classifier_error,
"sanity_check_status": sanity_check_status,
"sanity_check_last_run": sanity_check_last_run,
"sanity_check_next_run": sanity_check_next_run,
"sanity_check_error": sanity_check_error,
},
},
)

View File

@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-11 18:43-0800\n"
"POT-Creation-Date: 2025-02-14 15:45-0800\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@ -21,67 +21,67 @@ msgstr ""
msgid "Documents"
msgstr ""
#: documents/filters.py:369
#: documents/filters.py:370
msgid "Value must be valid JSON."
msgstr ""
#: documents/filters.py:388
#: documents/filters.py:389
msgid "Invalid custom field query expression"
msgstr ""
#: documents/filters.py:398
#: documents/filters.py:399
msgid "Invalid expression list. Must be nonempty."
msgstr ""
#: documents/filters.py:419
#: documents/filters.py:420
msgid "Invalid logical operator {op!r}"
msgstr ""
#: documents/filters.py:433
#: documents/filters.py:434
msgid "Maximum number of query conditions exceeded."
msgstr ""
#: documents/filters.py:498
#: documents/filters.py:499
msgid "{name!r} is not a valid custom field."
msgstr ""
#: documents/filters.py:535
#: documents/filters.py:536
msgid "{data_type} does not support query expr {expr!r}."
msgstr ""
#: documents/filters.py:643
#: documents/filters.py:644
msgid "Maximum nesting depth exceeded."
msgstr ""
#: documents/filters.py:813
#: documents/filters.py:829
msgid "Custom field not found"
msgstr ""
#: documents/models.py:41 documents/models.py:806
#: documents/models.py:41 documents/models.py:829
msgid "owner"
msgstr ""
#: documents/models.py:58 documents/models.py:1017
#: documents/models.py:58 documents/models.py:1040
msgid "None"
msgstr ""
#: documents/models.py:59 documents/models.py:1018
#: documents/models.py:59 documents/models.py:1041
msgid "Any word"
msgstr ""
#: documents/models.py:60 documents/models.py:1019
#: documents/models.py:60 documents/models.py:1042
msgid "All words"
msgstr ""
#: documents/models.py:61 documents/models.py:1020
#: documents/models.py:61 documents/models.py:1043
msgid "Exact match"
msgstr ""
#: documents/models.py:62 documents/models.py:1021
#: documents/models.py:62 documents/models.py:1044
msgid "Regular expression"
msgstr ""
#: documents/models.py:63 documents/models.py:1022
#: documents/models.py:63 documents/models.py:1045
msgid "Fuzzy word"
msgstr ""
@ -89,20 +89,20 @@ msgstr ""
msgid "Automatic"
msgstr ""
#: documents/models.py:67 documents/models.py:433 documents/models.py:1498
#: documents/models.py:67 documents/models.py:433 documents/models.py:1521
#: paperless_mail/models.py:23 paperless_mail/models.py:143
msgid "name"
msgstr ""
#: documents/models.py:69 documents/models.py:1085
#: documents/models.py:69 documents/models.py:1108
msgid "match"
msgstr ""
#: documents/models.py:72 documents/models.py:1088
#: documents/models.py:72 documents/models.py:1111
msgid "matching algorithm"
msgstr ""
#: documents/models.py:77 documents/models.py:1093
#: documents/models.py:77 documents/models.py:1116
msgid "is insensitive"
msgstr ""
@ -168,7 +168,7 @@ msgstr ""
msgid "title"
msgstr ""
#: documents/models.py:175 documents/models.py:720
#: documents/models.py:175 documents/models.py:743
msgid "content"
msgstr ""
@ -206,8 +206,8 @@ msgstr ""
msgid "The number of pages of the document."
msgstr ""
#: documents/models.py:221 documents/models.py:401 documents/models.py:726
#: documents/models.py:764 documents/models.py:835 documents/models.py:893
#: documents/models.py:221 documents/models.py:401 documents/models.py:749
#: documents/models.py:787 documents/models.py:858 documents/models.py:916
msgid "created"
msgstr ""
@ -255,8 +255,8 @@ msgstr ""
msgid "The position of this document in your physical document archive."
msgstr ""
#: documents/models.py:295 documents/models.py:737 documents/models.py:791
#: documents/models.py:1541
#: documents/models.py:295 documents/models.py:760 documents/models.py:814
#: documents/models.py:1564
msgid "document"
msgstr ""
@ -320,11 +320,11 @@ msgstr ""
msgid "Title"
msgstr ""
#: documents/models.py:420 documents/models.py:1037
#: documents/models.py:420 documents/models.py:1060
msgid "Created"
msgstr ""
#: documents/models.py:421 documents/models.py:1036
#: documents/models.py:421 documents/models.py:1059
msgid "Added"
msgstr ""
@ -608,563 +608,595 @@ msgstr ""
msgid "filter rules"
msgstr ""
#: documents/models.py:654
msgid "Auto Task"
msgstr ""
#: documents/models.py:655
msgid "Scheduled Task"
msgstr ""
#: documents/models.py:656
msgid "Manual Task"
msgstr ""
#: documents/models.py:659
msgid "Consume File"
msgstr ""
#: documents/models.py:660
msgid "Train Classifier"
msgstr ""
#: documents/models.py:661
msgid "Check Sanity"
msgstr ""
#: documents/models.py:666
msgid "Task ID"
msgstr ""
#: documents/models.py:657
#: documents/models.py:667
msgid "Celery ID for the Task that was run"
msgstr ""
#: documents/models.py:662
#: documents/models.py:672
msgid "Acknowledged"
msgstr ""
#: documents/models.py:663
#: documents/models.py:673
msgid "If the task is acknowledged via the frontend or API"
msgstr ""
#: documents/models.py:669
#: documents/models.py:679
msgid "Task Filename"
msgstr ""
#: documents/models.py:670
#: documents/models.py:680
msgid "Name of the file which the Task was run for"
msgstr ""
#: documents/models.py:676
#: documents/models.py:687
msgid "Task Name"
msgstr ""
#: documents/models.py:677
msgid "Name of the Task which was run"
#: documents/models.py:688
msgid "Name of the task that was run"
msgstr ""
#: documents/models.py:684
#: documents/models.py:695
msgid "Task State"
msgstr ""
#: documents/models.py:685
#: documents/models.py:696
msgid "Current state of the task being run"
msgstr ""
#: documents/models.py:690
#: documents/models.py:702
msgid "Created DateTime"
msgstr ""
#: documents/models.py:691
#: documents/models.py:703
msgid "Datetime field when the task result was created in UTC"
msgstr ""
#: documents/models.py:696
#: documents/models.py:709
msgid "Started DateTime"
msgstr ""
#: documents/models.py:697
#: documents/models.py:710
msgid "Datetime field when the task was started in UTC"
msgstr ""
#: documents/models.py:702
#: documents/models.py:716
msgid "Completed DateTime"
msgstr ""
#: documents/models.py:703
#: documents/models.py:717
msgid "Datetime field when the task was completed in UTC"
msgstr ""
#: documents/models.py:708
#: documents/models.py:723
msgid "Result Data"
msgstr ""
#: documents/models.py:710
#: documents/models.py:725
msgid "The data returned by the task"
msgstr ""
#: documents/models.py:722
#: documents/models.py:733
msgid "Task Type"
msgstr ""
#: documents/models.py:734
msgid "The type of task that was run"
msgstr ""
#: documents/models.py:745
msgid "Note for the document"
msgstr ""
#: documents/models.py:746
#: documents/models.py:769
msgid "user"
msgstr ""
#: documents/models.py:751
#: documents/models.py:774
msgid "note"
msgstr ""
#: documents/models.py:752
#: documents/models.py:775
msgid "notes"
msgstr ""
#: documents/models.py:760
#: documents/models.py:783
msgid "Archive"
msgstr ""
#: documents/models.py:761
#: documents/models.py:784
msgid "Original"
msgstr ""
#: documents/models.py:772 paperless_mail/models.py:75
#: documents/models.py:795 paperless_mail/models.py:75
msgid "expiration"
msgstr ""
#: documents/models.py:779
#: documents/models.py:802
msgid "slug"
msgstr ""
#: documents/models.py:811
#: documents/models.py:834
msgid "share link"
msgstr ""
#: documents/models.py:812
#: documents/models.py:835
msgid "share links"
msgstr ""
#: documents/models.py:824
#: documents/models.py:847
msgid "String"
msgstr ""
#: documents/models.py:825
#: documents/models.py:848
msgid "URL"
msgstr ""
#: documents/models.py:826
#: documents/models.py:849
msgid "Date"
msgstr ""
#: documents/models.py:827
#: documents/models.py:850
msgid "Boolean"
msgstr ""
#: documents/models.py:828
#: documents/models.py:851
msgid "Integer"
msgstr ""
#: documents/models.py:829
#: documents/models.py:852
msgid "Float"
msgstr ""
#: documents/models.py:830
#: documents/models.py:853
msgid "Monetary"
msgstr ""
#: documents/models.py:831
#: documents/models.py:854
msgid "Document Link"
msgstr ""
#: documents/models.py:832
#: documents/models.py:855
msgid "Select"
msgstr ""
#: documents/models.py:844
#: documents/models.py:867
msgid "data type"
msgstr ""
#: documents/models.py:851
#: documents/models.py:874
msgid "extra data"
msgstr ""
#: documents/models.py:855
#: documents/models.py:878
msgid "Extra data for the custom field, such as select options"
msgstr ""
#: documents/models.py:861
#: documents/models.py:884
msgid "custom field"
msgstr ""
#: documents/models.py:862
#: documents/models.py:885
msgid "custom fields"
msgstr ""
#: documents/models.py:959
#: documents/models.py:982
msgid "custom field instance"
msgstr ""
#: documents/models.py:960
#: documents/models.py:983
msgid "custom field instances"
msgstr ""
#: documents/models.py:1025
#: documents/models.py:1048
msgid "Consumption Started"
msgstr ""
#: documents/models.py:1026
#: documents/models.py:1049
msgid "Document Added"
msgstr ""
#: documents/models.py:1027
#: documents/models.py:1050
msgid "Document Updated"
msgstr ""
#: documents/models.py:1028
#: documents/models.py:1051
msgid "Scheduled"
msgstr ""
#: documents/models.py:1031
#: documents/models.py:1054
msgid "Consume Folder"
msgstr ""
#: documents/models.py:1032
#: documents/models.py:1055
msgid "Api Upload"
msgstr ""
#: documents/models.py:1033
#: documents/models.py:1056
msgid "Mail Fetch"
msgstr ""
#: documents/models.py:1038
#: documents/models.py:1061
msgid "Modified"
msgstr ""
#: documents/models.py:1039
#: documents/models.py:1062
msgid "Custom Field"
msgstr ""
#: documents/models.py:1042
#: documents/models.py:1065
msgid "Workflow Trigger Type"
msgstr ""
#: documents/models.py:1054
#: documents/models.py:1077
msgid "filter path"
msgstr ""
#: documents/models.py:1059
#: documents/models.py:1082
msgid ""
"Only consume documents with a path that matches this if specified. Wildcards "
"specified as * are allowed. Case insensitive."
msgstr ""
#: documents/models.py:1066
#: documents/models.py:1089
msgid "filter filename"
msgstr ""
#: documents/models.py:1071 paperless_mail/models.py:200
#: documents/models.py:1094 paperless_mail/models.py:200
msgid ""
"Only consume documents which entirely match this filename if specified. "
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
msgstr ""
#: documents/models.py:1082
#: documents/models.py:1105
msgid "filter documents from this mail rule"
msgstr ""
#: documents/models.py:1098
#: documents/models.py:1121
msgid "has these tag(s)"
msgstr ""
#: documents/models.py:1106
#: documents/models.py:1129
msgid "has this document type"
msgstr ""
#: documents/models.py:1114
#: documents/models.py:1137
msgid "has this correspondent"
msgstr ""
#: documents/models.py:1118
#: documents/models.py:1141
msgid "schedule offset days"
msgstr ""
#: documents/models.py:1121
#: documents/models.py:1144
msgid "The number of days to offset the schedule trigger by."
msgstr ""
#: documents/models.py:1126
#: documents/models.py:1149
msgid "schedule is recurring"
msgstr ""
#: documents/models.py:1129
#: documents/models.py:1152
msgid "If the schedule should be recurring."
msgstr ""
#: documents/models.py:1134
#: documents/models.py:1157
msgid "schedule recurring delay in days"
msgstr ""
#: documents/models.py:1138
#: documents/models.py:1161
msgid "The number of days between recurring schedule triggers."
msgstr ""
#: documents/models.py:1143
#: documents/models.py:1166
msgid "schedule date field"
msgstr ""
#: documents/models.py:1148
#: documents/models.py:1171
msgid "The field to check for a schedule trigger."
msgstr ""
#: documents/models.py:1157
#: documents/models.py:1180
msgid "schedule date custom field"
msgstr ""
#: documents/models.py:1161
#: documents/models.py:1184
msgid "workflow trigger"
msgstr ""
#: documents/models.py:1162
#: documents/models.py:1185
msgid "workflow triggers"
msgstr ""
#: documents/models.py:1170
#: documents/models.py:1193
msgid "email subject"
msgstr ""
#: documents/models.py:1174
#: documents/models.py:1197
msgid ""
"The subject of the email, can include some placeholders, see documentation."
msgstr ""
#: documents/models.py:1180
#: documents/models.py:1203
msgid "email body"
msgstr ""
#: documents/models.py:1183
#: documents/models.py:1206
msgid ""
"The body (message) of the email, can include some placeholders, see "
"documentation."
msgstr ""
#: documents/models.py:1189
#: documents/models.py:1212
msgid "emails to"
msgstr ""
#: documents/models.py:1192
#: documents/models.py:1215
msgid "The destination email addresses, comma separated."
msgstr ""
#: documents/models.py:1198
#: documents/models.py:1221
msgid "include document in email"
msgstr ""
#: documents/models.py:1207
#: documents/models.py:1230
msgid "webhook url"
msgstr ""
#: documents/models.py:1209
#: documents/models.py:1232
msgid "The destination URL for the notification."
msgstr ""
#: documents/models.py:1214
#: documents/models.py:1237
msgid "use parameters"
msgstr ""
#: documents/models.py:1219
#: documents/models.py:1242
msgid "send as JSON"
msgstr ""
#: documents/models.py:1223
#: documents/models.py:1246
msgid "webhook parameters"
msgstr ""
#: documents/models.py:1226
#: documents/models.py:1249
msgid "The parameters to send with the webhook URL if body not used."
msgstr ""
#: documents/models.py:1230
#: documents/models.py:1253
msgid "webhook body"
msgstr ""
#: documents/models.py:1233
#: documents/models.py:1256
msgid "The body to send with the webhook URL if parameters not used."
msgstr ""
#: documents/models.py:1237
#: documents/models.py:1260
msgid "webhook headers"
msgstr ""
#: documents/models.py:1240
#: documents/models.py:1263
msgid "The headers to send with the webhook URL."
msgstr ""
#: documents/models.py:1245
#: documents/models.py:1268
msgid "include document in webhook"
msgstr ""
#: documents/models.py:1256
#: documents/models.py:1279
msgid "Assignment"
msgstr ""
#: documents/models.py:1260
#: documents/models.py:1283
msgid "Removal"
msgstr ""
#: documents/models.py:1264 documents/templates/account/password_reset.html:15
#: documents/models.py:1287 documents/templates/account/password_reset.html:15
msgid "Email"
msgstr ""
#: documents/models.py:1268
#: documents/models.py:1291
msgid "Webhook"
msgstr ""
#: documents/models.py:1272
#: documents/models.py:1295
msgid "Workflow Action Type"
msgstr ""
#: documents/models.py:1278
#: documents/models.py:1301
msgid "assign title"
msgstr ""
#: documents/models.py:1283
#: documents/models.py:1306
msgid ""
"Assign a document title, can include some placeholders, see documentation."
msgstr ""
#: documents/models.py:1292 paperless_mail/models.py:274
#: documents/models.py:1315 paperless_mail/models.py:274
msgid "assign this tag"
msgstr ""
#: documents/models.py:1301 paperless_mail/models.py:282
#: documents/models.py:1324 paperless_mail/models.py:282
msgid "assign this document type"
msgstr ""
#: documents/models.py:1310 paperless_mail/models.py:296
#: documents/models.py:1333 paperless_mail/models.py:296
msgid "assign this correspondent"
msgstr ""
#: documents/models.py:1319
#: documents/models.py:1342
msgid "assign this storage path"
msgstr ""
#: documents/models.py:1328
#: documents/models.py:1351
msgid "assign this owner"
msgstr ""
#: documents/models.py:1335
#: documents/models.py:1358
msgid "grant view permissions to these users"
msgstr ""
#: documents/models.py:1342
#: documents/models.py:1365
msgid "grant view permissions to these groups"
msgstr ""
#: documents/models.py:1349
#: documents/models.py:1372
msgid "grant change permissions to these users"
msgstr ""
#: documents/models.py:1356
#: documents/models.py:1379
msgid "grant change permissions to these groups"
msgstr ""
#: documents/models.py:1363
#: documents/models.py:1386
msgid "assign these custom fields"
msgstr ""
#: documents/models.py:1370
#: documents/models.py:1393
msgid "remove these tag(s)"
msgstr ""
#: documents/models.py:1375
#: documents/models.py:1398
msgid "remove all tags"
msgstr ""
#: documents/models.py:1382
#: documents/models.py:1405
msgid "remove these document type(s)"
msgstr ""
#: documents/models.py:1387
#: documents/models.py:1410
msgid "remove all document types"
msgstr ""
#: documents/models.py:1394
#: documents/models.py:1417
msgid "remove these correspondent(s)"
msgstr ""
#: documents/models.py:1399
#: documents/models.py:1422
msgid "remove all correspondents"
msgstr ""
#: documents/models.py:1406
#: documents/models.py:1429
msgid "remove these storage path(s)"
msgstr ""
#: documents/models.py:1411
#: documents/models.py:1434
msgid "remove all storage paths"
msgstr ""
#: documents/models.py:1418
#: documents/models.py:1441
msgid "remove these owner(s)"
msgstr ""
#: documents/models.py:1423
#: documents/models.py:1446
msgid "remove all owners"
msgstr ""
#: documents/models.py:1430
#: documents/models.py:1453
msgid "remove view permissions for these users"
msgstr ""
#: documents/models.py:1437
#: documents/models.py:1460
msgid "remove view permissions for these groups"
msgstr ""
#: documents/models.py:1444
#: documents/models.py:1467
msgid "remove change permissions for these users"
msgstr ""
#: documents/models.py:1451
#: documents/models.py:1474
msgid "remove change permissions for these groups"
msgstr ""
#: documents/models.py:1456
#: documents/models.py:1479
msgid "remove all permissions"
msgstr ""
#: documents/models.py:1463
#: documents/models.py:1486
msgid "remove these custom fields"
msgstr ""
#: documents/models.py:1468
#: documents/models.py:1491
msgid "remove all custom fields"
msgstr ""
#: documents/models.py:1477
#: documents/models.py:1500
msgid "email"
msgstr ""
#: documents/models.py:1486
#: documents/models.py:1509
msgid "webhook"
msgstr ""
#: documents/models.py:1490
#: documents/models.py:1513
msgid "workflow action"
msgstr ""
#: documents/models.py:1491
#: documents/models.py:1514
msgid "workflow actions"
msgstr ""
#: documents/models.py:1500 paperless_mail/models.py:145
#: documents/models.py:1523 paperless_mail/models.py:145
msgid "order"
msgstr ""
#: documents/models.py:1506
#: documents/models.py:1529
msgid "triggers"
msgstr ""
#: documents/models.py:1513
#: documents/models.py:1536
msgid "actions"
msgstr ""
#: documents/models.py:1516 paperless_mail/models.py:154
#: documents/models.py:1539 paperless_mail/models.py:154
msgid "enabled"
msgstr ""
#: documents/models.py:1527
#: documents/models.py:1550
msgid "workflow"
msgstr ""
#: documents/models.py:1531
#: documents/models.py:1554
msgid "workflow trigger type"
msgstr ""
#: documents/models.py:1545
#: documents/models.py:1568
msgid "date run"
msgstr ""
#: documents/models.py:1551
#: documents/models.py:1574
msgid "workflow run"
msgstr ""
#: documents/models.py:1552
#: documents/models.py:1575
msgid "workflow runs"
msgstr ""
@ -1402,21 +1434,6 @@ msgstr ""
msgid "As a final step, please complete the following form:"
msgstr ""
#: documents/validators.py:17
#, python-brace-format
msgid "Unable to parse URI {value}, missing scheme"
msgstr ""
#: documents/validators.py:22
#, python-brace-format
msgid "Unable to parse URI {value}, missing net location or path"
msgstr ""
#: documents/validators.py:27
#, python-brace-format
msgid "Unable to parse URI {value}"
msgstr ""
#: paperless/apps.py:10
msgid "Paperless"
msgstr ""