Enhancement: system status report sanity check, simpler classifier check, styling updates (#9106)

This commit is contained in:
shamoon 2025-02-26 14:12:20 -08:00 committed by GitHub
parent ec34197b59
commit 2d52226732
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1117 additions and 479 deletions

View File

@ -1514,7 +1514,7 @@
<source>Error retrieving users</source> <source>Error retrieving users</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <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>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
@ -1525,7 +1525,7 @@
<source>Error retrieving groups</source> <source>Error retrieving groups</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <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>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
@ -1536,28 +1536,28 @@
<source>Settings were saved successfully.</source> <source>Settings were saved successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="525012668859298131" datatype="html"> <trans-unit id="525012668859298131" datatype="html">
<source>Settings were saved successfully. Reload is required to apply some changes.</source> <source>Settings were saved successfully. Reload is required to apply some changes.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8491974984518503778" datatype="html"> <trans-unit id="8491974984518503778" datatype="html">
<source>Reload now</source> <source>Reload now</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3011185103048412841" datatype="html"> <trans-unit id="3011185103048412841" datatype="html">
<source>An error occurred while saving settings.</source> <source>An error occurred while saving settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context> <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>
<context-group purpose="location"> <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/app-frame/app-frame.component.ts</context>
@ -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="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">111</context> <context context-type="linenumber">111</context>
</context-group> </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-group purpose="location">
<context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context> <context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
<context context-type="linenumber">30</context> <context context-type="linenumber">30</context>
@ -5615,7 +5627,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <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>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
@ -6010,77 +6022,113 @@
<source>Migration Status</source> <source>Migration Status</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7489316373554112115" datatype="html"> <trans-unit id="7489316373554112115" datatype="html">
<source>Up to date</source> <source>Up to date</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7881311375431899727" datatype="html"> <trans-unit id="7881311375431899727" datatype="html">
<source>Latest Migration</source> <source>Latest Migration</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4632965004151576238" datatype="html"> <trans-unit id="4632965004151576238" datatype="html">
<source>Pending Migrations</source> <source>Pending Migrations</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6904866445262015585" datatype="html"> <trans-unit id="2790343143501919450" datatype="html">
<source>Tasks</source> <source>Tasks Queue</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6911698235105017958" datatype="html"> <trans-unit id="6911698235105017958" datatype="html">
<source>Redis Status</source> <source>Redis Status</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5349496739889768589" datatype="html"> <trans-unit id="5349496739889768589" datatype="html">
<source>Celery Status</source> <source>Celery Status</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="31377277941774469" datatype="html"> <trans-unit id="31377277941774469" datatype="html">
<source>Search Index</source> <source>Search Index</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4089509911694721896" datatype="html"> <trans-unit id="4089509911694721896" datatype="html">
<source>Last Updated</source> <source>Last Updated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="46628344485199198" datatype="html"> <trans-unit id="46628344485199198" datatype="html">
<source>Classifier</source> <source>Classifier</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6096684179126491743" datatype="html"> <trans-unit id="6096684179126491743" datatype="html">
<source>Last Trained</source> <source>Last Trained</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context> <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> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6732151329960766506" datatype="html"> <trans-unit id="6732151329960766506" datatype="html">

View File

@ -303,12 +303,17 @@ describe('SettingsComponent', () => {
redis_error: redis_error:
'Error 61 connecting to localhost:6379. Connection refused.', 'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR, celery_status: SystemStatusItemStatus.ERROR,
celery_url: 'celery@localhost',
celery_error: 'Error connecting to celery@localhost',
index_status: SystemStatusItemStatus.OK, index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(), index_last_modified: new Date().toISOString(),
index_error: null, index_error: null,
classifier_status: SystemStatusItemStatus.OK, classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(), classifier_last_trained: new Date().toISOString(),
classifier_error: null, 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)) jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
@ -320,6 +325,8 @@ describe('SettingsComponent', () => {
component['systemStatus'].database.status = SystemStatusItemStatus.OK component['systemStatus'].database.status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.sanity_check_status =
SystemStatusItemStatus.OK
expect(component.systemStatusHasErrors).toBeFalsy() expect(component.systemStatusHasErrors).toBeFalsy()
}) })

View File

@ -164,7 +164,10 @@ export class SettingsComponent
this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR || this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR || this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.index_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
) )
} }

View File

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

View File

@ -1,5 +1,5 @@
<div class="modal-header"> <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> <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -11,11 +11,11 @@
</div> </div>
</div> </div>
} @else { } @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="col">
<div class="card bg-light h-100"> <div class="card bg-light h-100">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0" i18n>Environment</h5> <h6 class="card-title mb-0" i18n>Environment</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<dl class="card-text"> <dl class="card-text">
@ -38,27 +38,37 @@
<div class="col"> <div class="col">
<div class="card bg-light h-100"> <div class="card bg-light h-100">
<div class="card-header"> <div class="card-header">
<h5 class="card-title mb-0" i18n>Database</h5> <h6 class="card-title mb-0" i18n>Database</h6>
</div> </div>
<div class="card-body"> <div class="card-body">
<dl class="card-text"> <dl class="card-text">
<dt i18n>Type</dt> <dt i18n>Type</dt>
<dd>{{status.database.type}}</dd> <dd>{{status.database.type}}</dd>
<dt i18n>Status</dt> <dt i18n>Status</dt>
<dd class="d-flex align-items-center"> <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}} {{status.database.status}}
@if (status.database.status === 'OK') { @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 { } @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>
} }
</button>
<ng-template #databaseStatus>
@if (status.database.status === 'OK') {
{{status.database.url}}
} @else {
{{status.database.url}}: {{status.database.error}}
}
</ng-template>
</dd> </dd>
<dt i18n>Migration Status</dt> <dt i18n>Migration Status</dt>
<dd class="d-flex align-items-center"> <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) { @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 { } @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> <ng-template #migrationStatus>
<h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span> <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> </ul>
} }
</ng-template> </ng-template>
</button>
</dd> </dd>
</dl> </dl>
</div> </div>
@ -80,63 +91,157 @@
<div class="col"> <div class="col">
<div class="card bg-light h-100"> <div class="card bg-light h-100">
<div class="card-header"> <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>
<div class="card-body"> <div class="card-body">
<dl class="card-text"> <dl class="card-text">
<dt i18n>Redis Status</dt> <dt i18n>Redis Status</dt>
<dd class="d-flex align-items-center"> <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}} {{status.tasks.redis_status}}
@if (status.tasks.redis_status === 'OK') { @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 { } @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>
} }
</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> </dd>
<dt i18n>Celery Status</dt> <dt i18n>Celery Status</dt>
<dd class="d-flex align-items-center"> <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}} {{status.tasks.celery_status}}
@if (status.tasks.celery_status === 'OK') { @if (status.tasks.celery_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs> <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else { } @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs> <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> </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> <dt i18n>Search Index</dt>
<dd class="d-flex align-items-center"> <dd class="d-flex align-items-center">
<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}} {{status.tasks.index_status}}
@if (status.tasks.index_status === 'OK') { @if (status.tasks.index_status === 'OK') {
@if (isStale(status.tasks.index_last_modified)) { @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 { } @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 { } @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>
}
</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>&nbsp;
<ng-container i18n>Run Task</ng-container>
</button>
}
} }
</dd> </dd>
<ng-template #indexStatus> <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> <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> </ng-template>
<dt i18n>Classifier</dt> <dt i18n>Classifier</dt>
<dd class="d-flex align-items-center"> <dd class="d-flex align-items-center">
<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}} {{status.tasks.classifier_status}}
@if (status.tasks.classifier_status === 'OK') { @if (status.tasks.classifier_status === 'OK') {
@if (isStale(status.tasks.classifier_last_trained)) { @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 { } @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 { } @else {
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1" <i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
[class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR" [class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR"
[class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING" [class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs>
ngbPopover="{{status.tasks.classifier_error}}" }
triggers="mouseenter:mouseleave"></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>&nbsp;
<ng-container i18n>Run Task</ng-container>
</button>
}
} }
</dd> </dd>
<ng-template #classifierStatus> <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> <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>&nbsp;
<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> </ng-template>
</dl> </dl>
</div> </div>
@ -146,7 +251,7 @@
} }
</div> </div>
<div class="modal-footer"> <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) { @if (!copied) {
<i-bs name="clipboard-fill"></i-bs>&nbsp; <i-bs name="clipboard-fill"></i-bs>&nbsp;
} }

View File

@ -0,0 +1,3 @@
.btn.small {
font-size: 0.75rem;
}

View File

@ -9,11 +9,16 @@ import {
} from '@angular/core/testing' } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { PaperlessTaskName } from 'src/app/data/paperless-task'
import { import {
InstallType, InstallType,
SystemStatus, SystemStatus,
SystemStatusItemStatus, SystemStatusItemStatus,
} from 'src/app/data/system-status' } 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' import { SystemStatusDialogComponent } from './system-status-dialog.component'
const status: SystemStatus = { const status: SystemStatus = {
@ -36,12 +41,17 @@ const status: SystemStatus = {
redis_status: SystemStatusItemStatus.ERROR, redis_status: SystemStatusItemStatus.ERROR,
redis_error: 'Error 61 connecting to localhost:6379. Connection refused.', redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR, celery_status: SystemStatusItemStatus.ERROR,
celery_url: 'celery@localhost',
celery_error: 'Error connecting to celery@localhost',
index_status: SystemStatusItemStatus.OK, index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(), index_last_modified: new Date().toISOString(),
index_error: null, index_error: null,
classifier_status: SystemStatusItemStatus.OK, classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(), classifier_last_trained: new Date().toISOString(),
classifier_error: null, classifier_error: null,
sanity_check_status: SystemStatusItemStatus.OK,
sanity_check_last_run: new Date().toISOString(),
sanity_check_error: null,
}, },
} }
@ -49,6 +59,9 @@ describe('SystemStatusDialogComponent', () => {
let component: SystemStatusDialogComponent let component: SystemStatusDialogComponent
let fixture: ComponentFixture<SystemStatusDialogComponent> let fixture: ComponentFixture<SystemStatusDialogComponent>
let clipboard: Clipboard let clipboard: Clipboard
let tasksService: TasksService
let systemStatusService: SystemStatusService
let toastService: ToastService
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@ -67,6 +80,9 @@ describe('SystemStatusDialogComponent', () => {
component = fixture.componentInstance component = fixture.componentInstance
component.status = status component.status = status
clipboard = TestBed.inject(Clipboard) clipboard = TestBed.inject(Clipboard)
tasksService = TestBed.inject(TasksService)
systemStatusService = TestBed.inject(SystemStatusService)
toastService = TestBed.inject(ToastService)
fixture.detectChanges() fixture.detectChanges()
}) })
@ -93,4 +109,37 @@ describe('SystemStatusDialogComponent', () => {
expect(component.isStale(date.toISOString())).toBeTruthy() expect(component.isStale(date.toISOString())).toBeTruthy()
expect(component.isStale(date.toISOString(), 26)).toBeFalsy() 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`
)
})
}) })

View File

@ -7,12 +7,17 @@ import {
NgbProgressbarModule, NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { PaperlessTaskName } from 'src/app/data/paperless-task'
import { import {
SystemStatus, SystemStatus,
SystemStatusItemStatus, SystemStatusItemStatus,
} from 'src/app/data/system-status' } from 'src/app/data/system-status'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { FileSizePipe } from 'src/app/pipes/file-size.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({ @Component({
selector: 'pngx-system-status-dialog', selector: 'pngx-system-status-dialog',
@ -30,13 +35,24 @@ import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
}) })
export class SystemStatusDialogComponent { export class SystemStatusDialogComponent {
public SystemStatusItemStatus = SystemStatusItemStatus public SystemStatusItemStatus = SystemStatusItemStatus
public PaperlessTaskName = PaperlessTaskName
public status: SystemStatus public status: SystemStatus
public copied: boolean = false public copied: boolean = false
private runningTasks: Set<PaperlessTaskName> = new Set()
get currentUserIsSuperUser(): boolean {
return this.permissionsService.isSuperUser()
}
constructor( constructor(
public activeModal: NgbActiveModal, public activeModal: NgbActiveModal,
private clipboard: Clipboard private clipboard: Clipboard,
private systemStatusService: SystemStatusService,
private tasksService: TasksService,
private toastService: ToastService,
private permissionsService: PermissionsService
) {} ) {}
public close() { public close() {
@ -56,4 +72,30 @@ export class SystemStatusDialogComponent {
const now = new Date() const now = new Date()
return now.getTime() - date.getTime() > hours * 60 * 60 * 1000 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
)
},
})
}
} }

View File

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

View File

@ -32,11 +32,16 @@ export interface SystemStatus {
redis_status: SystemStatusItemStatus redis_status: SystemStatusItemStatus
redis_error: string redis_error: string
celery_status: SystemStatusItemStatus celery_status: SystemStatusItemStatus
celery_url: string
celery_error: string
index_status: SystemStatusItemStatus index_status: SystemStatusItemStatus
index_last_modified: string // ISO date string index_last_modified: string // ISO date string
index_error: string index_error: string
classifier_status: SystemStatusItemStatus classifier_status: SystemStatusItemStatus
classifier_last_trained: string // ISO date string classifier_last_trained: string // ISO date string
classifier_error: 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' } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing' import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment' 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' import { TasksService } from './tasks.service'
describe('TasksService', () => { describe('TasksService', () => {
@ -33,7 +37,7 @@ describe('TasksService', () => {
it('calls tasks api endpoint on reload', () => { it('calls tasks api endpoint on reload', () => {
tasksService.reload() tasksService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}tasks/` `${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
}) })
@ -41,7 +45,9 @@ describe('TasksService', () => {
it('does not call tasks api endpoint on reload if already loading', () => { it('does not call tasks api endpoint on reload if already loading', () => {
tasksService.loading = true tasksService.loading = true
tasksService.reload() 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', () => { it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
@ -55,14 +61,19 @@ describe('TasksService', () => {
}) })
req.flush([]) req.flush([])
// reload is then called // 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', () => { it('sorts tasks returned from api', () => {
expect(tasksService.total).toEqual(0) expect(tasksService.total).toEqual(0)
const mockTasks = [ const mockTasks = [
{ {
type: PaperlessTaskType.File, type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Complete, status: PaperlessTaskStatus.Complete,
acknowledged: false, acknowledged: false,
task_id: '1234', task_id: '1234',
@ -70,7 +81,8 @@ describe('TasksService', () => {
date_created: new Date(), date_created: new Date(),
}, },
{ {
type: PaperlessTaskType.File, type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Failed, status: PaperlessTaskStatus.Failed,
acknowledged: false, acknowledged: false,
task_id: '1235', task_id: '1235',
@ -78,7 +90,8 @@ describe('TasksService', () => {
date_created: new Date(), date_created: new Date(),
}, },
{ {
type: PaperlessTaskType.File, type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Pending, status: PaperlessTaskStatus.Pending,
acknowledged: false, acknowledged: false,
task_id: '1236', task_id: '1236',
@ -86,7 +99,8 @@ describe('TasksService', () => {
date_created: new Date(), date_created: new Date(),
}, },
{ {
type: PaperlessTaskType.File, type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Started, status: PaperlessTaskStatus.Started,
acknowledged: false, acknowledged: false,
task_id: '1237', task_id: '1237',
@ -94,7 +108,8 @@ describe('TasksService', () => {
date_created: new Date(), date_created: new Date(),
}, },
{ {
type: PaperlessTaskType.File, type: PaperlessTaskType.Auto,
task_name: PaperlessTaskName.ConsumeFile,
status: PaperlessTaskStatus.Complete, status: PaperlessTaskStatus.Complete,
acknowledged: false, acknowledged: false,
task_id: '1238', task_id: '1238',
@ -106,7 +121,7 @@ describe('TasksService', () => {
tasksService.reload() tasksService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}tasks/` `${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
) )
req.flush(mockTasks) req.flush(mockTasks)
@ -117,4 +132,19 @@ describe('TasksService', () => {
expect(tasksService.queuedFileTasks).toHaveLength(1) expect(tasksService.queuedFileTasks).toHaveLength(1)
expect(tasksService.startedFileTasks).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',
})
})
}) })

View File

@ -1,11 +1,11 @@
import { HttpClient } from '@angular/common/http' import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { Subject } from 'rxjs' import { Observable, Subject } from 'rxjs'
import { first, takeUntil } from 'rxjs/operators' import { first, takeUntil } from 'rxjs/operators'
import { import {
PaperlessTask, PaperlessTask,
PaperlessTaskName,
PaperlessTaskStatus, PaperlessTaskStatus,
PaperlessTaskType,
} from 'src/app/data/paperless-task' } from 'src/app/data/paperless-task'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
@ -14,6 +14,7 @@ import { environment } from 'src/environments/environment'
}) })
export class TasksService { export class TasksService {
private baseUrl: string = environment.apiBaseUrl private baseUrl: string = environment.apiBaseUrl
private endpoint: string = 'tasks'
public loading: boolean public loading: boolean
@ -54,10 +55,14 @@ export class TasksService {
this.loading = true this.loading = true
this.http this.http
.get<PaperlessTask[]>(`${this.baseUrl}tasks/`) .get<PaperlessTask[]>(
`${this.baseUrl}${this.endpoint}/?task_name=consume_file&acknowledged=false`
)
.pipe(takeUntil(this.unsubscribeNotifer), first()) .pipe(takeUntil(this.unsubscribeNotifer), first())
.subscribe((r) => { .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 this.loading = false
}) })
} }
@ -76,4 +81,13 @@ export class TasksService {
public cancelPending(): void { public cancelPending(): void {
this.unsubscribeNotifer.next(true) this.unsubscribeNotifer.next(true)
} }
public run(taskName: PaperlessTaskName): Observable<any> {
return this.http.post<any>(
`${environment.apiBaseUrl}${this.endpoint}/run/`,
{
task_name: taskName,
}
)
}
} }

View File

@ -107,6 +107,7 @@ import {
personFillLock, personFillLock,
personLock, personLock,
personSquare, personSquare,
playFill,
plus, plus,
plusCircle, plusCircle,
questionCircle, questionCircle,
@ -312,6 +313,7 @@ const icons = {
personFillLock, personFillLock,
personLock, personLock,
personSquare, personSquare,
playFill,
plus, plus,
plusCircle, plusCircle,
questionCircle, questionCircle,

View File

@ -21,10 +21,12 @@
--pngx-success-darken-10: hsl(152, 69%, 11%); // based on success #198754 --pngx-success-darken-10: hsl(152, 69%, 11%); // based on success #198754
--pngx-bg-alt: #fff; --pngx-bg-alt: #fff;
--pngx-bg-darker: var(--bs-gray-100); --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-bg-disabled: #f7f7f7;
--pngx-focus-alpha: 0.3; --pngx-focus-alpha: 0.3;
--pngx-toast-max-width: 360px; --pngx-toast-max-width: 360px;
--bs-info: var(--pngx-bg-alt2);
--bs-info-rgb: 233, 236, 239;
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {
--pngx-toast-max-width: 450px; --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 { @mixin dark-mode {
--bs-body-color: #{$text-color-dark-bg};
--pngx-body-color-accent: #{$text-color-dark-bg-accent}; --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-secondary-color: #6c757d;
--bs-danger: #b71631; --bs-danger: #b71631;
--bs-danger-rgb: 183, 22, 49; --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-body-bg-rgb: 22, 22, 24;
--bs-light: #1c1c1f; --bs-light: #1c1c1f;
--bs-light-rgb: 28, 28, 31; --bs-light-rgb: 28, 28, 31;
--bs-info: var(--pngx-bg-alt);
--bs-info-rgb: 36, 36, 39;
--bs-border-color: #47494f; --bs-border-color: #47494f;
--pngx-bg-alt2: #232323;
--pngx-bg-darker: #101216;
--bs-tertiary-bg: var(--pngx-bg-darker); --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-dark-border-subtle: var(--pngx-bg-darker);
--bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs --bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs

View File

@ -3,7 +3,6 @@ from __future__ import annotations
import logging import logging
import pickle import pickle
import re import re
import time
import warnings import warnings
from hashlib import sha256 from hashlib import sha256
from pathlib import Path from pathlib import Path
@ -144,19 +143,6 @@ class DocumentClassifier:
): ):
raise IncompatibleClassifierVersionError("sklearn version update") 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: def save(self) -> None:
target_file: Path = settings.MODEL_FILE target_file: Path = settings.MODEL_FILE
target_file_temp: Path = target_file.with_suffix(".pickle.part") target_file_temp: Path = target_file.with_suffix(".pickle.part")
@ -177,7 +163,6 @@ class DocumentClassifier:
pickle.dump(self.storage_path_classifier, f) pickle.dump(self.storage_path_classifier, f)
target_file_temp.rename(target_file) target_file_temp.rename(target_file)
self.set_last_checked()
def train(self) -> bool: def train(self) -> bool:
# Get non-inbox documents # Get non-inbox documents
@ -246,7 +231,6 @@ class DocumentClassifier:
and self.last_doc_change_time >= latest_doc_change and self.last_doc_change_time >= latest_doc_change
) and self.last_auto_type_hash == hasher.digest(): ) and self.last_auto_type_hash == hasher.digest():
logger.info("No updates since last training") logger.info("No updates since last training")
self.set_last_checked()
# Set the classifier information into the cache # Set the classifier information into the cache
# Caching for 50 minutes, so slightly less than the normal retrain time # Caching for 50 minutes, so slightly less than the normal retrain time
cache.set( cache.set(

View File

@ -37,6 +37,7 @@ from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import Log from documents.models import Log
from documents.models import PaperlessTask
from documents.models import ShareLink from documents.models import ShareLink
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
@ -775,6 +776,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): class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
""" """
A filter backend that limits results to those where the requesting user 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): 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): def handle(self, *args, **options):
self.handle_progress_bar_mixin(**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() 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 import multiselectfield.db.fields
from django.db import migrations from django.db import migrations
@ -16,12 +16,52 @@ def update_workflow_sources(apps, schema_editor):
trigger.save() 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): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("documents", "1062_alter_savedviewfilterrule_rule_type"), ("documents", "1062_alter_savedviewfilterrule_rule_type"),
] ]
operations = [ 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( migrations.AlterField(
model_name="workflowactionwebhook", model_name="workflowactionwebhook",
name="url", name="url",

View File

@ -650,6 +650,17 @@ class PaperlessTask(ModelWithOwner):
ALL_STATES = sorted(states.ALL_STATES) ALL_STATES = sorted(states.ALL_STATES)
TASK_STATE_CHOICES = sorted(zip(ALL_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( task_id = models.CharField(
max_length=255, max_length=255,
unique=True, unique=True,
@ -673,8 +684,9 @@ class PaperlessTask(ModelWithOwner):
task_name = models.CharField( task_name = models.CharField(
null=True, null=True,
max_length=255, max_length=255,
choices=TaskName.choices,
verbose_name=_("Task Name"), 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( status = models.CharField(
@ -684,24 +696,28 @@ class PaperlessTask(ModelWithOwner):
verbose_name=_("Task State"), verbose_name=_("Task State"),
help_text=_("Current state of the task being run"), help_text=_("Current state of the task being run"),
) )
date_created = models.DateTimeField( date_created = models.DateTimeField(
null=True, null=True,
default=timezone.now, default=timezone.now,
verbose_name=_("Created DateTime"), verbose_name=_("Created DateTime"),
help_text=_("Datetime field when the task result was created in UTC"), help_text=_("Datetime field when the task result was created in UTC"),
) )
date_started = models.DateTimeField( date_started = models.DateTimeField(
null=True, null=True,
default=None, default=None,
verbose_name=_("Started DateTime"), verbose_name=_("Started DateTime"),
help_text=_("Datetime field when the task was started in UTC"), help_text=_("Datetime field when the task was started in UTC"),
) )
date_done = models.DateTimeField( date_done = models.DateTimeField(
null=True, null=True,
default=None, default=None,
verbose_name=_("Completed DateTime"), verbose_name=_("Completed DateTime"),
help_text=_("Datetime field when the task was completed in UTC"), help_text=_("Datetime field when the task was completed in UTC"),
) )
result = models.TextField( result = models.TextField(
null=True, null=True,
default=None, default=None,
@ -711,6 +727,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: def __str__(self) -> str:
return f"Task {self.task_id}" return f"Task {self.task_id}"

View File

@ -1,13 +1,17 @@
import hashlib import hashlib
import logging import logging
import uuid
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import Final from typing import Final
from celery import states
from django.conf import settings from django.conf import settings
from django.utils import timezone
from tqdm import tqdm from tqdm import tqdm
from documents.models import Document from documents.models import Document
from documents.models import PaperlessTask
class SanityCheckMessages: class SanityCheckMessages:
@ -57,7 +61,17 @@ class SanityCheckFailedException(Exception):
pass 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() messages = SanityCheckMessages()
present_files = { present_files = {
@ -142,4 +156,11 @@ def check_sanity(*, progress=False) -> SanityCheckMessages:
for extra_file in present_files: for extra_file in present_files:
messages.warning(None, f"Orphaned file in media dir: {extra_file}") 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 return messages

View File

@ -1710,6 +1710,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
fields = ( fields = (
"id", "id",
"task_id", "task_id",
"task_name",
"task_file_name", "task_file_name",
"date_created", "date_created",
"date_done", "date_done",
@ -1721,12 +1722,6 @@ class TasksViewSerializer(OwnedObjectSerializer):
"owner", "owner",
) )
type = serializers.SerializerMethodField()
def get_type(self, obj) -> str:
# just file tasks, for now
return "file"
related_document = serializers.SerializerMethodField() related_document = serializers.SerializerMethodField()
created_doc_re = re.compile(r"New document id (\d+) created") created_doc_re = re.compile(r"New document id (\d+) created")
duplicate_doc_re = re.compile(r"It is a duplicate of .* \(#(\d+)\)") duplicate_doc_re = re.compile(r"It is a duplicate of .* \(#(\d+)\)")
@ -1734,6 +1729,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
def get_related_document(self, obj) -> str | None: def get_related_document(self, obj) -> str | None:
result = None result = None
re = None re = None
if obj.result:
match obj.status: match obj.status:
case states.SUCCESS: case states.SUCCESS:
re = self.created_doc_re re = self.created_doc_re
@ -1752,6 +1748,14 @@ class TasksViewSerializer(OwnedObjectSerializer):
return result return result
class RunTaskViewSerializer(serializers.Serializer):
task_name = serializers.ChoiceField(
choices=PaperlessTask.TaskName.choices,
label="Task Name",
write_only=True,
)
class AcknowledgeTasksViewSerializer(serializers.Serializer): class AcknowledgeTasksViewSerializer(serializers.Serializer):
tasks = serializers.ListField( tasks = serializers.ListField(
required=True, required=True,

View File

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

View File

@ -9,6 +9,7 @@ from tempfile import TemporaryDirectory
import tqdm import tqdm
from celery import Task from celery import Task
from celery import shared_task from celery import shared_task
from celery import states
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
@ -35,6 +36,7 @@ from documents.models import Correspondent
from documents.models import CustomFieldInstance from documents.models import CustomFieldInstance
from documents.models import Document from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import PaperlessTask
from documents.models import StoragePath from documents.models import StoragePath
from documents.models import Tag from documents.models import Tag
from documents.models import Workflow from documents.models import Workflow
@ -74,19 +76,34 @@ def index_reindex(*, progress_bar_disable=False):
@shared_task @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 ( if (
not Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists() not Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
and not DocumentType.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 Correspondent.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
and not StoragePath.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 # Special case, items were once auto and trained, so remove the model
# and prevent its use again # and prevent its use again
if settings.MODEL_FILE.exists(): if settings.MODEL_FILE.exists():
logger.info(f"Removing {settings.MODEL_FILE} so it won't be used") logger.info(f"Removing {settings.MODEL_FILE} so it won't be used")
settings.MODEL_FILE.unlink() settings.MODEL_FILE.unlink()
task.status = states.SUCCESS
task.result = result
task.date_done = timezone.now()
task.save()
return return
classifier = load_classifier() classifier = load_classifier()
@ -100,11 +117,19 @@ def train_classifier():
f"Saving updated classifier model to {settings.MODEL_FILE}...", f"Saving updated classifier model to {settings.MODEL_FILE}...",
) )
classifier.save() classifier.save()
task.result = "Training completed successfully"
else: else:
logger.debug("Training data unchanged.") 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: except Exception as e:
logger.warning("Classifier error: " + str(e)) logger.warning("Classifier error: " + str(e))
task.status = states.FAILURE
task.result = str(e)
@shared_task(bind=True) @shared_task(bind=True)
@ -176,13 +201,16 @@ def consume_file(
@shared_task @shared_task
def sanity_check(): def sanity_check(*, scheduled=True, raise_on_error=True):
messages = sanity_checker.check_sanity() messages = sanity_checker.check_sanity(scheduled=scheduled)
messages.log_messages() messages.log_messages()
if messages.has_error: 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: elif messages.has_warning:
return "Sanity check exited with warnings. See log." return "Sanity check exited with warnings. See log."
elif len(messages) > 0: elif len(messages) > 0:

View File

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

@ -1,4 +1,5 @@
import uuid import uuid
from unittest import mock
import celery import celery
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
@ -8,6 +9,7 @@ from rest_framework.test import APITestCase
from documents.models import PaperlessTask from documents.models import PaperlessTask
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from documents.views import TasksViewSet
class TestTasks(DirectoriesMixin, APITestCase): class TestTasks(DirectoriesMixin, APITestCase):
@ -130,7 +132,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) 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) self.assertEqual(len(response.data), 0)
def test_tasks_owner_aware(self): def test_tasks_owner_aware(self):
@ -246,7 +248,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
PaperlessTask.objects.create( PaperlessTask.objects.create(
task_id=str(uuid.uuid4()), task_id=str(uuid.uuid4()),
task_file_name="test.pdf", task_file_name="test.pdf",
task_name="documents.tasks.some_task", task_name=PaperlessTask.TaskName.CONSUME_FILE,
status=celery.states.SUCCESS, status=celery.states.SUCCESS,
) )
@ -272,7 +274,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
PaperlessTask.objects.create( PaperlessTask.objects.create(
task_id=str(uuid.uuid4()), task_id=str(uuid.uuid4()),
task_file_name="anothertest.pdf", task_file_name="anothertest.pdf",
task_name="documents.tasks.some_task", task_name=PaperlessTask.TaskName.CONSUME_FILE,
status=celery.states.SUCCESS, status=celery.states.SUCCESS,
) )
@ -309,3 +311,62 @@ class TestTasks(DirectoriesMixin, APITestCase):
returned_data = response.data[0] returned_data = response.data[0]
self.assertEqual(returned_data["related_document"], "1234") 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()

View File

@ -68,7 +68,7 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
self.assertIsNotNone(task) self.assertIsNotNone(task)
self.assertEqual(headers["id"], task.task_id) self.assertEqual(headers["id"], task.task_id)
self.assertEqual("hello-999.pdf", task.task_file_name) 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(1, task.owner_id)
self.assertEqual(celery.states.PENDING, task.status) self.assertEqual(celery.states.PENDING, task.status)

View File

@ -118,6 +118,19 @@ class TestSanityCheck(DirectoriesMixin, TestCase):
self.assertRaises(SanityCheckFailedException, tasks.sanity_check) self.assertRaises(SanityCheckFailedException, tasks.sanity_check)
m.assert_called_once() 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") @mock.patch("documents.tasks.sanity_checker.check_sanity")
def test_sanity_check_warning(self, m): def test_sanity_check_warning(self, m):
messages = SanityCheckMessages() messages = SanityCheckMessages()

View File

@ -14,6 +14,7 @@ from urllib.parse import urlparse
import httpx import httpx
import pathvalidate import pathvalidate
from celery import states
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import User 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 DocumentTypeFilterSet
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
from documents.filters import ObjectOwnedPermissionsFilter from documents.filters import ObjectOwnedPermissionsFilter
from documents.filters import PaperlessTaskFilterSet
from documents.filters import ShareLinkFilterSet from documents.filters import ShareLinkFilterSet
from documents.filters import StoragePathFilterSet from documents.filters import StoragePathFilterSet
from documents.filters import TagFilterSet from documents.filters import TagFilterSet
@ -144,6 +146,7 @@ from documents.serialisers import DocumentListSerializer
from documents.serialisers import DocumentSerializer from documents.serialisers import DocumentSerializer
from documents.serialisers import DocumentTypeSerializer from documents.serialisers import DocumentTypeSerializer
from documents.serialisers import PostDocumentSerializer from documents.serialisers import PostDocumentSerializer
from documents.serialisers import RunTaskViewSerializer
from documents.serialisers import SavedViewSerializer from documents.serialisers import SavedViewSerializer
from documents.serialisers import SearchResultSerializer from documents.serialisers import SearchResultSerializer
from documents.serialisers import ShareLinkSerializer from documents.serialisers import ShareLinkSerializer
@ -160,6 +163,9 @@ from documents.serialisers import WorkflowTriggerSerializer
from documents.signals import document_updated from documents.signals import document_updated
from documents.tasks import consume_file from documents.tasks import consume_file
from documents.tasks import empty_trash 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 documents.templating.filepath import validate_filepath_template_and_render
from paperless import version from paperless import version
from paperless.celery import app as celery_app from paperless.celery import app as celery_app
@ -2276,16 +2282,27 @@ class RemoteVersionView(GenericAPIView):
class TasksViewSet(ReadOnlyModelViewSet): class TasksViewSet(ReadOnlyModelViewSet):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions) permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = TasksViewSerializer 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): def get_queryset(self):
queryset = ( queryset = PaperlessTask.objects.all().order_by("-date_created")
PaperlessTask.objects.filter(
acknowledged=False,
)
.order_by("date_created")
.reverse()
)
task_id = self.request.query_params.get("task_id") task_id = self.request.query_params.get("task_id")
if task_id is not None: if task_id is not None:
queryset = PaperlessTask.objects.filter(task_id=task_id) queryset = PaperlessTask.objects.filter(task_id=task_id)
@ -2308,6 +2325,25 @@ class TasksViewSet(ReadOnlyModelViewSet):
except Exception: except Exception:
return HttpResponseBadRequest() 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): class ShareLinkViewSet(ModelViewSet, PassUserMixin):
model = ShareLink model = ShareLink
@ -2614,6 +2650,14 @@ class CustomFieldViewSet(ModelViewSet):
"last_trained": serializers.DateTimeField(), "last_trained": serializers.DateTimeField(),
}, },
), ),
"sanity_check": inline_serializer(
name="SanityCheck",
fields={
"status": serializers.CharField(),
"error": serializers.CharField(),
"last_run": serializers.DateTimeField(),
},
),
}, },
), ),
}, },
@ -2674,13 +2718,20 @@ class SystemStatusView(PassUserMixin):
) )
redis_error = "Error connecting to redis, check logs for more detail." redis_error = "Error connecting to redis, check logs for more detail."
celery_error = None
celery_url = None
try: try:
celery_ping = celery_app.control.inspect().ping() 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": if first_worker_ping["ok"] == "pong":
celery_active = "OK" celery_active = "OK"
except Exception: except Exception as e:
celery_active = "ERROR" 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 index_error = None
try: try:
@ -2697,54 +2748,42 @@ class SystemStatusView(PassUserMixin):
) )
index_last_modified = None index_last_modified = None
classifier_error = None last_trained_task = (
classifier_status = None PaperlessTask.objects.filter(
try: task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
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 ( .order_by("-date_done")
docs_queryset.count() > 0 .first()
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_status = "OK"
classifier_last_trained = ( classifier_error = None
make_aware( if last_trained_task is None:
datetime.fromtimestamp(classifier.get_last_checked()), classifier_status = "WARNING"
) classifier_error = "No classifier training tasks found"
if settings.MODEL_FILE.exists() elif last_trained_task and last_trained_task.status == states.FAILURE:
and classifier.get_last_checked() is not None
else None
)
except Exception as e:
if classifier_status is None:
classifier_status = "ERROR" classifier_status = "ERROR"
classifier_last_trained = None classifier_error = last_trained_task.result
if classifier_error is None: classifier_last_trained = (
classifier_error = ( last_trained_task.date_done if last_trained_task else None
"Unable to load classifier, check logs for more detail."
) )
logger.exception(
f"System status detected a possible problem while loading the classifier: {e}", 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( return Response(
@ -2773,12 +2812,17 @@ class SystemStatusView(PassUserMixin):
"redis_status": redis_status, "redis_status": redis_status,
"redis_error": redis_error, "redis_error": redis_error,
"celery_status": celery_active, "celery_status": celery_active,
"celery_url": celery_url,
"celery_error": celery_error,
"index_status": index_status, "index_status": index_status,
"index_last_modified": index_last_modified, "index_last_modified": index_last_modified,
"index_error": index_error, "index_error": index_error,
"classifier_status": classifier_status, "classifier_status": classifier_status,
"classifier_last_trained": classifier_last_trained, "classifier_last_trained": classifier_last_trained,
"classifier_error": classifier_error, "classifier_error": classifier_error,
"sanity_check_status": sanity_check_status,
"sanity_check_last_run": sanity_check_last_run,
"sanity_check_error": sanity_check_error,
}, },
}, },
) )

View File

@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: English\n" "Language-Team: English\n"
@ -21,67 +21,67 @@ msgstr ""
msgid "Documents" msgid "Documents"
msgstr "" msgstr ""
#: documents/filters.py:369 #: documents/filters.py:370
msgid "Value must be valid JSON." msgid "Value must be valid JSON."
msgstr "" msgstr ""
#: documents/filters.py:388 #: documents/filters.py:389
msgid "Invalid custom field query expression" msgid "Invalid custom field query expression"
msgstr "" msgstr ""
#: documents/filters.py:398 #: documents/filters.py:399
msgid "Invalid expression list. Must be nonempty." msgid "Invalid expression list. Must be nonempty."
msgstr "" msgstr ""
#: documents/filters.py:419 #: documents/filters.py:420
msgid "Invalid logical operator {op!r}" msgid "Invalid logical operator {op!r}"
msgstr "" msgstr ""
#: documents/filters.py:433 #: documents/filters.py:434
msgid "Maximum number of query conditions exceeded." msgid "Maximum number of query conditions exceeded."
msgstr "" msgstr ""
#: documents/filters.py:498 #: documents/filters.py:499
msgid "{name!r} is not a valid custom field." msgid "{name!r} is not a valid custom field."
msgstr "" msgstr ""
#: documents/filters.py:535 #: documents/filters.py:536
msgid "{data_type} does not support query expr {expr!r}." msgid "{data_type} does not support query expr {expr!r}."
msgstr "" msgstr ""
#: documents/filters.py:643 #: documents/filters.py:644
msgid "Maximum nesting depth exceeded." msgid "Maximum nesting depth exceeded."
msgstr "" msgstr ""
#: documents/filters.py:813 #: documents/filters.py:829
msgid "Custom field not found" msgid "Custom field not found"
msgstr "" msgstr ""
#: documents/models.py:41 documents/models.py:806 #: documents/models.py:41 documents/models.py:830
msgid "owner" msgid "owner"
msgstr "" msgstr ""
#: documents/models.py:58 documents/models.py:1017 #: documents/models.py:58 documents/models.py:1041
msgid "None" msgid "None"
msgstr "" msgstr ""
#: documents/models.py:59 documents/models.py:1018 #: documents/models.py:59 documents/models.py:1042
msgid "Any word" msgid "Any word"
msgstr "" msgstr ""
#: documents/models.py:60 documents/models.py:1019 #: documents/models.py:60 documents/models.py:1043
msgid "All words" msgid "All words"
msgstr "" msgstr ""
#: documents/models.py:61 documents/models.py:1020 #: documents/models.py:61 documents/models.py:1044
msgid "Exact match" msgid "Exact match"
msgstr "" msgstr ""
#: documents/models.py:62 documents/models.py:1021 #: documents/models.py:62 documents/models.py:1045
msgid "Regular expression" msgid "Regular expression"
msgstr "" msgstr ""
#: documents/models.py:63 documents/models.py:1022 #: documents/models.py:63 documents/models.py:1046
msgid "Fuzzy word" msgid "Fuzzy word"
msgstr "" msgstr ""
@ -89,20 +89,20 @@ msgstr ""
msgid "Automatic" msgid "Automatic"
msgstr "" 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 #: paperless_mail/models.py:23 paperless_mail/models.py:143
msgid "name" msgid "name"
msgstr "" msgstr ""
#: documents/models.py:69 documents/models.py:1085 #: documents/models.py:69 documents/models.py:1110
msgid "match" msgid "match"
msgstr "" msgstr ""
#: documents/models.py:72 documents/models.py:1088 #: documents/models.py:72 documents/models.py:1113
msgid "matching algorithm" msgid "matching algorithm"
msgstr "" msgstr ""
#: documents/models.py:77 documents/models.py:1093 #: documents/models.py:77 documents/models.py:1118
msgid "is insensitive" msgid "is insensitive"
msgstr "" msgstr ""
@ -168,7 +168,7 @@ msgstr ""
msgid "title" msgid "title"
msgstr "" msgstr ""
#: documents/models.py:175 documents/models.py:720 #: documents/models.py:175 documents/models.py:744
msgid "content" msgid "content"
msgstr "" msgstr ""
@ -206,8 +206,8 @@ msgstr ""
msgid "The number of pages of the document." msgid "The number of pages of the document."
msgstr "" msgstr ""
#: documents/models.py:221 documents/models.py:401 documents/models.py:726 #: documents/models.py:221 documents/models.py:401 documents/models.py:750
#: documents/models.py:764 documents/models.py:835 documents/models.py:893 #: documents/models.py:788 documents/models.py:859 documents/models.py:917
msgid "created" msgid "created"
msgstr "" msgstr ""
@ -255,8 +255,8 @@ msgstr ""
msgid "The position of this document in your physical document archive." msgid "The position of this document in your physical document archive."
msgstr "" msgstr ""
#: documents/models.py:295 documents/models.py:737 documents/models.py:791 #: documents/models.py:295 documents/models.py:761 documents/models.py:815
#: documents/models.py:1541 #: documents/models.py:1569
msgid "document" msgid "document"
msgstr "" msgstr ""
@ -320,11 +320,11 @@ msgstr ""
msgid "Title" msgid "Title"
msgstr "" msgstr ""
#: documents/models.py:420 documents/models.py:1037 #: documents/models.py:420 documents/models.py:1062
msgid "Created" msgid "Created"
msgstr "" msgstr ""
#: documents/models.py:421 documents/models.py:1036 #: documents/models.py:421 documents/models.py:1061
msgid "Added" msgid "Added"
msgstr "" msgstr ""
@ -608,581 +608,621 @@ msgstr ""
msgid "filter rules" msgid "filter rules"
msgstr "" msgstr ""
#: documents/models.py:656 #: documents/models.py:654
msgid "Task ID" msgid "Auto Task"
msgstr "" msgstr ""
#: documents/models.py:657 #: documents/models.py:655
msgid "Celery ID for the Task that was run" 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 "" msgstr ""
#: documents/models.py:662 #: documents/models.py:662
msgid "Index Optimize"
msgstr ""
#: documents/models.py:667
msgid "Task ID"
msgstr ""
#: documents/models.py:668
msgid "Celery ID for the Task that was run"
msgstr ""
#: documents/models.py:673
msgid "Acknowledged" msgid "Acknowledged"
msgstr "" msgstr ""
#: documents/models.py:663 #: documents/models.py:674
msgid "If the task is acknowledged via the frontend or API" msgid "If the task is acknowledged via the frontend or API"
msgstr "" msgstr ""
#: documents/models.py:669 #: documents/models.py:680
msgid "Task Filename" msgid "Task Filename"
msgstr "" msgstr ""
#: documents/models.py:670 #: documents/models.py:681
msgid "Name of the file which the Task was run for" msgid "Name of the file which the Task was run for"
msgstr "" msgstr ""
#: documents/models.py:676 #: documents/models.py:688
msgid "Task Name" msgid "Task Name"
msgstr "" msgstr ""
#: documents/models.py:677 #: documents/models.py:689
msgid "Name of the Task which was run" msgid "Name of the task that was run"
msgstr ""
#: documents/models.py:684
msgid "Task State"
msgstr ""
#: documents/models.py:685
msgid "Current state of the task being run"
msgstr ""
#: documents/models.py:690
msgid "Created DateTime"
msgstr ""
#: documents/models.py:691
msgid "Datetime field when the task result was created in UTC"
msgstr "" msgstr ""
#: documents/models.py:696 #: documents/models.py:696
msgid "Started DateTime" msgid "Task State"
msgstr "" msgstr ""
#: documents/models.py:697 #: documents/models.py:697
msgid "Datetime field when the task was started in UTC" msgid "Current state of the task being run"
msgstr ""
#: documents/models.py:702
msgid "Completed DateTime"
msgstr "" msgstr ""
#: documents/models.py:703 #: documents/models.py:703
msgid "Datetime field when the task was completed in UTC" msgid "Created DateTime"
msgstr "" msgstr ""
#: documents/models.py:708 #: documents/models.py:704
msgid "Result Data" msgid "Datetime field when the task result was created in UTC"
msgstr "" msgstr ""
#: documents/models.py:710 #: documents/models.py:710
msgid "Started DateTime"
msgstr ""
#: documents/models.py:711
msgid "Datetime field when the task was started in UTC"
msgstr ""
#: documents/models.py:717
msgid "Completed DateTime"
msgstr ""
#: documents/models.py:718
msgid "Datetime field when the task was completed in UTC"
msgstr ""
#: documents/models.py:724
msgid "Result Data"
msgstr ""
#: documents/models.py:726
msgid "The data returned by the task" msgid "The data returned by the task"
msgstr "" msgstr ""
#: documents/models.py:722 #: documents/models.py:734
msgid "Note for the document" msgid "Task Type"
msgstr ""
#: documents/models.py:735
msgid "The type of task that was run"
msgstr "" msgstr ""
#: documents/models.py:746 #: documents/models.py:746
msgid "Note for the document"
msgstr ""
#: documents/models.py:770
msgid "user" msgid "user"
msgstr "" msgstr ""
#: documents/models.py:751 #: documents/models.py:775
msgid "note" msgid "note"
msgstr "" msgstr ""
#: documents/models.py:752 #: documents/models.py:776
msgid "notes" msgid "notes"
msgstr "" msgstr ""
#: documents/models.py:760 #: documents/models.py:784
msgid "Archive" msgid "Archive"
msgstr "" msgstr ""
#: documents/models.py:761 #: documents/models.py:785
msgid "Original" msgid "Original"
msgstr "" msgstr ""
#: documents/models.py:772 paperless_mail/models.py:75 #: documents/models.py:796 paperless_mail/models.py:75
msgid "expiration" msgid "expiration"
msgstr "" msgstr ""
#: documents/models.py:779 #: documents/models.py:803
msgid "slug" msgid "slug"
msgstr "" msgstr ""
#: documents/models.py:811 #: documents/models.py:835
msgid "share link" msgid "share link"
msgstr "" msgstr ""
#: documents/models.py:812 #: documents/models.py:836
msgid "share links" msgid "share links"
msgstr "" msgstr ""
#: documents/models.py:824 #: documents/models.py:848
msgid "String" msgid "String"
msgstr "" msgstr ""
#: documents/models.py:825 #: documents/models.py:849
msgid "URL" msgid "URL"
msgstr "" msgstr ""
#: documents/models.py:826 #: documents/models.py:850
msgid "Date" msgid "Date"
msgstr "" msgstr ""
#: documents/models.py:827 #: documents/models.py:851
msgid "Boolean" msgid "Boolean"
msgstr "" msgstr ""
#: documents/models.py:828 #: documents/models.py:852
msgid "Integer" msgid "Integer"
msgstr "" msgstr ""
#: documents/models.py:829 #: documents/models.py:853
msgid "Float" msgid "Float"
msgstr "" msgstr ""
#: documents/models.py:830 #: documents/models.py:854
msgid "Monetary" msgid "Monetary"
msgstr "" msgstr ""
#: documents/models.py:831 #: documents/models.py:855
msgid "Document Link" msgid "Document Link"
msgstr "" msgstr ""
#: documents/models.py:832 #: documents/models.py:856
msgid "Select" msgid "Select"
msgstr "" msgstr ""
#: documents/models.py:844 #: documents/models.py:868
msgid "data type" msgid "data type"
msgstr "" msgstr ""
#: documents/models.py:851 #: documents/models.py:875
msgid "extra data" msgid "extra data"
msgstr "" msgstr ""
#: documents/models.py:855 #: documents/models.py:879
msgid "Extra data for the custom field, such as select options" msgid "Extra data for the custom field, such as select options"
msgstr "" msgstr ""
#: documents/models.py:861 #: documents/models.py:885
msgid "custom field" msgid "custom field"
msgstr "" msgstr ""
#: documents/models.py:862 #: documents/models.py:886
msgid "custom fields" msgid "custom fields"
msgstr "" msgstr ""
#: documents/models.py:959 #: documents/models.py:983
msgid "custom field instance" msgid "custom field instance"
msgstr "" msgstr ""
#: documents/models.py:960 #: documents/models.py:984
msgid "custom field instances" msgid "custom field instances"
msgstr "" msgstr ""
#: documents/models.py:1025 #: documents/models.py:1049
msgid "Consumption Started" msgid "Consumption Started"
msgstr "" msgstr ""
#: documents/models.py:1026 #: documents/models.py:1050
msgid "Document Added" msgid "Document Added"
msgstr "" msgstr ""
#: documents/models.py:1027 #: documents/models.py:1051
msgid "Document Updated" msgid "Document Updated"
msgstr "" msgstr ""
#: documents/models.py:1028 #: documents/models.py:1052
msgid "Scheduled" msgid "Scheduled"
msgstr "" msgstr ""
#: documents/models.py:1031 #: documents/models.py:1055
msgid "Consume Folder" msgid "Consume Folder"
msgstr "" msgstr ""
#: documents/models.py:1032 #: documents/models.py:1056
msgid "Api Upload" msgid "Api Upload"
msgstr "" msgstr ""
#: documents/models.py:1033 #: documents/models.py:1057
msgid "Mail Fetch" msgid "Mail Fetch"
msgstr "" msgstr ""
#: documents/models.py:1038 #: documents/models.py:1058
msgid "Web UI"
msgstr ""
#: documents/models.py:1063
msgid "Modified" msgid "Modified"
msgstr "" msgstr ""
#: documents/models.py:1039 #: documents/models.py:1064
msgid "Custom Field" msgid "Custom Field"
msgstr "" msgstr ""
#: documents/models.py:1042 #: documents/models.py:1067
msgid "Workflow Trigger Type" msgid "Workflow Trigger Type"
msgstr "" msgstr ""
#: documents/models.py:1054 #: documents/models.py:1079
msgid "filter path" msgid "filter path"
msgstr "" msgstr ""
#: documents/models.py:1059 #: documents/models.py:1084
msgid "" msgid ""
"Only consume documents with a path that matches this if specified. Wildcards " "Only consume documents with a path that matches this if specified. Wildcards "
"specified as * are allowed. Case insensitive." "specified as * are allowed. Case insensitive."
msgstr "" msgstr ""
#: documents/models.py:1066 #: documents/models.py:1091
msgid "filter filename" msgid "filter filename"
msgstr "" msgstr ""
#: documents/models.py:1071 paperless_mail/models.py:200 #: documents/models.py:1096 paperless_mail/models.py:200
msgid "" msgid ""
"Only consume documents which entirely match this filename if specified. " "Only consume documents which entirely match this filename if specified. "
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
msgstr "" msgstr ""
#: documents/models.py:1082 #: documents/models.py:1107
msgid "filter documents from this mail rule" msgid "filter documents from this mail rule"
msgstr "" msgstr ""
#: documents/models.py:1098 #: documents/models.py:1123
msgid "has these tag(s)" msgid "has these tag(s)"
msgstr "" msgstr ""
#: documents/models.py:1106 #: documents/models.py:1131
msgid "has this document type" msgid "has this document type"
msgstr "" msgstr ""
#: documents/models.py:1114 #: documents/models.py:1139
msgid "has this correspondent" msgid "has this correspondent"
msgstr "" msgstr ""
#: documents/models.py:1118 #: documents/models.py:1143
msgid "schedule offset days" msgid "schedule offset days"
msgstr "" msgstr ""
#: documents/models.py:1121 #: documents/models.py:1146
msgid "The number of days to offset the schedule trigger by." msgid "The number of days to offset the schedule trigger by."
msgstr "" msgstr ""
#: documents/models.py:1126 #: documents/models.py:1151
msgid "schedule is recurring" msgid "schedule is recurring"
msgstr "" msgstr ""
#: documents/models.py:1129 #: documents/models.py:1154
msgid "If the schedule should be recurring." msgid "If the schedule should be recurring."
msgstr "" msgstr ""
#: documents/models.py:1134 #: documents/models.py:1159
msgid "schedule recurring delay in days" msgid "schedule recurring delay in days"
msgstr "" msgstr ""
#: documents/models.py:1138 #: documents/models.py:1163
msgid "The number of days between recurring schedule triggers." msgid "The number of days between recurring schedule triggers."
msgstr "" msgstr ""
#: documents/models.py:1143 #: documents/models.py:1168
msgid "schedule date field" msgid "schedule date field"
msgstr "" msgstr ""
#: documents/models.py:1148 #: documents/models.py:1173
msgid "The field to check for a schedule trigger." msgid "The field to check for a schedule trigger."
msgstr "" msgstr ""
#: documents/models.py:1157 #: documents/models.py:1182
msgid "schedule date custom field" msgid "schedule date custom field"
msgstr "" msgstr ""
#: documents/models.py:1161 #: documents/models.py:1186
msgid "workflow trigger" msgid "workflow trigger"
msgstr "" msgstr ""
#: documents/models.py:1162 #: documents/models.py:1187
msgid "workflow triggers" msgid "workflow triggers"
msgstr "" msgstr ""
#: documents/models.py:1170 #: documents/models.py:1195
msgid "email subject" msgid "email subject"
msgstr "" msgstr ""
#: documents/models.py:1174 #: documents/models.py:1199
msgid "" msgid ""
"The subject of the email, can include some placeholders, see documentation." "The subject of the email, can include some placeholders, see documentation."
msgstr "" msgstr ""
#: documents/models.py:1180 #: documents/models.py:1205
msgid "email body" msgid "email body"
msgstr "" msgstr ""
#: documents/models.py:1183 #: documents/models.py:1208
msgid "" msgid ""
"The body (message) of the email, can include some placeholders, see " "The body (message) of the email, can include some placeholders, see "
"documentation." "documentation."
msgstr "" msgstr ""
#: documents/models.py:1189 #: documents/models.py:1214
msgid "emails to" msgid "emails to"
msgstr "" msgstr ""
#: documents/models.py:1192 #: documents/models.py:1217
msgid "The destination email addresses, comma separated." msgid "The destination email addresses, comma separated."
msgstr "" msgstr ""
#: documents/models.py:1198 #: documents/models.py:1223
msgid "include document in email" msgid "include document in email"
msgstr "" msgstr ""
#: documents/models.py:1207 #: documents/models.py:1234
msgid "webhook url" msgid "webhook url"
msgstr "" msgstr ""
#: documents/models.py:1209 #: documents/models.py:1237
msgid "The destination URL for the notification." msgid "The destination URL for the notification."
msgstr "" msgstr ""
#: documents/models.py:1214 #: documents/models.py:1242
msgid "use parameters" msgid "use parameters"
msgstr "" msgstr ""
#: documents/models.py:1219 #: documents/models.py:1247
msgid "send as JSON" msgid "send as JSON"
msgstr "" msgstr ""
#: documents/models.py:1223 #: documents/models.py:1251
msgid "webhook parameters" msgid "webhook parameters"
msgstr "" msgstr ""
#: documents/models.py:1226 #: documents/models.py:1254
msgid "The parameters to send with the webhook URL if body not used." msgid "The parameters to send with the webhook URL if body not used."
msgstr "" msgstr ""
#: documents/models.py:1230 #: documents/models.py:1258
msgid "webhook body" msgid "webhook body"
msgstr "" msgstr ""
#: documents/models.py:1233 #: documents/models.py:1261
msgid "The body to send with the webhook URL if parameters not used." msgid "The body to send with the webhook URL if parameters not used."
msgstr "" msgstr ""
#: documents/models.py:1237 #: documents/models.py:1265
msgid "webhook headers" msgid "webhook headers"
msgstr "" msgstr ""
#: documents/models.py:1240 #: documents/models.py:1268
msgid "The headers to send with the webhook URL." msgid "The headers to send with the webhook URL."
msgstr "" msgstr ""
#: documents/models.py:1245 #: documents/models.py:1273
msgid "include document in webhook" msgid "include document in webhook"
msgstr "" msgstr ""
#: documents/models.py:1256 #: documents/models.py:1284
msgid "Assignment" msgid "Assignment"
msgstr "" msgstr ""
#: documents/models.py:1260 #: documents/models.py:1288
msgid "Removal" msgid "Removal"
msgstr "" 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" msgid "Email"
msgstr "" msgstr ""
#: documents/models.py:1268 #: documents/models.py:1296
msgid "Webhook" msgid "Webhook"
msgstr "" msgstr ""
#: documents/models.py:1272 #: documents/models.py:1300
msgid "Workflow Action Type" msgid "Workflow Action Type"
msgstr "" msgstr ""
#: documents/models.py:1278 #: documents/models.py:1306
msgid "assign title" msgid "assign title"
msgstr "" msgstr ""
#: documents/models.py:1283 #: documents/models.py:1311
msgid "" msgid ""
"Assign a document title, can include some placeholders, see documentation." "Assign a document title, can include some placeholders, see documentation."
msgstr "" msgstr ""
#: documents/models.py:1292 paperless_mail/models.py:274 #: documents/models.py:1320 paperless_mail/models.py:274
msgid "assign this tag" msgid "assign this tag"
msgstr "" msgstr ""
#: documents/models.py:1301 paperless_mail/models.py:282 #: documents/models.py:1329 paperless_mail/models.py:282
msgid "assign this document type" msgid "assign this document type"
msgstr "" msgstr ""
#: documents/models.py:1310 paperless_mail/models.py:296 #: documents/models.py:1338 paperless_mail/models.py:296
msgid "assign this correspondent" msgid "assign this correspondent"
msgstr "" msgstr ""
#: documents/models.py:1319 #: documents/models.py:1347
msgid "assign this storage path" msgid "assign this storage path"
msgstr "" msgstr ""
#: documents/models.py:1328 #: documents/models.py:1356
msgid "assign this owner" msgid "assign this owner"
msgstr "" msgstr ""
#: documents/models.py:1335 #: documents/models.py:1363
msgid "grant view permissions to these users" msgid "grant view permissions to these users"
msgstr "" msgstr ""
#: documents/models.py:1342 #: documents/models.py:1370
msgid "grant view permissions to these groups" msgid "grant view permissions to these groups"
msgstr "" msgstr ""
#: documents/models.py:1349 #: documents/models.py:1377
msgid "grant change permissions to these users" msgid "grant change permissions to these users"
msgstr "" msgstr ""
#: documents/models.py:1356 #: documents/models.py:1384
msgid "grant change permissions to these groups" msgid "grant change permissions to these groups"
msgstr "" msgstr ""
#: documents/models.py:1363 #: documents/models.py:1391
msgid "assign these custom fields" msgid "assign these custom fields"
msgstr "" msgstr ""
#: documents/models.py:1370 #: documents/models.py:1398
msgid "remove these tag(s)" msgid "remove these tag(s)"
msgstr "" msgstr ""
#: documents/models.py:1375 #: documents/models.py:1403
msgid "remove all tags" msgid "remove all tags"
msgstr "" msgstr ""
#: documents/models.py:1382 #: documents/models.py:1410
msgid "remove these document type(s)" msgid "remove these document type(s)"
msgstr "" msgstr ""
#: documents/models.py:1387 #: documents/models.py:1415
msgid "remove all document types" msgid "remove all document types"
msgstr "" msgstr ""
#: documents/models.py:1394 #: documents/models.py:1422
msgid "remove these correspondent(s)" msgid "remove these correspondent(s)"
msgstr "" msgstr ""
#: documents/models.py:1399 #: documents/models.py:1427
msgid "remove all correspondents" msgid "remove all correspondents"
msgstr "" msgstr ""
#: documents/models.py:1406 #: documents/models.py:1434
msgid "remove these storage path(s)" msgid "remove these storage path(s)"
msgstr "" msgstr ""
#: documents/models.py:1411 #: documents/models.py:1439
msgid "remove all storage paths" msgid "remove all storage paths"
msgstr "" msgstr ""
#: documents/models.py:1418 #: documents/models.py:1446
msgid "remove these owner(s)" msgid "remove these owner(s)"
msgstr "" msgstr ""
#: documents/models.py:1423 #: documents/models.py:1451
msgid "remove all owners" msgid "remove all owners"
msgstr "" msgstr ""
#: documents/models.py:1430 #: documents/models.py:1458
msgid "remove view permissions for these users" msgid "remove view permissions for these users"
msgstr "" msgstr ""
#: documents/models.py:1437 #: documents/models.py:1465
msgid "remove view permissions for these groups" msgid "remove view permissions for these groups"
msgstr "" msgstr ""
#: documents/models.py:1444 #: documents/models.py:1472
msgid "remove change permissions for these users" msgid "remove change permissions for these users"
msgstr "" msgstr ""
#: documents/models.py:1451 #: documents/models.py:1479
msgid "remove change permissions for these groups" msgid "remove change permissions for these groups"
msgstr "" msgstr ""
#: documents/models.py:1456 #: documents/models.py:1484
msgid "remove all permissions" msgid "remove all permissions"
msgstr "" msgstr ""
#: documents/models.py:1463 #: documents/models.py:1491
msgid "remove these custom fields" msgid "remove these custom fields"
msgstr "" msgstr ""
#: documents/models.py:1468 #: documents/models.py:1496
msgid "remove all custom fields" msgid "remove all custom fields"
msgstr "" msgstr ""
#: documents/models.py:1477 #: documents/models.py:1505
msgid "email" msgid "email"
msgstr "" msgstr ""
#: documents/models.py:1486 #: documents/models.py:1514
msgid "webhook" msgid "webhook"
msgstr "" msgstr ""
#: documents/models.py:1490 #: documents/models.py:1518
msgid "workflow action" msgid "workflow action"
msgstr "" msgstr ""
#: documents/models.py:1491 #: documents/models.py:1519
msgid "workflow actions" msgid "workflow actions"
msgstr "" msgstr ""
#: documents/models.py:1500 paperless_mail/models.py:145 #: documents/models.py:1528 paperless_mail/models.py:145
msgid "order" msgid "order"
msgstr "" msgstr ""
#: documents/models.py:1506 #: documents/models.py:1534
msgid "triggers" msgid "triggers"
msgstr "" msgstr ""
#: documents/models.py:1513 #: documents/models.py:1541
msgid "actions" msgid "actions"
msgstr "" msgstr ""
#: documents/models.py:1516 paperless_mail/models.py:154 #: documents/models.py:1544 paperless_mail/models.py:154
msgid "enabled" msgid "enabled"
msgstr "" msgstr ""
#: documents/models.py:1527 #: documents/models.py:1555
msgid "workflow" msgid "workflow"
msgstr "" msgstr ""
#: documents/models.py:1531 #: documents/models.py:1559
msgid "workflow trigger type" msgid "workflow trigger type"
msgstr "" msgstr ""
#: documents/models.py:1545 #: documents/models.py:1573
msgid "date run" msgid "date run"
msgstr "" msgstr ""
#: documents/models.py:1551 #: documents/models.py:1579
msgid "workflow run" msgid "workflow run"
msgstr "" msgstr ""
#: documents/models.py:1552 #: documents/models.py:1580
msgid "workflow runs" msgid "workflow runs"
msgstr "" msgstr ""
#: documents/serialisers.py:127 #: documents/serialisers.py:128
#, python-format #, python-format
msgid "Invalid regular expression: %(error)s" msgid "Invalid regular expression: %(error)s"
msgstr "" msgstr ""
#: documents/serialisers.py:553 #: documents/serialisers.py:554
msgid "Invalid color." msgid "Invalid color."
msgstr "" msgstr ""
#: documents/serialisers.py:1554 #: documents/serialisers.py:1570
#, python-format #, python-format
msgid "File type %(type)s not supported" msgid "File type %(type)s not supported"
msgstr "" msgstr ""
#: documents/serialisers.py:1643 #: documents/serialisers.py:1659
msgid "Invalid variable detected." msgid "Invalid variable detected."
msgstr "" msgstr ""
@ -1402,17 +1442,23 @@ msgstr ""
msgid "As a final step, please complete the following form:" msgid "As a final step, please complete the following form:"
msgstr "" msgstr ""
#: documents/validators.py:17 #: documents/validators.py:24
#, python-brace-format #, python-brace-format
msgid "Unable to parse URI {value}, missing scheme" msgid "Unable to parse URI {value}, missing scheme"
msgstr "" msgstr ""
#: documents/validators.py:22 #: documents/validators.py:29
#, python-brace-format #, python-brace-format
msgid "Unable to parse URI {value}, missing net location or path" msgid "Unable to parse URI {value}, missing net location or path"
msgstr "" 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 #, python-brace-format
msgid "Unable to parse URI {value}" msgid "Unable to parse URI {value}"
msgstr "" msgstr ""
@ -1701,7 +1747,7 @@ msgstr ""
msgid "Chinese Traditional" msgid "Chinese Traditional"
msgstr "" msgstr ""
#: paperless/urls.py:364 #: paperless/urls.py:369
msgid "Paperless-ngx administration" msgid "Paperless-ngx administration"
msgstr "" msgstr ""