<source>Error retrieving users</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
- <context context-type="linenumber">217</context>
+ <context context-type="linenumber">220</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
<source>Error retrieving groups</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
- <context context-type="linenumber">236</context>
+ <context context-type="linenumber">239</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
<source>Settings were saved successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
- <context context-type="linenumber">532</context>
+ <context context-type="linenumber">535</context>
</context-group>
</trans-unit>
<trans-unit id="525012668859298131" datatype="html">
<source>Settings were saved successfully. Reload is required to apply some changes.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
- <context context-type="linenumber">536</context>
+ <context context-type="linenumber">539</context>
</context-group>
</trans-unit>
<trans-unit id="8491974984518503778" datatype="html">
<source>Reload now</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
- <context context-type="linenumber">537</context>
+ <context context-type="linenumber">540</context>
</context-group>
</trans-unit>
<trans-unit id="3011185103048412841" datatype="html">
<source>An error occurred while saving settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
- <context context-type="linenumber">547</context>
+ <context context-type="linenumber">550</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<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">175</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">209</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">243</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>
</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">261</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
<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="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">166</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">200</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">234</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">173</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">178</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">207</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">212</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">241</context>
</context-group>
</trans-unit>
<trans-unit id="6732151329960766506" datatype="html">
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))
component['systemStatus'].database.status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
+ component['systemStatus'].tasks.sanity_check_status =
+ SystemStatusItemStatus.OK
expect(component.systemStatusHasErrors).toBeFalsy()
})
this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
- this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR
+ this.systemStatus.tasks.classifier_status ===
+ SystemStatusItemStatus.ERROR ||
+ this.systemStatus.tasks.sanity_check_status ===
+ SystemStatusItemStatus.ERROR
)
}
import { routes } from 'src/app/app-routing.module'
import {
PaperlessTask,
+ PaperlessTaskName,
PaperlessTaskStatus,
PaperlessTaskType,
} from 'src/app/data/paperless-task'
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,
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)',
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,
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,
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,
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,
jest.useFakeTimers()
fixture.detectChanges()
httpTestingController
- .expectOne(`${environment.apiBaseUrl}tasks/`)
+ .expectOne(
+ `${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
+ )
.flush(tasks)
})
<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">
</div>
</div>
} @else {
- <div class="row row-cols-1 row-cols-md-3 g-3">
+ <div class="row row-cols-1 row-cols-md-4 g-3">
<div class="col">
<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">
<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">
- {{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>
- } @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>
- }
+ <dd>
+ <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="databaseStatus" triggers="click mouseenter:mouseleave">
+ {{status.database.status}}
+ @if (status.database.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>
+ }
+ </button>
+ <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">
- @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>
- } @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-template #migrationStatus>
- <h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span>
- @if (status.database.migration_status.unapplied_migrations.length > 0) {
- <h6 class="mt-3"><ng-container i18n>Pending Migrations</ng-container>:</h6>
- <ul>
- @for (migration of status.database.migration_status.unapplied_migrations; track migration) {
- <li class="font-monospace small">{{migration}}</li>
- }
- </ul>
+ <dd>
+ <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="migrationStatus" triggers="click 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"></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"></i-bs>
}
- </ng-template>
+ <ng-template #migrationStatus>
+ <h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span>
+ @if (status.database.migration_status.unapplied_migrations.length > 0) {
+ <h6 class="mt-3"><ng-container i18n>Pending Migrations</ng-container>:</h6>
+ <ul>
+ @for (migration of status.database.migration_status.unapplied_migrations; track migration) {
+ <li class="font-monospace small">{{migration}}</li>
+ }
+ </ul>
+ }
+ </ng-template>
+ </button>
</dd>
</dl>
</div>
<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">
- {{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>
- } @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>
- }
+ <dd>
+ <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="redisStatus" triggers="click 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"></i-bs>
+ } @else {
+ <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
+ }
+ </button>
+ <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">
- {{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>
- }
+ <dd>
+ <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="celeryStatus" triggers="click 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>
+ }
+ </button>
+ <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">
- {{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>
+ <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>
+ }
} @else {
- <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs>
+ <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
+ }
+ </button>
+ @if (currentUserIsSuperUser) {
+ @if (isRunning(PaperlessTaskName.IndexOptimize)) {
+ <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
+ } @else {
+ <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.IndexOptimize)">
+ <i-bs name="play-fill"></i-bs>
+ <ng-container i18n>Run Task</ng-container>
+ </button>
}
- } @else {
- <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.index_error}}" triggers="mouseenter:mouseleave"></i-bs>
}
</dd>
<ng-template #indexStatus>
- <h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_last_modified | customDate:'medium'}}</span>
+ @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">
- {{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>
+ <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="classifierStatus" triggers="click 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"></i-bs>
+ } @else {
+ <i-bs name="check-circle-fill" class="text-primary 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>
- }
- } @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>
+ }
+ </button>
+ @if (currentUserIsSuperUser) {
+ @if (isRunning(PaperlessTaskName.TrainClassifier)) {
+ <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
+ } @else {
+ <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.TrainClassifier)">
+ <i-bs name="play-fill"></i-bs>
+ <ng-container i18n>Run Task</ng-container>
+ </button>
+ }
}
</dd>
<ng-template #classifierStatus>
- <h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_last_trained | customDate:'medium'}}</span>
+ @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 class="d-flex align-items-center">
+ <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="sanityCheckerStatus" triggers="click 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>
+ }
+ </button>
+ @if (currentUserIsSuperUser) {
+ @if (isRunning(PaperlessTaskName.SanityCheck)) {
+ <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
+ } @else {
+ <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.SanityCheck)">
+ <i-bs name="play-fill"></i-bs>
+ <ng-container i18n>Run Task</ng-container>
+ </button>
+ }
+ }
+ </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>
}
</div>
<div class="modal-footer">
- <button class="btn btn-sm btn-outline-secondary" (click)="copy()">
+ <button class="btn btn-sm d-flex align-items-center btn-dark btn btn-sm d-flex align-items-center btn-dark btn-outline-secondary" (click)="copy()">
@if (!copied) {
<i-bs name="clipboard-fill"></i-bs>
}
+.btn.small {
+ font-size: 0.75rem;
+}
} from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { of, throwError } from 'rxjs'
+import { PaperlessTaskName } from 'src/app/data/paperless-task'
import {
InstallType,
SystemStatus,
SystemStatusItemStatus,
} from 'src/app/data/system-status'
+import { SystemStatusService } from 'src/app/services/system-status.service'
+import { TasksService } from 'src/app/services/tasks.service'
+import { ToastService } from 'src/app/services/toast.service'
import { SystemStatusDialogComponent } from './system-status-dialog.component'
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,
},
}
let component: SystemStatusDialogComponent
let fixture: ComponentFixture<SystemStatusDialogComponent>
let clipboard: Clipboard
+ let tasksService: TasksService
+ let systemStatusService: SystemStatusService
+ let toastService: ToastService
beforeEach(async () => {
await TestBed.configureTestingModule({
component = fixture.componentInstance
component.status = status
clipboard = TestBed.inject(Clipboard)
+ tasksService = TestBed.inject(TasksService)
+ systemStatusService = TestBed.inject(SystemStatusService)
+ toastService = TestBed.inject(ToastService)
fixture.detectChanges()
})
expect(component.isStale(date.toISOString())).toBeTruthy()
expect(component.isStale(date.toISOString(), 26)).toBeFalsy()
})
+
+ it('should check if task is running', () => {
+ component.runTask(PaperlessTaskName.IndexOptimize)
+ expect(component.isRunning(PaperlessTaskName.IndexOptimize)).toBeTruthy()
+ expect(component.isRunning(PaperlessTaskName.SanityCheck)).toBeFalsy()
+ })
+
+ it('should support running tasks, refresh status and show toasts', () => {
+ const toastSpy = jest.spyOn(toastService, 'showInfo')
+ const toastErrorSpy = jest.spyOn(toastService, 'showError')
+ const getStatusSpy = jest.spyOn(systemStatusService, 'get')
+ const runSpy = jest.spyOn(tasksService, 'run')
+
+ // fail first
+ runSpy.mockReturnValue(throwError(() => new Error('error')))
+ component.runTask(PaperlessTaskName.IndexOptimize)
+ expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize)
+ expect(toastErrorSpy).toHaveBeenCalledWith(
+ `Failed to start task ${PaperlessTaskName.IndexOptimize}, see the logs for more details`,
+ expect.any(Error)
+ )
+
+ // succeed
+ runSpy.mockReturnValue(of({}))
+ getStatusSpy.mockReturnValue(of(status))
+ component.runTask(PaperlessTaskName.IndexOptimize)
+ expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize)
+
+ expect(getStatusSpy).toHaveBeenCalled()
+ expect(toastSpy).toHaveBeenCalledWith(
+ `Task ${PaperlessTaskName.IndexOptimize} started`
+ )
+ })
})
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { PaperlessTaskName } from 'src/app/data/paperless-task'
import {
SystemStatus,
SystemStatusItemStatus,
} from 'src/app/data/system-status'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { SystemStatusService } from 'src/app/services/system-status.service'
+import { TasksService } from 'src/app/services/tasks.service'
+import { ToastService } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-system-status-dialog',
})
export class SystemStatusDialogComponent {
public SystemStatusItemStatus = SystemStatusItemStatus
+ public PaperlessTaskName = PaperlessTaskName
public status: SystemStatus
public copied: boolean = false
+ private runningTasks: Set<PaperlessTaskName> = new Set()
+
+ get currentUserIsSuperUser(): boolean {
+ return this.permissionsService.isSuperUser()
+ }
+
constructor(
public activeModal: NgbActiveModal,
- private clipboard: Clipboard
+ private clipboard: Clipboard,
+ private systemStatusService: SystemStatusService,
+ private tasksService: TasksService,
+ private toastService: ToastService,
+ private permissionsService: PermissionsService
) {}
public close() {
const now = new Date()
return now.getTime() - date.getTime() > hours * 60 * 60 * 1000
}
+
+ public isRunning(taskName: PaperlessTaskName): boolean {
+ return this.runningTasks.has(taskName)
+ }
+
+ public runTask(taskName: PaperlessTaskName) {
+ this.runningTasks.add(taskName)
+ this.toastService.showInfo(`Task ${taskName} started`)
+ this.tasksService.run(taskName).subscribe({
+ next: () => {
+ this.runningTasks.delete(taskName)
+ this.systemStatusService.get().subscribe({
+ next: (status) => {
+ this.status = status
+ },
+ })
+ },
+ error: (err) => {
+ this.runningTasks.delete(taskName)
+ this.toastService.showError(
+ `Failed to start task ${taskName}, see the logs for more details`,
+ err
+ )
+ },
+ })
+ }
}
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',
+ IndexOptimize = 'index_optimize',
}
export enum PaperlessTaskStatus {
task_file_name: string
+ task_name: PaperlessTaskName
+
date_created: Date
date_done?: Date
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
}
}
} 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', () => {
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')
})
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', () => {
})
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',
date_created: new Date(),
},
{
- type: PaperlessTaskType.File,
+ type: PaperlessTaskType.Auto,
+ task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Failed,
acknowledged: false,
task_id: '1235',
date_created: new Date(),
},
{
- type: PaperlessTaskType.File,
+ type: PaperlessTaskType.Auto,
+ task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Pending,
acknowledged: false,
task_id: '1236',
date_created: new Date(),
},
{
- type: PaperlessTaskType.File,
+ type: PaperlessTaskType.Auto,
+ task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Started,
acknowledged: false,
task_id: '1237',
date_created: new Date(),
},
{
- type: PaperlessTaskType.File,
+ type: PaperlessTaskType.Auto,
+ task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Complete,
acknowledged: false,
task_id: '1238',
tasksService.reload()
const req = httpTestingController.expectOne(
- `${environment.apiBaseUrl}tasks/`
+ `${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
)
req.flush(mockTasks)
expect(tasksService.queuedFileTasks).toHaveLength(1)
expect(tasksService.startedFileTasks).toHaveLength(1)
})
+
+ it('supports running tasks', () => {
+ tasksService.run(PaperlessTaskName.SanityCheck).subscribe((res) => {
+ expect(res).toEqual({
+ result: 'success',
+ })
+ })
+ const req = httpTestingController.expectOne(
+ `${environment.apiBaseUrl}tasks/run/`
+ )
+ expect(req.request.method).toEqual('POST')
+ req.flush({
+ result: 'success',
+ })
+ })
})
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
-import { Subject } from 'rxjs'
+import { Observable, 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'
})
export class TasksService {
private baseUrl: string = environment.apiBaseUrl
+ private endpoint: string = 'tasks'
public loading: boolean
this.loading = true
this.http
- .get<PaperlessTask[]>(`${this.baseUrl}tasks/`)
+ .get<PaperlessTask[]>(
+ `${this.baseUrl}${this.endpoint}/?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
})
}
public cancelPending(): void {
this.unsubscribeNotifer.next(true)
}
+
+ public run(taskName: PaperlessTaskName): Observable<any> {
+ return this.http.post<any>(
+ `${environment.apiBaseUrl}${this.endpoint}/run/`,
+ {
+ task_name: taskName,
+ }
+ )
+ }
}
personFillLock,
personLock,
personSquare,
+ playFill,
plus,
plusCircle,
questionCircle,
personFillLock,
personLock,
personSquare,
+ playFill,
plus,
plusCircle,
questionCircle,
--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;
}
}
@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;
--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
import logging
import pickle
import re
-import time
import warnings
from hashlib import sha256
from pathlib import Path
):
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")
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
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(
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
}
+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
)
def handle(self, *args, **options):
- train_classifier()
+ train_classifier(scheduled=False)
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()
-# 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
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"),
+ ("index_optimize", "Index Optimize"),
+ ],
+ 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",
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"))
+ INDEX_OPTIMIZE = ("index_optimize", _("Index Optimize"))
+
task_id = models.CharField(
max_length=255,
unique=True,
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(
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,
),
)
+ 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}"
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:
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 = {
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
fields = (
"id",
"task_id",
+ "task_name",
"task_file_name",
"date_created",
"date_done",
"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+)\)")
def get_related_document(self, obj) -> str | None:
result = None
re = None
- match obj.status:
- case states.SUCCESS:
- re = self.created_doc_re
- case states.FAILURE:
- re = (
- self.duplicate_doc_re
- if "existing document is in the trash" not in obj.result
- else None
- )
- if re is not None:
- try:
- result = re.search(obj.result).group(1)
- except Exception:
- pass
+ if obj.result:
+ match obj.status:
+ case states.SUCCESS:
+ re = self.created_doc_re
+ case states.FAILURE:
+ re = (
+ self.duplicate_doc_re
+ if "existing document is in the trash" not in obj.result
+ else None
+ )
+ if re is not None:
+ try:
+ result = re.search(obj.result).group(1)
+ except Exception:
+ pass
return result
+class RunTaskViewSerializer(serializers.Serializer):
+ task_name = serializers.ChoiceField(
+ choices=PaperlessTask.TaskName.choices,
+ label="Task Name",
+ write_only=True,
+ )
+
+
class AcknowledgeTasksViewSerializer(serializers.Serializer):
tasks = serializers.ListField(
required=True,
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,
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
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
@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()
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)
@shared_task
-def sanity_check():
- messages = sanity_checker.check_sanity()
+def sanity_check(*, scheduled=True, raise_on_error=True):
+ messages = sanity_checker.check_sanity(scheduled=scheduled)
messages.log_messages()
if messages.has_error:
- raise SanityCheckFailedException("Sanity check failed with errors. See log.")
+ message = "Sanity check exited with errors. See log."
+ if raise_on_error:
+ raise SanityCheckFailedException(message)
+ return message
elif messages.has_warning:
return "Sanity check exited with warnings. See log."
elif len(messages) > 0:
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
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:
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)
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"])
+ 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",
+ )
- @mock.patch(
- "documents.classifier.load_classifier",
- side_effect=ClassifierModelCorruptError(),
- )
- 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,
- )
- 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"],
- "ERROR",
- )
- self.assertIsNotNone(response.data["tasks"]["classifier_error"])
+ 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)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(
+ response.data["tasks"]["classifier_status"],
+ "ERROR",
+ )
+ 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")):
- 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")
+ 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"]["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"])
import uuid
+from unittest import mock
import celery
from django.contrib.auth.models import Permission
from documents.models import PaperlessTask
from documents.tests.utils import DirectoriesMixin
+from documents.views import TasksViewSet
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):
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,
)
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,
)
returned_data = response.data[0]
self.assertEqual(returned_data["related_document"], "1234")
+
+ def test_run_train_classifier_task(self):
+ """
+ GIVEN:
+ - A superuser
+ WHEN:
+ - API call is made to run the train classifier task
+ THEN:
+ - The task is run
+ """
+ mock_train_classifier = mock.Mock(return_value="Task started")
+ TasksViewSet.TASK_AND_ARGS_BY_NAME = {
+ PaperlessTask.TaskName.TRAIN_CLASSIFIER: (
+ mock_train_classifier,
+ {"scheduled": False},
+ ),
+ }
+ response = self.client.post(
+ self.ENDPOINT + "run/",
+ {"task_name": PaperlessTask.TaskName.TRAIN_CLASSIFIER},
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data, {"result": "Task started"})
+ mock_train_classifier.assert_called_once_with(scheduled=False)
+
+ # mock error
+ mock_train_classifier.reset_mock()
+ mock_train_classifier.side_effect = Exception("Error")
+ response = self.client.post(
+ self.ENDPOINT + "run/",
+ {"task_name": PaperlessTask.TaskName.TRAIN_CLASSIFIER},
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
+ mock_train_classifier.assert_called_once_with(scheduled=False)
+
+ @mock.patch("documents.tasks.sanity_check")
+ def test_run_task_requires_superuser(self, mock_check_sanity):
+ """
+ GIVEN:
+ - A regular user
+ WHEN:
+ - API call is made to run a task
+ THEN:
+ - The task is not run
+ """
+ regular_user = User.objects.create_user(username="test")
+ regular_user.user_permissions.add(*Permission.objects.all())
+ self.client.logout()
+ self.client.force_authenticate(user=regular_user)
+
+ response = self.client.post(
+ self.ENDPOINT + "run/",
+ {"task_name": PaperlessTask.TaskName.CHECK_SANITY},
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ mock_check_sanity.assert_not_called()
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)
self.assertRaises(SanityCheckFailedException, tasks.sanity_check)
m.assert_called_once()
+ @mock.patch("documents.tasks.sanity_checker.check_sanity")
+ def test_sanity_check_error_no_raise(self, m):
+ messages = SanityCheckMessages()
+ messages.error(None, "Some error")
+ m.return_value = messages
+ # No exception should be raised
+ result = tasks.sanity_check(raise_on_error=False)
+ self.assertEqual(
+ result,
+ "Sanity check exited with errors. See log.",
+ )
+ m.assert_called_once()
+
@mock.patch("documents.tasks.sanity_checker.check_sanity")
def test_sanity_check_warning(self, m):
messages = SanityCheckMessages()
import httpx
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
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
from documents.serialisers import DocumentSerializer
from documents.serialisers import DocumentTypeSerializer
from documents.serialisers import PostDocumentSerializer
+from documents.serialisers import RunTaskViewSerializer
from documents.serialisers import SavedViewSerializer
from documents.serialisers import SearchResultSerializer
from documents.serialisers import ShareLinkSerializer
from documents.signals import document_updated
from documents.tasks import consume_file
from documents.tasks import empty_trash
+from documents.tasks import index_optimize
+from documents.tasks import sanity_check
+from documents.tasks import train_classifier
from documents.templating.filepath import validate_filepath_template_and_render
from paperless import version
from paperless.celery import app as celery_app
class TasksViewSet(ReadOnlyModelViewSet):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = TasksViewSerializer
- filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
+ filter_backends = (
+ DjangoFilterBackend,
+ OrderingFilter,
+ ObjectOwnedOrGrantedPermissionsFilter,
+ )
+ filterset_class = PaperlessTaskFilterSet
+
+ TASK_AND_ARGS_BY_NAME = {
+ PaperlessTask.TaskName.INDEX_OPTIMIZE: (index_optimize, {}),
+ PaperlessTask.TaskName.TRAIN_CLASSIFIER: (
+ train_classifier,
+ {"scheduled": False},
+ ),
+ PaperlessTask.TaskName.CHECK_SANITY: (
+ sanity_check,
+ {"scheduled": False, "raise_on_error": False},
+ ),
+ }
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)
except Exception:
return HttpResponseBadRequest()
+ @action(methods=["post"], detail=False)
+ def run(self, request):
+ serializer = RunTaskViewSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ task_name = serializer.validated_data.get("task_name")
+
+ if not request.user.is_superuser:
+ return HttpResponseForbidden("Insufficient permissions")
+
+ try:
+ task_func, task_args = self.TASK_AND_ARGS_BY_NAME[task_name]
+ result = task_func(**task_args)
+ return Response({"result": result})
+ except Exception as e:
+ logger.warning(f"An error occurred running task: {e!s}")
+ return HttpResponseServerError(
+ "Error running task, check logs for more detail.",
+ )
+
class ShareLinkViewSet(ModelViewSet, PassUserMixin):
model = ShareLink
"last_trained": serializers.DateTimeField(),
},
),
+ "sanity_check": inline_serializer(
+ name="SanityCheck",
+ fields={
+ "status": serializers.CharField(),
+ "error": serializers.CharField(),
+ "last_run": serializers.DateTimeField(),
+ },
+ ),
},
),
},
)
redis_error = "Error connecting to redis, check logs for more detail."
+ celery_error = None
+ celery_url = 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]
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:
)
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,
- )
- 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()
- )
- 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
+ last_trained_task = (
+ PaperlessTask.objects.filter(
+ task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
)
- except Exception as e:
- if classifier_status is None:
- classifier_status = "ERROR"
- classifier_last_trained = None
- if classifier_error is None:
- classifier_error = (
- "Unable to load classifier, check logs for more detail."
- )
- logger.exception(
- f"System status detected a possible problem while loading the classifier: {e}",
+ .order_by("-date_done")
+ .first()
+ )
+ classifier_status = "OK"
+ classifier_error = 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_error = last_trained_task.result
+ classifier_last_trained = (
+ last_trained_task.date_done if last_trained_task else None
+ )
+
+ 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
+ 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
+ )
return Response(
{
"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_error": classifier_error,
+ "sanity_check_status": sanity_check_status,
+ "sanity_check_last_run": sanity_check_last_run,
+ "sanity_check_error": sanity_check_error,
},
},
)
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-25 11:07-0800\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
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:830
msgid "owner"
msgstr ""
-#: documents/models.py:58 documents/models.py:1017
+#: documents/models.py:58 documents/models.py:1041
msgid "None"
msgstr ""
-#: documents/models.py:59 documents/models.py:1018
+#: documents/models.py:59 documents/models.py:1042
msgid "Any word"
msgstr ""
-#: documents/models.py:60 documents/models.py:1019
+#: documents/models.py:60 documents/models.py:1043
msgid "All words"
msgstr ""
-#: documents/models.py:61 documents/models.py:1020
+#: documents/models.py:61 documents/models.py:1044
msgid "Exact match"
msgstr ""
-#: documents/models.py:62 documents/models.py:1021
+#: documents/models.py:62 documents/models.py:1045
msgid "Regular expression"
msgstr ""
-#: documents/models.py:63 documents/models.py:1022
+#: documents/models.py:63 documents/models.py:1046
msgid "Fuzzy word"
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:1526
#: 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:1110
msgid "match"
msgstr ""
-#: documents/models.py:72 documents/models.py:1088
+#: documents/models.py:72 documents/models.py:1113
msgid "matching algorithm"
msgstr ""
-#: documents/models.py:77 documents/models.py:1093
+#: documents/models.py:77 documents/models.py:1118
msgid "is insensitive"
msgstr ""
msgid "title"
msgstr ""
-#: documents/models.py:175 documents/models.py:720
+#: documents/models.py:175 documents/models.py:744
msgid "content"
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:750
+#: documents/models.py:788 documents/models.py:859 documents/models.py:917
msgid "created"
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:761 documents/models.py:815
+#: documents/models.py:1569
msgid "document"
msgstr ""
msgid "Title"
msgstr ""
-#: documents/models.py:420 documents/models.py:1037
+#: documents/models.py:420 documents/models.py:1062
msgid "Created"
msgstr ""
-#: documents/models.py:421 documents/models.py:1036
+#: documents/models.py:421 documents/models.py:1061
msgid "Added"
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:662
+msgid "Index Optimize"
+msgstr ""
+
+#: documents/models.py:667
msgid "Task ID"
msgstr ""
-#: documents/models.py:657
+#: documents/models.py:668
msgid "Celery ID for the Task that was run"
msgstr ""
-#: documents/models.py:662
+#: documents/models.py:673
msgid "Acknowledged"
msgstr ""
-#: documents/models.py:663
+#: documents/models.py:674
msgid "If the task is acknowledged via the frontend or API"
msgstr ""
-#: documents/models.py:669
+#: documents/models.py:680
msgid "Task Filename"
msgstr ""
-#: documents/models.py:670
+#: documents/models.py:681
msgid "Name of the file which the Task was run for"
msgstr ""
-#: documents/models.py:676
+#: documents/models.py:688
msgid "Task Name"
msgstr ""
-#: documents/models.py:677
-msgid "Name of the Task which was run"
+#: documents/models.py:689
+msgid "Name of the task that was run"
msgstr ""
-#: documents/models.py:684
+#: documents/models.py:696
msgid "Task State"
msgstr ""
-#: documents/models.py:685
+#: documents/models.py:697
msgid "Current state of the task being run"
msgstr ""
-#: documents/models.py:690
+#: documents/models.py:703
msgid "Created DateTime"
msgstr ""
-#: documents/models.py:691
+#: documents/models.py:704
msgid "Datetime field when the task result was created in UTC"
msgstr ""
-#: documents/models.py:696
+#: documents/models.py:710
msgid "Started DateTime"
msgstr ""
-#: documents/models.py:697
+#: documents/models.py:711
msgid "Datetime field when the task was started in UTC"
msgstr ""
-#: documents/models.py:702
+#: documents/models.py:717
msgid "Completed DateTime"
msgstr ""
-#: documents/models.py:703
+#: documents/models.py:718
msgid "Datetime field when the task was completed in UTC"
msgstr ""
-#: documents/models.py:708
+#: documents/models.py:724
msgid "Result Data"
msgstr ""
-#: documents/models.py:710
+#: documents/models.py:726
msgid "The data returned by the task"
msgstr ""
-#: documents/models.py:722
-msgid "Note for the document"
+#: documents/models.py:734
+msgid "Task Type"
+msgstr ""
+
+#: documents/models.py:735
+msgid "The type of task that was run"
msgstr ""
#: documents/models.py:746
+msgid "Note for the document"
+msgstr ""
+
+#: documents/models.py:770
msgid "user"
msgstr ""
-#: documents/models.py:751
+#: documents/models.py:775
msgid "note"
msgstr ""
-#: documents/models.py:752
+#: documents/models.py:776
msgid "notes"
msgstr ""
-#: documents/models.py:760
+#: documents/models.py:784
msgid "Archive"
msgstr ""
-#: documents/models.py:761
+#: documents/models.py:785
msgid "Original"
msgstr ""
-#: documents/models.py:772 paperless_mail/models.py:75
+#: documents/models.py:796 paperless_mail/models.py:75
msgid "expiration"
msgstr ""
-#: documents/models.py:779
+#: documents/models.py:803
msgid "slug"
msgstr ""
-#: documents/models.py:811
+#: documents/models.py:835
msgid "share link"
msgstr ""
-#: documents/models.py:812
+#: documents/models.py:836
msgid "share links"
msgstr ""
-#: documents/models.py:824
+#: documents/models.py:848
msgid "String"
msgstr ""
-#: documents/models.py:825
+#: documents/models.py:849
msgid "URL"
msgstr ""
-#: documents/models.py:826
+#: documents/models.py:850
msgid "Date"
msgstr ""
-#: documents/models.py:827
+#: documents/models.py:851
msgid "Boolean"
msgstr ""
-#: documents/models.py:828
+#: documents/models.py:852
msgid "Integer"
msgstr ""
-#: documents/models.py:829
+#: documents/models.py:853
msgid "Float"
msgstr ""
-#: documents/models.py:830
+#: documents/models.py:854
msgid "Monetary"
msgstr ""
-#: documents/models.py:831
+#: documents/models.py:855
msgid "Document Link"
msgstr ""
-#: documents/models.py:832
+#: documents/models.py:856
msgid "Select"
msgstr ""
-#: documents/models.py:844
+#: documents/models.py:868
msgid "data type"
msgstr ""
-#: documents/models.py:851
+#: documents/models.py:875
msgid "extra data"
msgstr ""
-#: documents/models.py:855
+#: documents/models.py:879
msgid "Extra data for the custom field, such as select options"
msgstr ""
-#: documents/models.py:861
+#: documents/models.py:885
msgid "custom field"
msgstr ""
-#: documents/models.py:862
+#: documents/models.py:886
msgid "custom fields"
msgstr ""
-#: documents/models.py:959
+#: documents/models.py:983
msgid "custom field instance"
msgstr ""
-#: documents/models.py:960
+#: documents/models.py:984
msgid "custom field instances"
msgstr ""
-#: documents/models.py:1025
+#: documents/models.py:1049
msgid "Consumption Started"
msgstr ""
-#: documents/models.py:1026
+#: documents/models.py:1050
msgid "Document Added"
msgstr ""
-#: documents/models.py:1027
+#: documents/models.py:1051
msgid "Document Updated"
msgstr ""
-#: documents/models.py:1028
+#: documents/models.py:1052
msgid "Scheduled"
msgstr ""
-#: documents/models.py:1031
+#: documents/models.py:1055
msgid "Consume Folder"
msgstr ""
-#: documents/models.py:1032
+#: documents/models.py:1056
msgid "Api Upload"
msgstr ""
-#: documents/models.py:1033
+#: documents/models.py:1057
msgid "Mail Fetch"
msgstr ""
-#: documents/models.py:1038
+#: documents/models.py:1058
+msgid "Web UI"
+msgstr ""
+
+#: documents/models.py:1063
msgid "Modified"
msgstr ""
-#: documents/models.py:1039
+#: documents/models.py:1064
msgid "Custom Field"
msgstr ""
-#: documents/models.py:1042
+#: documents/models.py:1067
msgid "Workflow Trigger Type"
msgstr ""
-#: documents/models.py:1054
+#: documents/models.py:1079
msgid "filter path"
msgstr ""
-#: documents/models.py:1059
+#: documents/models.py:1084
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:1091
msgid "filter filename"
msgstr ""
-#: documents/models.py:1071 paperless_mail/models.py:200
+#: documents/models.py:1096 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:1107
msgid "filter documents from this mail rule"
msgstr ""
-#: documents/models.py:1098
+#: documents/models.py:1123
msgid "has these tag(s)"
msgstr ""
-#: documents/models.py:1106
+#: documents/models.py:1131
msgid "has this document type"
msgstr ""
-#: documents/models.py:1114
+#: documents/models.py:1139
msgid "has this correspondent"
msgstr ""
-#: documents/models.py:1118
+#: documents/models.py:1143
msgid "schedule offset days"
msgstr ""
-#: documents/models.py:1121
+#: documents/models.py:1146
msgid "The number of days to offset the schedule trigger by."
msgstr ""
-#: documents/models.py:1126
+#: documents/models.py:1151
msgid "schedule is recurring"
msgstr ""
-#: documents/models.py:1129
+#: documents/models.py:1154
msgid "If the schedule should be recurring."
msgstr ""
-#: documents/models.py:1134
+#: documents/models.py:1159
msgid "schedule recurring delay in days"
msgstr ""
-#: documents/models.py:1138
+#: documents/models.py:1163
msgid "The number of days between recurring schedule triggers."
msgstr ""
-#: documents/models.py:1143
+#: documents/models.py:1168
msgid "schedule date field"
msgstr ""
-#: documents/models.py:1148
+#: documents/models.py:1173
msgid "The field to check for a schedule trigger."
msgstr ""
-#: documents/models.py:1157
+#: documents/models.py:1182
msgid "schedule date custom field"
msgstr ""
-#: documents/models.py:1161
+#: documents/models.py:1186
msgid "workflow trigger"
msgstr ""
-#: documents/models.py:1162
+#: documents/models.py:1187
msgid "workflow triggers"
msgstr ""
-#: documents/models.py:1170
+#: documents/models.py:1195
msgid "email subject"
msgstr ""
-#: documents/models.py:1174
+#: documents/models.py:1199
msgid ""
"The subject of the email, can include some placeholders, see documentation."
msgstr ""
-#: documents/models.py:1180
+#: documents/models.py:1205
msgid "email body"
msgstr ""
-#: documents/models.py:1183
+#: documents/models.py:1208
msgid ""
"The body (message) of the email, can include some placeholders, see "
"documentation."
msgstr ""
-#: documents/models.py:1189
+#: documents/models.py:1214
msgid "emails to"
msgstr ""
-#: documents/models.py:1192
+#: documents/models.py:1217
msgid "The destination email addresses, comma separated."
msgstr ""
-#: documents/models.py:1198
+#: documents/models.py:1223
msgid "include document in email"
msgstr ""
-#: documents/models.py:1207
+#: documents/models.py:1234
msgid "webhook url"
msgstr ""
-#: documents/models.py:1209
+#: documents/models.py:1237
msgid "The destination URL for the notification."
msgstr ""
-#: documents/models.py:1214
+#: documents/models.py:1242
msgid "use parameters"
msgstr ""
-#: documents/models.py:1219
+#: documents/models.py:1247
msgid "send as JSON"
msgstr ""
-#: documents/models.py:1223
+#: documents/models.py:1251
msgid "webhook parameters"
msgstr ""
-#: documents/models.py:1226
+#: documents/models.py:1254
msgid "The parameters to send with the webhook URL if body not used."
msgstr ""
-#: documents/models.py:1230
+#: documents/models.py:1258
msgid "webhook body"
msgstr ""
-#: documents/models.py:1233
+#: documents/models.py:1261
msgid "The body to send with the webhook URL if parameters not used."
msgstr ""
-#: documents/models.py:1237
+#: documents/models.py:1265
msgid "webhook headers"
msgstr ""
-#: documents/models.py:1240
+#: documents/models.py:1268
msgid "The headers to send with the webhook URL."
msgstr ""
-#: documents/models.py:1245
+#: documents/models.py:1273
msgid "include document in webhook"
msgstr ""
-#: documents/models.py:1256
+#: documents/models.py:1284
msgid "Assignment"
msgstr ""
-#: documents/models.py:1260
+#: documents/models.py:1288
msgid "Removal"
msgstr ""
-#: documents/models.py:1264 documents/templates/account/password_reset.html:15
+#: documents/models.py:1292 documents/templates/account/password_reset.html:15
msgid "Email"
msgstr ""
-#: documents/models.py:1268
+#: documents/models.py:1296
msgid "Webhook"
msgstr ""
-#: documents/models.py:1272
+#: documents/models.py:1300
msgid "Workflow Action Type"
msgstr ""
-#: documents/models.py:1278
+#: documents/models.py:1306
msgid "assign title"
msgstr ""
-#: documents/models.py:1283
+#: documents/models.py:1311
msgid ""
"Assign a document title, can include some placeholders, see documentation."
msgstr ""
-#: documents/models.py:1292 paperless_mail/models.py:274
+#: documents/models.py:1320 paperless_mail/models.py:274
msgid "assign this tag"
msgstr ""
-#: documents/models.py:1301 paperless_mail/models.py:282
+#: documents/models.py:1329 paperless_mail/models.py:282
msgid "assign this document type"
msgstr ""
-#: documents/models.py:1310 paperless_mail/models.py:296
+#: documents/models.py:1338 paperless_mail/models.py:296
msgid "assign this correspondent"
msgstr ""
-#: documents/models.py:1319
+#: documents/models.py:1347
msgid "assign this storage path"
msgstr ""
-#: documents/models.py:1328
+#: documents/models.py:1356
msgid "assign this owner"
msgstr ""
-#: documents/models.py:1335
+#: documents/models.py:1363
msgid "grant view permissions to these users"
msgstr ""
-#: documents/models.py:1342
+#: documents/models.py:1370
msgid "grant view permissions to these groups"
msgstr ""
-#: documents/models.py:1349
+#: documents/models.py:1377
msgid "grant change permissions to these users"
msgstr ""
-#: documents/models.py:1356
+#: documents/models.py:1384
msgid "grant change permissions to these groups"
msgstr ""
-#: documents/models.py:1363
+#: documents/models.py:1391
msgid "assign these custom fields"
msgstr ""
-#: documents/models.py:1370
+#: documents/models.py:1398
msgid "remove these tag(s)"
msgstr ""
-#: documents/models.py:1375
+#: documents/models.py:1403
msgid "remove all tags"
msgstr ""
-#: documents/models.py:1382
+#: documents/models.py:1410
msgid "remove these document type(s)"
msgstr ""
-#: documents/models.py:1387
+#: documents/models.py:1415
msgid "remove all document types"
msgstr ""
-#: documents/models.py:1394
+#: documents/models.py:1422
msgid "remove these correspondent(s)"
msgstr ""
-#: documents/models.py:1399
+#: documents/models.py:1427
msgid "remove all correspondents"
msgstr ""
-#: documents/models.py:1406
+#: documents/models.py:1434
msgid "remove these storage path(s)"
msgstr ""
-#: documents/models.py:1411
+#: documents/models.py:1439
msgid "remove all storage paths"
msgstr ""
-#: documents/models.py:1418
+#: documents/models.py:1446
msgid "remove these owner(s)"
msgstr ""
-#: documents/models.py:1423
+#: documents/models.py:1451
msgid "remove all owners"
msgstr ""
-#: documents/models.py:1430
+#: documents/models.py:1458
msgid "remove view permissions for these users"
msgstr ""
-#: documents/models.py:1437
+#: documents/models.py:1465
msgid "remove view permissions for these groups"
msgstr ""
-#: documents/models.py:1444
+#: documents/models.py:1472
msgid "remove change permissions for these users"
msgstr ""
-#: documents/models.py:1451
+#: documents/models.py:1479
msgid "remove change permissions for these groups"
msgstr ""
-#: documents/models.py:1456
+#: documents/models.py:1484
msgid "remove all permissions"
msgstr ""
-#: documents/models.py:1463
+#: documents/models.py:1491
msgid "remove these custom fields"
msgstr ""
-#: documents/models.py:1468
+#: documents/models.py:1496
msgid "remove all custom fields"
msgstr ""
-#: documents/models.py:1477
+#: documents/models.py:1505
msgid "email"
msgstr ""
-#: documents/models.py:1486
+#: documents/models.py:1514
msgid "webhook"
msgstr ""
-#: documents/models.py:1490
+#: documents/models.py:1518
msgid "workflow action"
msgstr ""
-#: documents/models.py:1491
+#: documents/models.py:1519
msgid "workflow actions"
msgstr ""
-#: documents/models.py:1500 paperless_mail/models.py:145
+#: documents/models.py:1528 paperless_mail/models.py:145
msgid "order"
msgstr ""
-#: documents/models.py:1506
+#: documents/models.py:1534
msgid "triggers"
msgstr ""
-#: documents/models.py:1513
+#: documents/models.py:1541
msgid "actions"
msgstr ""
-#: documents/models.py:1516 paperless_mail/models.py:154
+#: documents/models.py:1544 paperless_mail/models.py:154
msgid "enabled"
msgstr ""
-#: documents/models.py:1527
+#: documents/models.py:1555
msgid "workflow"
msgstr ""
-#: documents/models.py:1531
+#: documents/models.py:1559
msgid "workflow trigger type"
msgstr ""
-#: documents/models.py:1545
+#: documents/models.py:1573
msgid "date run"
msgstr ""
-#: documents/models.py:1551
+#: documents/models.py:1579
msgid "workflow run"
msgstr ""
-#: documents/models.py:1552
+#: documents/models.py:1580
msgid "workflow runs"
msgstr ""
-#: documents/serialisers.py:127
+#: documents/serialisers.py:128
#, python-format
msgid "Invalid regular expression: %(error)s"
msgstr ""
-#: documents/serialisers.py:553
+#: documents/serialisers.py:554
msgid "Invalid color."
msgstr ""
-#: documents/serialisers.py:1554
+#: documents/serialisers.py:1570
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
-#: documents/serialisers.py:1643
+#: documents/serialisers.py:1659
msgid "Invalid variable detected."
msgstr ""
msgid "As a final step, please complete the following form:"
msgstr ""
-#: documents/validators.py:17
+#: documents/validators.py:24
#, python-brace-format
msgid "Unable to parse URI {value}, missing scheme"
msgstr ""
-#: documents/validators.py:22
+#: documents/validators.py:29
#, python-brace-format
msgid "Unable to parse URI {value}, missing net location or path"
msgstr ""
-#: documents/validators.py:27
+#: documents/validators.py:36
+msgid ""
+"URI scheme '{parts.scheme}' is not allowed. Allowed schemes: {', '."
+"join(allowed_schemes)}"
+msgstr ""
+
+#: documents/validators.py:45
#, python-brace-format
msgid "Unable to parse URI {value}"
msgstr ""
msgid "Chinese Traditional"
msgstr ""
-#: paperless/urls.py:364
+#: paperless/urls.py:369
msgid "Paperless-ngx administration"
msgstr ""