]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Enhancement: system status report sanity check, simpler classifier check, styling...
authorshamoon <4887959+shamoon@users.noreply.github.com>
Wed, 26 Feb 2025 22:12:20 +0000 (14:12 -0800)
committerGitHub <noreply@github.com>
Wed, 26 Feb 2025 22:12:20 +0000 (22:12 +0000)
30 files changed:
src-ui/messages.xlf
src-ui/src/app/components/admin/settings/settings.component.spec.ts
src-ui/src/app/components/admin/settings/settings.component.ts
src-ui/src/app/components/admin/tasks/tasks.component.spec.ts
src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html
src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.scss
src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.spec.ts
src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.ts
src-ui/src/app/data/paperless-task.ts
src-ui/src/app/data/system-status.ts
src-ui/src/app/services/tasks.service.spec.ts
src-ui/src/app/services/tasks.service.ts
src-ui/src/main.ts
src-ui/src/theme.scss
src/documents/classifier.py
src/documents/filters.py
src/documents/management/commands/document_create_classifier.py
src/documents/management/commands/document_sanity_checker.py
src/documents/migrations/1063_paperlesstask_type_alter_paperlesstask_task_name_and_more.py [moved from src/documents/migrations/1063_alter_workflowactionwebhook_url_and_more.py with 50% similarity]
src/documents/models.py
src/documents/sanity_checker.py
src/documents/serialisers.py
src/documents/signals/handlers.py
src/documents/tasks.py
src/documents/tests/test_api_status.py
src/documents/tests/test_api_tasks.py
src/documents/tests/test_task_signals.py
src/documents/tests/test_tasks.py
src/documents/views.py
src/locale/en_US/LC_MESSAGES/django.po

index 12b0a09a6c0e5abefede3f8e276e4a961bddf0ae..7a35f94fe8345c722f4af2e6a5125a8378cac0e6 100644 (file)
         <source>Error retrieving users</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">217</context>
+          <context context-type="linenumber">220</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
         <source>Error retrieving groups</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">236</context>
+          <context context-type="linenumber">239</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
         <source>Settings were saved successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">532</context>
+          <context context-type="linenumber">535</context>
         </context-group>
       </trans-unit>
       <trans-unit id="525012668859298131" datatype="html">
         <source>Settings were saved successfully. Reload is required to apply some changes.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">536</context>
+          <context context-type="linenumber">539</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8491974984518503778" datatype="html">
         <source>Reload now</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">537</context>
+          <context context-type="linenumber">540</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3011185103048412841" datatype="html">
         <source>An error occurred while saving settings.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">547</context>
+          <context context-type="linenumber">550</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
           <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
           <context context-type="linenumber">111</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">175</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">209</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">243</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
           <context context-type="linenumber">30</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
-          <context context-type="linenumber">156</context>
+          <context context-type="linenumber">261</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
         <source>Migration Status</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
-          <context context-type="linenumber">56</context>
+          <context context-type="linenumber">65</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7489316373554112115" datatype="html">
         <source>Up to date</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
-          <context context-type="linenumber">59</context>
+          <context context-type="linenumber">69</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7881311375431899727" datatype="html">
         <source>Latest Migration</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
-          <context context-type="linenumber">64</context>
+          <context context-type="linenumber">74</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4632965004151576238" datatype="html">
         <source>Pending Migrations</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
-          <context context-type="linenumber">66</context>
+          <context context-type="linenumber">76</context>
         </context-group>
       </trans-unit>
-      <trans-unit id="6904866445262015585" datatype="html">
-        <source>Tasks</source>
+      <trans-unit id="2790343143501919450" datatype="html">
+        <source>Tasks Queue</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
-          <context context-type="linenumber">83</context>
+          <context context-type="linenumber">94</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6911698235105017958" datatype="html">
         <source>Redis Status</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
-          <context context-type="linenumber">87</context>
+          <context context-type="linenumber">98</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5349496739889768589" datatype="html">
         <source>Celery Status</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
-          <context context-type="linenumber">96</context>
+          <context context-type="linenumber">116</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2041675390931385838" datatype="html">
+        <source>Health</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">142</context>
         </context-group>
       </trans-unit>
       <trans-unit id="31377277941774469" datatype="html">
         <source>Search Index</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
-          <context context-type="linenumber">105</context>
+          <context context-type="linenumber">146</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="9127131074422113272" datatype="html">
+        <source>Run Task</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">166</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">200</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">234</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4089509911694721896" datatype="html">
         <source>Last Updated</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
-          <context context-type="linenumber">119</context>
+          <context context-type="linenumber">173</context>
         </context-group>
       </trans-unit>
       <trans-unit id="46628344485199198" datatype="html">
         <source>Classifier</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
-          <context context-type="linenumber">121</context>
+          <context context-type="linenumber">178</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6096684179126491743" datatype="html">
         <source>Last Trained</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
-          <context context-type="linenumber">139</context>
+          <context context-type="linenumber">207</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6427836860962380759" datatype="html">
+        <source>Sanity Checker</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">212</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6578747070254776938" datatype="html">
+        <source>Last Run</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">241</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6732151329960766506" datatype="html">
index 4f50e745319c9ba0c9ef6df64dd221e489a5b9a1..c6eeaf896ccef8048219c6efedd8af4afc23c263 100644 (file)
@@ -303,12 +303,17 @@ describe('SettingsComponent', () => {
         redis_error:
           'Error 61 connecting to localhost:6379. Connection refused.',
         celery_status: SystemStatusItemStatus.ERROR,
+        celery_url: 'celery@localhost',
+        celery_error: 'Error connecting to celery@localhost',
         index_status: SystemStatusItemStatus.OK,
         index_last_modified: new Date().toISOString(),
         index_error: null,
         classifier_status: SystemStatusItemStatus.OK,
         classifier_last_trained: new Date().toISOString(),
         classifier_error: null,
+        sanity_check_status: SystemStatusItemStatus.ERROR,
+        sanity_check_last_run: new Date().toISOString(),
+        sanity_check_error: 'Error running sanity check.',
       },
     }
     jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
@@ -320,6 +325,8 @@ describe('SettingsComponent', () => {
     component['systemStatus'].database.status = SystemStatusItemStatus.OK
     component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
     component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
+    component['systemStatus'].tasks.sanity_check_status =
+      SystemStatusItemStatus.OK
     expect(component.systemStatusHasErrors).toBeFalsy()
   })
 
index 68f702cfadd4c36ad815af19dae42c23f8ef19f5..8737be16036a9e2971defd56671e9d8e2a3bb368 100644 (file)
@@ -164,7 +164,10 @@ export class SettingsComponent
       this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
       this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
       this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
-      this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR
+      this.systemStatus.tasks.classifier_status ===
+        SystemStatusItemStatus.ERROR ||
+      this.systemStatus.tasks.sanity_check_status ===
+        SystemStatusItemStatus.ERROR
     )
   }
 
index a0a6f9ea0e5fd2d951d4268d4d01c19ae4f102dd..8158be7b272e8f494f25db43c70fc5944742b83f 100644 (file)
@@ -19,6 +19,7 @@ import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 import { routes } from 'src/app/app-routing.module'
 import {
   PaperlessTask,
+  PaperlessTaskName,
   PaperlessTaskStatus,
   PaperlessTaskType,
 } from 'src/app/data/paperless-task'
@@ -39,7 +40,8 @@ const tasks: PaperlessTask[] = [
     task_file_name: 'test.pdf',
     date_created: new Date('2023-03-01T10:26:03.093116Z'),
     date_done: new Date('2023-03-01T10:26:07.223048Z'),
-    type: PaperlessTaskType.File,
+    type: PaperlessTaskType.Auto,
+    task_name: PaperlessTaskName.ConsumeFile,
     status: PaperlessTaskStatus.Failed,
     result: 'test.pd: Not consuming test.pdf: It is a duplicate of test (#100)',
     acknowledged: false,
@@ -51,7 +53,8 @@ const tasks: PaperlessTask[] = [
     task_file_name: '191092.pdf',
     date_created: new Date('2023-03-01T09:26:03.093116Z'),
     date_done: new Date('2023-03-01T09:26:07.223048Z'),
-    type: PaperlessTaskType.File,
+    type: PaperlessTaskType.Auto,
+    task_name: PaperlessTaskName.ConsumeFile,
     status: PaperlessTaskStatus.Failed,
     result:
       '191092.pd: Not consuming 191092.pdf: It is a duplicate of 191092 (#311)',
@@ -64,7 +67,8 @@ const tasks: PaperlessTask[] = [
     task_file_name: 'Scan Jun 6, 2023 at 3.19 PM.pdf',
     date_created: new Date('2023-06-06T15:22:05.722323-07:00'),
     date_done: new Date('2023-06-06T15:22:14.564305-07:00'),
-    type: PaperlessTaskType.File,
+    type: PaperlessTaskType.Auto,
+    task_name: PaperlessTaskName.ConsumeFile,
     status: PaperlessTaskStatus.Pending,
     result: null,
     acknowledged: false,
@@ -76,7 +80,8 @@ const tasks: PaperlessTask[] = [
     task_file_name: 'paperless-mail-l4dkg8ir',
     date_created: new Date('2023-06-04T11:24:32.898089-07:00'),
     date_done: new Date('2023-06-04T11:24:44.678605-07:00'),
-    type: PaperlessTaskType.File,
+    type: PaperlessTaskType.Auto,
+    task_name: PaperlessTaskName.ConsumeFile,
     status: PaperlessTaskStatus.Complete,
     result: 'Success. New document id 422 created',
     acknowledged: false,
@@ -88,7 +93,8 @@ const tasks: PaperlessTask[] = [
     task_file_name: 'onlinePaymentSummary.pdf',
     date_created: new Date('2023-06-01T13:49:51.631305-07:00'),
     date_done: new Date('2023-06-01T13:49:54.190220-07:00'),
-    type: PaperlessTaskType.File,
+    type: PaperlessTaskType.Auto,
+    task_name: PaperlessTaskName.ConsumeFile,
     status: PaperlessTaskStatus.Complete,
     result: 'Success. New document id 421 created',
     acknowledged: false,
@@ -100,7 +106,8 @@ const tasks: PaperlessTask[] = [
     task_file_name: 'paperless-mail-_rrpmqk6',
     date_created: new Date('2023-06-07T02:54:35.694916Z'),
     date_done: null,
-    type: PaperlessTaskType.File,
+    type: PaperlessTaskType.Auto,
+    task_name: PaperlessTaskName.ConsumeFile,
     status: PaperlessTaskStatus.Started,
     result: null,
     acknowledged: false,
@@ -155,7 +162,9 @@ describe('TasksComponent', () => {
     jest.useFakeTimers()
     fixture.detectChanges()
     httpTestingController
-      .expectOne(`${environment.apiBaseUrl}tasks/`)
+      .expectOne(
+        `${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
+      )
       .flush(tasks)
   })
 
index 2dc934df455aad7a3e16fcd6bb2beec15a5ccc54..94c4ef22d8ad3a8999fefff952f9a172bd54d04f 100644 (file)
@@ -1,5 +1,5 @@
 <div class="modal-header">
-  <h5 class="modal-title" id="modal-basic-title" i18n>System Status</h5>
+  <h6 class="modal-title" id="modal-basic-title" i18n>System Status</h6>
   <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
 </div>
 <div class="modal-body">
       </div>
     </div>
   } @else {
-    <div class="row row-cols-1 row-cols-md-3 g-3">
+    <div class="row row-cols-1 row-cols-md-4 g-3">
       <div class="col">
         <div class="card bg-light h-100">
           <div class="card-header">
-            <h5 class="card-title mb-0" i18n>Environment</h5>
+            <h6 class="card-title mb-0" i18n>Environment</h6>
           </div>
           <div class="card-body">
             <dl class="card-text">
       <div class="col">
         <div class="card bg-light h-100">
           <div class="card-header">
-            <h5 class="card-title mb-0" i18n>Database</h5>
+            <h6 class="card-title mb-0" i18n>Database</h6>
           </div>
           <div class="card-body">
             <dl class="card-text">
               <dt i18n>Type</dt>
               <dd>{{status.database.type}}</dd>
               <dt i18n>Status</dt>
-              <dd class="d-flex align-items-center">
-                {{status.database.status}}
-                @if (status.database.status === 'OK') {
-                  <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.database.url}}" triggers="mouseenter:mouseleave"></i-bs>
-                } @else {
-                  <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.database.url}}: {{status.database.error}}" triggers="mouseenter:mouseleave"></i-bs>
-                }
+              <dd>
+                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="databaseStatus" triggers="click mouseenter:mouseleave">
+                  {{status.database.status}}
+                  @if (status.database.status === 'OK') {
+                    <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
+                  } @else {
+                    <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
+                  }
+                </button>
+                <ng-template #databaseStatus>
+                  @if (status.database.status === 'OK') {
+                    {{status.database.url}}
+                  } @else {
+                    {{status.database.url}}: {{status.database.error}}
+                  }
+                </ng-template>
               </dd>
               <dt i18n>Migration Status</dt>
-              <dd class="d-flex align-items-center">
-                @if (status.database.migration_status.unapplied_migrations.length === 0) {
-                  <ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
-                } @else {
-                  <ng-container>{{status.database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
-                }
-                <ng-template #migrationStatus>
-                  <h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span>
-                  @if (status.database.migration_status.unapplied_migrations.length > 0) {
-                    <h6 class="mt-3"><ng-container i18n>Pending Migrations</ng-container>:</h6>
-                    <ul>
-                      @for (migration of status.database.migration_status.unapplied_migrations; track migration) {
-                        <li class="font-monospace small">{{migration}}</li>
-                      }
-                    </ul>
+              <dd>
+                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="migrationStatus" triggers="click mouseenter:mouseleave">
+                  @if (status.database.migration_status.unapplied_migrations.length === 0) {
+                    <ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
+                  } @else {
+                    <ng-container>{{status.database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
                   }
-                </ng-template>
+                  <ng-template #migrationStatus>
+                    <h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span>
+                    @if (status.database.migration_status.unapplied_migrations.length > 0) {
+                      <h6 class="mt-3"><ng-container i18n>Pending Migrations</ng-container>:</h6>
+                      <ul>
+                        @for (migration of status.database.migration_status.unapplied_migrations; track migration) {
+                          <li class="font-monospace small">{{migration}}</li>
+                        }
+                      </ul>
+                    }
+                  </ng-template>
+                </button>
               </dd>
             </dl>
           </div>
       <div class="col">
         <div class="card bg-light h-100">
           <div class="card-header">
-            <h5 class="card-title mb-0" i18n>Tasks</h5>
+            <h6 class="card-title mb-0" i18n>Tasks Queue</h6>
           </div>
           <div class="card-body">
             <dl class="card-text">
               <dt i18n>Redis Status</dt>
-              <dd class="d-flex align-items-center">
-                {{status.tasks.redis_status}}
-                @if (status.tasks.redis_status === 'OK') {
-                  <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}" triggers="mouseenter:mouseleave"></i-bs>
-                } @else {
-                  <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}: {{status.tasks.redis_error}}" triggers="mouseenter:mouseleave"></i-bs>
-                }
+              <dd>
+                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="redisStatus" triggers="click mouseenter:mouseleave">
+                  {{status.tasks.redis_status}}
+                  @if (status.tasks.redis_status === 'OK') {
+                    <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
+                  } @else {
+                    <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
+                  }
+                </button>
+                <ng-template #redisStatus>
+                  @if (status.tasks.redis_status === 'OK') {
+                    {{status.tasks.redis_url}}
+                  } @else {
+                    {{status.tasks.redis_url}}: {{status.tasks.redis_error}}
+                  }
+                </ng-template>
               </dd>
               <dt i18n>Celery Status</dt>
-              <dd class="d-flex align-items-center">
-                {{status.tasks.celery_status}}
-                @if (status.tasks.celery_status === 'OK') {
-                  <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
-                } @else {
-                  <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
-                }
+              <dd>
+                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="celeryStatus" triggers="click mouseenter:mouseleave">
+                  {{status.tasks.celery_status}}
+                  @if (status.tasks.celery_status === 'OK') {
+                    <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
+                  } @else {
+                    <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
+                  }
+                </button>
+                <ng-template #celeryStatus>
+                  @if (status.tasks.celery_status === 'OK') {
+                    {{status.tasks.celery_url}}
+                  } @else {
+                    {{status.tasks.celery_error}}
+                  }
+                </ng-template>
               </dd>
+            </dl>
+          </div>
+        </div>
+      </div>
+
+      <div class="col">
+        <div class="card bg-light h-100">
+          <div class="card-header">
+            <h6 class="card-title mb-0" i18n>Health</h6>
+          </div>
+          <div class="card-body">
+            <dl class="card-text">
               <dt i18n>Search Index</dt>
               <dd class="d-flex align-items-center">
-                {{status.tasks.index_status}}
-                @if (status.tasks.index_status === 'OK') {
-                  @if (isStale(status.tasks.index_last_modified)) {
-                    <i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs>
+                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="indexStatus" triggers="click mouseenter:mouseleave">
+                  {{status.tasks.index_status}}
+                  @if (status.tasks.index_status === 'OK') {
+                    @if (isStale(status.tasks.index_last_modified)) {
+                      <i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
+                    } @else {
+                      <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
+                    }
                   } @else {
-                    <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs>
+                    <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
+                  }
+                </button>
+                @if (currentUserIsSuperUser) {
+                  @if (isRunning(PaperlessTaskName.IndexOptimize)) {
+                    <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
+                  } @else {
+                    <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.IndexOptimize)">
+                      <i-bs name="play-fill"></i-bs>&nbsp;
+                      <ng-container i18n>Run Task</ng-container>
+                    </button>
                   }
-                } @else {
-                  <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.index_error}}" triggers="mouseenter:mouseleave"></i-bs>
                 }
               </dd>
               <ng-template #indexStatus>
-                <h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_last_modified | customDate:'medium'}}</span>
+                @if (status.tasks.index_status === 'OK') {
+                  <h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_last_modified | customDate:'medium'}}</span>
+                } @else {
+                  <h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_error}}</span>
+                }
               </ng-template>
               <dt i18n>Classifier</dt>
               <dd class="d-flex align-items-center">
-                {{status.tasks.classifier_status}}
-                @if (status.tasks.classifier_status === 'OK') {
-                  @if (isStale(status.tasks.classifier_last_trained)) {
-                    <i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs>
+                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="classifierStatus" triggers="click mouseenter:mouseleave">
+                  {{status.tasks.classifier_status}}
+                  @if (status.tasks.classifier_status === 'OK') {
+                    @if (isStale(status.tasks.classifier_last_trained)) {
+                      <i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
+                    } @else {
+                      <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
+                    }
                   } @else {
-                    <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs>
-                  }
-                } @else {
                     <i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
                     [class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR"
-                    [class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"
-                    ngbPopover="{{status.tasks.classifier_error}}"
-                    triggers="mouseenter:mouseleave"></i-bs>
+                    [class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"></i-bs>
+                  }
+                </button>
+                @if (currentUserIsSuperUser) {
+                  @if (isRunning(PaperlessTaskName.TrainClassifier)) {
+                    <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
+                  } @else {
+                    <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.TrainClassifier)">
+                      <i-bs name="play-fill"></i-bs>&nbsp;
+                      <ng-container i18n>Run Task</ng-container>
+                    </button>
+                  }
                 }
               </dd>
               <ng-template #classifierStatus>
-                <h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_last_trained | customDate:'medium'}}</span>
+                @if (status.tasks.classifier_status === 'OK') {
+                  <h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_last_trained | customDate:'medium'}}</span>
+                } @else {
+                  <h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_error}}</span>
+                }
+              </ng-template>
+              <dt i18n>Sanity Checker</dt>
+              <dd class="d-flex align-items-center">
+                <button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="sanityCheckerStatus" triggers="click mouseenter:mouseleave">
+                  {{status.tasks.sanity_check_status}}
+                  @if (status.tasks.sanity_check_status === 'OK') {
+                    @if (isStale(status.tasks.sanity_check_last_run)) {
+                      <i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
+                    } @else {
+                      <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
+                    }
+                  } @else {
+                    <i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
+                    [class.text-danger]="status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR"
+                    [class.text-warning]="status.tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs>
+                  }
+                </button>
+                @if (currentUserIsSuperUser) {
+                  @if (isRunning(PaperlessTaskName.SanityCheck)) {
+                    <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
+                  } @else {
+                    <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.SanityCheck)">
+                      <i-bs name="play-fill"></i-bs>&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>
             </dl>
           </div>
   }
 </div>
 <div class="modal-footer">
-  <button class="btn btn-sm btn-outline-secondary" (click)="copy()">
+  <button class="btn btn-sm d-flex align-items-center btn-dark btn btn-sm d-flex align-items-center btn-dark btn-outline-secondary" (click)="copy()">
     @if (!copied) {
       <i-bs name="clipboard-fill"></i-bs>&nbsp;
     }
index cd076b18553935cd9048af3894f9cbfea240a728..28a0889ab72323cba7e19cecfceb805b74e20452 100644 (file)
@@ -9,11 +9,16 @@ import {
 } from '@angular/core/testing'
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { of, throwError } from 'rxjs'
+import { PaperlessTaskName } from 'src/app/data/paperless-task'
 import {
   InstallType,
   SystemStatus,
   SystemStatusItemStatus,
 } from 'src/app/data/system-status'
+import { SystemStatusService } from 'src/app/services/system-status.service'
+import { TasksService } from 'src/app/services/tasks.service'
+import { ToastService } from 'src/app/services/toast.service'
 import { SystemStatusDialogComponent } from './system-status-dialog.component'
 
 const status: SystemStatus = {
@@ -36,12 +41,17 @@ const status: SystemStatus = {
     redis_status: SystemStatusItemStatus.ERROR,
     redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
     celery_status: SystemStatusItemStatus.ERROR,
+    celery_url: 'celery@localhost',
+    celery_error: 'Error connecting to celery@localhost',
     index_status: SystemStatusItemStatus.OK,
     index_last_modified: new Date().toISOString(),
     index_error: null,
     classifier_status: SystemStatusItemStatus.OK,
     classifier_last_trained: new Date().toISOString(),
     classifier_error: null,
+    sanity_check_status: SystemStatusItemStatus.OK,
+    sanity_check_last_run: new Date().toISOString(),
+    sanity_check_error: null,
   },
 }
 
@@ -49,6 +59,9 @@ describe('SystemStatusDialogComponent', () => {
   let component: SystemStatusDialogComponent
   let fixture: ComponentFixture<SystemStatusDialogComponent>
   let clipboard: Clipboard
+  let tasksService: TasksService
+  let systemStatusService: SystemStatusService
+  let toastService: ToastService
 
   beforeEach(async () => {
     await TestBed.configureTestingModule({
@@ -67,6 +80,9 @@ describe('SystemStatusDialogComponent', () => {
     component = fixture.componentInstance
     component.status = status
     clipboard = TestBed.inject(Clipboard)
+    tasksService = TestBed.inject(TasksService)
+    systemStatusService = TestBed.inject(SystemStatusService)
+    toastService = TestBed.inject(ToastService)
     fixture.detectChanges()
   })
 
@@ -93,4 +109,37 @@ describe('SystemStatusDialogComponent', () => {
     expect(component.isStale(date.toISOString())).toBeTruthy()
     expect(component.isStale(date.toISOString(), 26)).toBeFalsy()
   })
+
+  it('should check if task is running', () => {
+    component.runTask(PaperlessTaskName.IndexOptimize)
+    expect(component.isRunning(PaperlessTaskName.IndexOptimize)).toBeTruthy()
+    expect(component.isRunning(PaperlessTaskName.SanityCheck)).toBeFalsy()
+  })
+
+  it('should support running tasks, refresh status and show toasts', () => {
+    const toastSpy = jest.spyOn(toastService, 'showInfo')
+    const toastErrorSpy = jest.spyOn(toastService, 'showError')
+    const getStatusSpy = jest.spyOn(systemStatusService, 'get')
+    const runSpy = jest.spyOn(tasksService, 'run')
+
+    // fail first
+    runSpy.mockReturnValue(throwError(() => new Error('error')))
+    component.runTask(PaperlessTaskName.IndexOptimize)
+    expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize)
+    expect(toastErrorSpy).toHaveBeenCalledWith(
+      `Failed to start task ${PaperlessTaskName.IndexOptimize}, see the logs for more details`,
+      expect.any(Error)
+    )
+
+    // succeed
+    runSpy.mockReturnValue(of({}))
+    getStatusSpy.mockReturnValue(of(status))
+    component.runTask(PaperlessTaskName.IndexOptimize)
+    expect(runSpy).toHaveBeenCalledWith(PaperlessTaskName.IndexOptimize)
+
+    expect(getStatusSpy).toHaveBeenCalled()
+    expect(toastSpy).toHaveBeenCalledWith(
+      `Task ${PaperlessTaskName.IndexOptimize} started`
+    )
+  })
 })
index 710d01dcf694c01c0ab287028cab85e008eca8fb..c7ba3c57aad78b6aaca3fa732615ac7f881f0b8c 100644 (file)
@@ -7,12 +7,17 @@ import {
   NgbProgressbarModule,
 } from '@ng-bootstrap/ng-bootstrap'
 import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { PaperlessTaskName } from 'src/app/data/paperless-task'
 import {
   SystemStatus,
   SystemStatusItemStatus,
 } from 'src/app/data/system-status'
 import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
 import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { SystemStatusService } from 'src/app/services/system-status.service'
+import { TasksService } from 'src/app/services/tasks.service'
+import { ToastService } from 'src/app/services/toast.service'
 
 @Component({
   selector: 'pngx-system-status-dialog',
@@ -30,13 +35,24 @@ import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
 })
 export class SystemStatusDialogComponent {
   public SystemStatusItemStatus = SystemStatusItemStatus
+  public PaperlessTaskName = PaperlessTaskName
   public status: SystemStatus
 
   public copied: boolean = false
 
+  private runningTasks: Set<PaperlessTaskName> = new Set()
+
+  get currentUserIsSuperUser(): boolean {
+    return this.permissionsService.isSuperUser()
+  }
+
   constructor(
     public activeModal: NgbActiveModal,
-    private clipboard: Clipboard
+    private clipboard: Clipboard,
+    private systemStatusService: SystemStatusService,
+    private tasksService: TasksService,
+    private toastService: ToastService,
+    private permissionsService: PermissionsService
   ) {}
 
   public close() {
@@ -56,4 +72,30 @@ export class SystemStatusDialogComponent {
     const now = new Date()
     return now.getTime() - date.getTime() > hours * 60 * 60 * 1000
   }
+
+  public isRunning(taskName: PaperlessTaskName): boolean {
+    return this.runningTasks.has(taskName)
+  }
+
+  public runTask(taskName: PaperlessTaskName) {
+    this.runningTasks.add(taskName)
+    this.toastService.showInfo(`Task ${taskName} started`)
+    this.tasksService.run(taskName).subscribe({
+      next: () => {
+        this.runningTasks.delete(taskName)
+        this.systemStatusService.get().subscribe({
+          next: (status) => {
+            this.status = status
+          },
+        })
+      },
+      error: (err) => {
+        this.runningTasks.delete(taskName)
+        this.toastService.showError(
+          `Failed to start task ${taskName}, see the logs for more details`,
+          err
+        )
+      },
+    })
+  }
 }
index d15f006d78fd625d0650d23cb5f32b4887a4f3ec..1bec277eb09d372d2d14289fb9214d538820a286 100644 (file)
@@ -1,8 +1,16 @@
 import { ObjectWithId } from './object-with-id'
 
 export enum PaperlessTaskType {
-  // just file tasks, for now
-  File = 'file',
+  Auto = 'auto_task',
+  ScheduledTask = 'scheduled_task',
+  ManualTask = 'manual_task',
+}
+
+export enum PaperlessTaskName {
+  ConsumeFile = 'consume_file',
+  TrainClassifier = 'train_classifier',
+  SanityCheck = 'check_sanity',
+  IndexOptimize = 'index_optimize',
 }
 
 export enum PaperlessTaskStatus {
@@ -23,6 +31,8 @@ export interface PaperlessTask extends ObjectWithId {
 
   task_file_name: string
 
+  task_name: PaperlessTaskName
+
   date_created: Date
 
   date_done?: Date
index a8f4ca621d83a7f140caa0abf59ff405fbecb7f0..698382154e6d3425abe923c15374c784528ccdae 100644 (file)
@@ -32,11 +32,16 @@ export interface SystemStatus {
     redis_status: SystemStatusItemStatus
     redis_error: string
     celery_status: SystemStatusItemStatus
+    celery_url: string
+    celery_error: string
     index_status: SystemStatusItemStatus
     index_last_modified: string // ISO date string
     index_error: string
     classifier_status: SystemStatusItemStatus
     classifier_last_trained: string // ISO date string
     classifier_error: string
+    sanity_check_status: SystemStatusItemStatus
+    sanity_check_last_run: string // ISO date string
+    sanity_check_error: string
   }
 }
index fa84c9a1928bb2710859280639bcb2d38a8f2240..0d4c8ee010a385953117f4957d9ed9554238667b 100644 (file)
@@ -5,7 +5,11 @@ import {
 } from '@angular/common/http/testing'
 import { TestBed } from '@angular/core/testing'
 import { environment } from 'src/environments/environment'
-import { PaperlessTaskStatus, PaperlessTaskType } from '../data/paperless-task'
+import {
+  PaperlessTaskName,
+  PaperlessTaskStatus,
+  PaperlessTaskType,
+} from '../data/paperless-task'
 import { TasksService } from './tasks.service'
 
 describe('TasksService', () => {
@@ -33,7 +37,7 @@ describe('TasksService', () => {
   it('calls tasks api endpoint on reload', () => {
     tasksService.reload()
     const req = httpTestingController.expectOne(
-      `${environment.apiBaseUrl}tasks/`
+      `${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
     )
     expect(req.request.method).toEqual('GET')
   })
@@ -41,7 +45,9 @@ describe('TasksService', () => {
   it('does not call tasks api endpoint on reload if already loading', () => {
     tasksService.loading = true
     tasksService.reload()
-    httpTestingController.expectNone(`${environment.apiBaseUrl}tasks/`)
+    httpTestingController.expectNone(
+      `${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
+    )
   })
 
   it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
@@ -55,14 +61,19 @@ describe('TasksService', () => {
     })
     req.flush([])
     // reload is then called
-    httpTestingController.expectOne(`${environment.apiBaseUrl}tasks/`).flush([])
+    httpTestingController
+      .expectOne(
+        `${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
+      )
+      .flush([])
   })
 
   it('sorts tasks returned from api', () => {
     expect(tasksService.total).toEqual(0)
     const mockTasks = [
       {
-        type: PaperlessTaskType.File,
+        type: PaperlessTaskType.Auto,
+        task_name: PaperlessTaskName.ConsumeFile,
         status: PaperlessTaskStatus.Complete,
         acknowledged: false,
         task_id: '1234',
@@ -70,7 +81,8 @@ describe('TasksService', () => {
         date_created: new Date(),
       },
       {
-        type: PaperlessTaskType.File,
+        type: PaperlessTaskType.Auto,
+        task_name: PaperlessTaskName.ConsumeFile,
         status: PaperlessTaskStatus.Failed,
         acknowledged: false,
         task_id: '1235',
@@ -78,7 +90,8 @@ describe('TasksService', () => {
         date_created: new Date(),
       },
       {
-        type: PaperlessTaskType.File,
+        type: PaperlessTaskType.Auto,
+        task_name: PaperlessTaskName.ConsumeFile,
         status: PaperlessTaskStatus.Pending,
         acknowledged: false,
         task_id: '1236',
@@ -86,7 +99,8 @@ describe('TasksService', () => {
         date_created: new Date(),
       },
       {
-        type: PaperlessTaskType.File,
+        type: PaperlessTaskType.Auto,
+        task_name: PaperlessTaskName.ConsumeFile,
         status: PaperlessTaskStatus.Started,
         acknowledged: false,
         task_id: '1237',
@@ -94,7 +108,8 @@ describe('TasksService', () => {
         date_created: new Date(),
       },
       {
-        type: PaperlessTaskType.File,
+        type: PaperlessTaskType.Auto,
+        task_name: PaperlessTaskName.ConsumeFile,
         status: PaperlessTaskStatus.Complete,
         acknowledged: false,
         task_id: '1238',
@@ -106,7 +121,7 @@ describe('TasksService', () => {
     tasksService.reload()
 
     const req = httpTestingController.expectOne(
-      `${environment.apiBaseUrl}tasks/`
+      `${environment.apiBaseUrl}tasks/?task_name=consume_file&acknowledged=false`
     )
 
     req.flush(mockTasks)
@@ -117,4 +132,19 @@ describe('TasksService', () => {
     expect(tasksService.queuedFileTasks).toHaveLength(1)
     expect(tasksService.startedFileTasks).toHaveLength(1)
   })
+
+  it('supports running tasks', () => {
+    tasksService.run(PaperlessTaskName.SanityCheck).subscribe((res) => {
+      expect(res).toEqual({
+        result: 'success',
+      })
+    })
+    const req = httpTestingController.expectOne(
+      `${environment.apiBaseUrl}tasks/run/`
+    )
+    expect(req.request.method).toEqual('POST')
+    req.flush({
+      result: 'success',
+    })
+  })
 })
index c3c8f1d2b8ee94630bd25fe5c4265b5ec9c45ed0..3ecfffe386e27270f400e566d2c8c3c0b8025706 100644 (file)
@@ -1,11 +1,11 @@
 import { HttpClient } from '@angular/common/http'
 import { Injectable } from '@angular/core'
-import { Subject } from 'rxjs'
+import { Observable, Subject } from 'rxjs'
 import { first, takeUntil } from 'rxjs/operators'
 import {
   PaperlessTask,
+  PaperlessTaskName,
   PaperlessTaskStatus,
-  PaperlessTaskType,
 } from 'src/app/data/paperless-task'
 import { environment } from 'src/environments/environment'
 
@@ -14,6 +14,7 @@ import { environment } from 'src/environments/environment'
 })
 export class TasksService {
   private baseUrl: string = environment.apiBaseUrl
+  private endpoint: string = 'tasks'
 
   public loading: boolean
 
@@ -54,10 +55,14 @@ export class TasksService {
     this.loading = true
 
     this.http
-      .get<PaperlessTask[]>(`${this.baseUrl}tasks/`)
+      .get<PaperlessTask[]>(
+        `${this.baseUrl}${this.endpoint}/?task_name=consume_file&acknowledged=false`
+      )
       .pipe(takeUntil(this.unsubscribeNotifer), first())
       .subscribe((r) => {
-        this.fileTasks = r.filter((t) => t.type == PaperlessTaskType.File) // they're all File tasks, for now
+        this.fileTasks = r.filter(
+          (t) => t.task_name == PaperlessTaskName.ConsumeFile
+        )
         this.loading = false
       })
   }
@@ -76,4 +81,13 @@ export class TasksService {
   public cancelPending(): void {
     this.unsubscribeNotifer.next(true)
   }
+
+  public run(taskName: PaperlessTaskName): Observable<any> {
+    return this.http.post<any>(
+      `${environment.apiBaseUrl}${this.endpoint}/run/`,
+      {
+        task_name: taskName,
+      }
+    )
+  }
 }
index dd31a6b1eaca4bf980383c552396ccc0fbf30031..eb34b94b4906fdba1ac480ae3ea4ecf0f8aa473e 100644 (file)
@@ -107,6 +107,7 @@ import {
   personFillLock,
   personLock,
   personSquare,
+  playFill,
   plus,
   plusCircle,
   questionCircle,
@@ -312,6 +313,7 @@ const icons = {
   personFillLock,
   personLock,
   personSquare,
+  playFill,
   plus,
   plusCircle,
   questionCircle,
index b60b70a0e229dc4ba617f062a9904b463f10ba49..d7cfa56283e77a24af00ff127472569009a196c2 100644 (file)
   --pngx-success-darken-10: hsl(152, 69%, 11%); // based on success #198754
   --pngx-bg-alt: #fff;
   --pngx-bg-darker: var(--bs-gray-100);
-  --pngx-bg-alt2: var(--bs-gray-200);
+  --pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
   --pngx-bg-disabled: #f7f7f7;
   --pngx-focus-alpha: 0.3;
   --pngx-toast-max-width: 360px;
+  --bs-info: var(--pngx-bg-alt2);
+  --bs-info-rgb: 233, 236, 239;
   @media screen and (min-width: 1024px) {
     --pngx-toast-max-width: 450px;
   }
@@ -71,8 +73,15 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
 }
 
 @mixin dark-mode {
-  --bs-body-color: #{$text-color-dark-bg};
   --pngx-body-color-accent: #{$text-color-dark-bg-accent};
+  --pngx-bg-alt: #242529;
+  --pngx-bg-alt2: #232323;
+  --pngx-bg-darker: #101216;
+  --pngx-bg-disabled: var(--pngx-bg-alt);
+  --pngx-focus-alpha: 0.6;
+  --pngx-primary-faded: var(--pngx-primary-darken-15);
+  --pngx-primary-text-contrast: var(--bs-body-color);
+  --bs-body-color: #{$text-color-dark-bg};
   --bs-secondary-color: #6c757d;
   --bs-danger: #b71631;
   --bs-danger-rgb: 183, 22, 49;
@@ -80,15 +89,10 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
   --bs-body-bg-rgb: 22, 22, 24;
   --bs-light: #1c1c1f;
   --bs-light-rgb: 28, 28, 31;
+  --bs-info: var(--pngx-bg-alt);
+  --bs-info-rgb: 36, 36, 39;
   --bs-border-color: #47494f;
-  --pngx-bg-alt2: #232323;
-  --pngx-bg-darker: #101216;
   --bs-tertiary-bg: var(--pngx-bg-darker);
-  --pngx-bg-alt: #242529;
-  --pngx-bg-disabled: var(--pngx-bg-alt);
-  --pngx-focus-alpha: 0.6;
-  --pngx-primary-faded: var(--pngx-primary-darken-15);
-  --pngx-primary-text-contrast: var(--bs-body-color);
   --bs-dark-border-subtle: var(--pngx-bg-darker);
   --bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs
 
index 548a4e83359096220becdd12db8335811a55dd47..728c8322898377c1b319ff6d6aa0d5258dd52720 100644 (file)
@@ -3,7 +3,6 @@ from __future__ import annotations
 import logging
 import pickle
 import re
-import time
 import warnings
 from hashlib import sha256
 from pathlib import Path
@@ -144,19 +143,6 @@ class DocumentClassifier:
                 ):
                     raise IncompatibleClassifierVersionError("sklearn version update")
 
-    def set_last_checked(self) -> None:
-        # save a timestamp of the last time we checked for retraining to a file
-        with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("w") as f:
-            f.write(str(time.time()))
-
-    def get_last_checked(self) -> float | None:
-        # load the timestamp of the last time we checked for retraining
-        try:
-            with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("r") as f:
-                return float(f.read())
-        except FileNotFoundError:  # pragma: no cover
-            return None
-
     def save(self) -> None:
         target_file: Path = settings.MODEL_FILE
         target_file_temp: Path = target_file.with_suffix(".pickle.part")
@@ -177,7 +163,6 @@ class DocumentClassifier:
             pickle.dump(self.storage_path_classifier, f)
 
         target_file_temp.rename(target_file)
-        self.set_last_checked()
 
     def train(self) -> bool:
         # Get non-inbox documents
@@ -246,7 +231,6 @@ class DocumentClassifier:
             and self.last_doc_change_time >= latest_doc_change
         ) and self.last_auto_type_hash == hasher.digest():
             logger.info("No updates since last training")
-            self.set_last_checked()
             # Set the classifier information into the cache
             # Caching for 50 minutes, so slightly less than the normal retrain time
             cache.set(
index b63da50e62b56249fa74d021034624b580328f92..d3b0ad3ceb65bed6257230df84dd45c22fd28cf8 100644 (file)
@@ -37,6 +37,7 @@ from documents.models import CustomFieldInstance
 from documents.models import Document
 from documents.models import DocumentType
 from documents.models import Log
+from documents.models import PaperlessTask
 from documents.models import ShareLink
 from documents.models import StoragePath
 from documents.models import Tag
@@ -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):
     """
     A filter backend that limits results to those where the requesting user
index f5df51aacc4a549757c767645eb1184ecc0d0359..f7903aac77f916e57f529de4513cc1f2b95d689e 100644 (file)
@@ -10,4 +10,4 @@ class Command(BaseCommand):
     )
 
     def handle(self, *args, **options):
-        train_classifier()
+        train_classifier(scheduled=False)
index 095781a9dbc0fb92ce9262e16e90d9731c394442..b634d4dc9ab5ae3a96f39db4c1f50f2b1530718c 100644 (file)
@@ -12,6 +12,6 @@ class Command(ProgressBarMixin, BaseCommand):
 
     def handle(self, *args, **options):
         self.handle_progress_bar_mixin(**options)
-        messages = check_sanity(progress=self.use_progress_bar)
+        messages = check_sanity(progress=self.use_progress_bar, scheduled=False)
 
         messages.log_messages()
similarity index 50%
rename from src/documents/migrations/1063_alter_workflowactionwebhook_url_and_more.py
rename to src/documents/migrations/1063_paperlesstask_type_alter_paperlesstask_task_name_and_more.py
index 16c1eeb63dad80703e93934653d9ec21bd93fd29..aeedbd6a02acea18f2d220b343eb51e6ca1352f7 100644 (file)
@@ -1,4 +1,4 @@
-# Generated by Django 5.1.6 on 2025-02-20 04:55
+# Generated by Django 5.1.6 on 2025-02-21 16:34
 
 import multiselectfield.db.fields
 from django.db import migrations
@@ -16,12 +16,52 @@ def update_workflow_sources(apps, schema_editor):
             trigger.save()
 
 
+def make_existing_tasks_consume_auto(apps, schema_editor):
+    PaperlessTask = apps.get_model("documents", "PaperlessTask")
+    PaperlessTask.objects.all().update(type="auto_task", task_name="consume_file")
+
+
 class Migration(migrations.Migration):
     dependencies = [
         ("documents", "1062_alter_savedviewfilterrule_rule_type"),
     ]
 
     operations = [
+        migrations.AddField(
+            model_name="paperlesstask",
+            name="type",
+            field=models.CharField(
+                choices=[
+                    ("auto_task", "Auto Task"),
+                    ("scheduled_task", "Scheduled Task"),
+                    ("manual_task", "Manual Task"),
+                ],
+                default="auto_task",
+                help_text="The type of task that was run",
+                max_length=30,
+                verbose_name="Task Type",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="paperlesstask",
+            name="task_name",
+            field=models.CharField(
+                choices=[
+                    ("consume_file", "Consume File"),
+                    ("train_classifier", "Train Classifier"),
+                    ("check_sanity", "Check Sanity"),
+                    ("index_optimize", "Index Optimize"),
+                ],
+                help_text="Name of the task that was run",
+                max_length=255,
+                null=True,
+                verbose_name="Task Name",
+            ),
+        ),
+        migrations.RunPython(
+            code=make_existing_tasks_consume_auto,
+            reverse_code=migrations.RunPython.noop,
+        ),
         migrations.AlterField(
             model_name="workflowactionwebhook",
             name="url",
index b23bd8045f72ec6c5739c1e91999d15b6379f112..42e47343814843f82f66fcd50f6fddae4054ffe9 100644 (file)
@@ -650,6 +650,17 @@ class PaperlessTask(ModelWithOwner):
     ALL_STATES = sorted(states.ALL_STATES)
     TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
 
+    class TaskType(models.TextChoices):
+        AUTO = ("auto_task", _("Auto Task"))
+        SCHEDULED_TASK = ("scheduled_task", _("Scheduled Task"))
+        MANUAL_TASK = ("manual_task", _("Manual Task"))
+
+    class TaskName(models.TextChoices):
+        CONSUME_FILE = ("consume_file", _("Consume File"))
+        TRAIN_CLASSIFIER = ("train_classifier", _("Train Classifier"))
+        CHECK_SANITY = ("check_sanity", _("Check Sanity"))
+        INDEX_OPTIMIZE = ("index_optimize", _("Index Optimize"))
+
     task_id = models.CharField(
         max_length=255,
         unique=True,
@@ -673,8 +684,9 @@ class PaperlessTask(ModelWithOwner):
     task_name = models.CharField(
         null=True,
         max_length=255,
+        choices=TaskName.choices,
         verbose_name=_("Task Name"),
-        help_text=_("Name of the Task which was run"),
+        help_text=_("Name of the task that was run"),
     )
 
     status = models.CharField(
@@ -684,24 +696,28 @@ class PaperlessTask(ModelWithOwner):
         verbose_name=_("Task State"),
         help_text=_("Current state of the task being run"),
     )
+
     date_created = models.DateTimeField(
         null=True,
         default=timezone.now,
         verbose_name=_("Created DateTime"),
         help_text=_("Datetime field when the task result was created in UTC"),
     )
+
     date_started = models.DateTimeField(
         null=True,
         default=None,
         verbose_name=_("Started DateTime"),
         help_text=_("Datetime field when the task was started in UTC"),
     )
+
     date_done = models.DateTimeField(
         null=True,
         default=None,
         verbose_name=_("Completed DateTime"),
         help_text=_("Datetime field when the task was completed in UTC"),
     )
+
     result = models.TextField(
         null=True,
         default=None,
@@ -711,6 +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:
         return f"Task {self.task_id}"
 
index 28d2024e7238fec9999f1dbfb07f068d50236c95..6cef98f1a3ea3a8fb001baa9105e944b0707034a 100644 (file)
@@ -1,13 +1,17 @@
 import hashlib
 import logging
+import uuid
 from collections import defaultdict
 from pathlib import Path
 from typing import Final
 
+from celery import states
 from django.conf import settings
+from django.utils import timezone
 from tqdm import tqdm
 
 from documents.models import Document
+from documents.models import PaperlessTask
 
 
 class SanityCheckMessages:
@@ -57,7 +61,17 @@ class SanityCheckFailedException(Exception):
     pass
 
 
-def check_sanity(*, progress=False) -> SanityCheckMessages:
+def check_sanity(*, progress=False, scheduled=True) -> SanityCheckMessages:
+    paperless_task = PaperlessTask.objects.create(
+        task_id=uuid.uuid4(),
+        type=PaperlessTask.TaskType.SCHEDULED_TASK
+        if scheduled
+        else PaperlessTask.TaskType.MANUAL_TASK,
+        task_name=PaperlessTask.TaskName.CHECK_SANITY,
+        status=states.STARTED,
+        date_created=timezone.now(),
+        date_started=timezone.now(),
+    )
     messages = SanityCheckMessages()
 
     present_files = {
@@ -142,4 +156,11 @@ def check_sanity(*, progress=False) -> SanityCheckMessages:
     for extra_file in present_files:
         messages.warning(None, f"Orphaned file in media dir: {extra_file}")
 
+    paperless_task.status = states.SUCCESS if not messages.has_error else states.FAILURE
+    # result is concatenated messages
+    paperless_task.result = f"{len(messages)} issues found."
+    if messages.has_error:
+        paperless_task.result += " Check logs for details."
+    paperless_task.date_done = timezone.now()
+    paperless_task.save(update_fields=["status", "result", "date_done"])
     return messages
index a486fe24153f7bb52aa902946bec13a42d0e1101..c0487b7b85351fe869bdbe844326d18c0016d06f 100644 (file)
@@ -1710,6 +1710,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
         fields = (
             "id",
             "task_id",
+            "task_name",
             "task_file_name",
             "date_created",
             "date_done",
@@ -1721,12 +1722,6 @@ class TasksViewSerializer(OwnedObjectSerializer):
             "owner",
         )
 
-    type = serializers.SerializerMethodField()
-
-    def get_type(self, obj) -> str:
-        # just file tasks, for now
-        return "file"
-
     related_document = serializers.SerializerMethodField()
     created_doc_re = re.compile(r"New document id (\d+) created")
     duplicate_doc_re = re.compile(r"It is a duplicate of .* \(#(\d+)\)")
@@ -1734,24 +1729,33 @@ class TasksViewSerializer(OwnedObjectSerializer):
     def get_related_document(self, obj) -> str | None:
         result = None
         re = None
-        match obj.status:
-            case states.SUCCESS:
-                re = self.created_doc_re
-            case states.FAILURE:
-                re = (
-                    self.duplicate_doc_re
-                    if "existing document is in the trash" not in obj.result
-                    else None
-                )
-        if re is not None:
-            try:
-                result = re.search(obj.result).group(1)
-            except Exception:
-                pass
+        if obj.result:
+            match obj.status:
+                case states.SUCCESS:
+                    re = self.created_doc_re
+                case states.FAILURE:
+                    re = (
+                        self.duplicate_doc_re
+                        if "existing document is in the trash" not in obj.result
+                        else None
+                    )
+            if re is not None:
+                try:
+                    result = re.search(obj.result).group(1)
+                except Exception:
+                    pass
 
         return result
 
 
+class RunTaskViewSerializer(serializers.Serializer):
+    task_name = serializers.ChoiceField(
+        choices=PaperlessTask.TaskName.choices,
+        label="Task Name",
+        write_only=True,
+    )
+
+
 class AcknowledgeTasksViewSerializer(serializers.Serializer):
     tasks = serializers.ListField(
         required=True,
index b3f029da7dfc0ded20e5f0f414746941a7913b72..78d0043b5eeeffc7b7381db573829b885c607eee 100644 (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
 
         PaperlessTask.objects.create(
+            type=PaperlessTask.TaskType.AUTO,
             task_id=headers["id"],
             status=states.PENDING,
             task_file_name=task_file_name,
-            task_name=headers["task"],
+            task_name=PaperlessTask.TaskName.CONSUME_FILE,
             result=None,
             date_created=timezone.now(),
             date_started=None,
index 052def80f647db0e80ab4858c038c64ea441fa97..8a504d28d9442e0ad7e0c4fb9c561581d1c1652d 100644 (file)
@@ -9,6 +9,7 @@ from tempfile import TemporaryDirectory
 import tqdm
 from celery import Task
 from celery import shared_task
+from celery import states
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.db import models
@@ -35,6 +36,7 @@ from documents.models import Correspondent
 from documents.models import CustomFieldInstance
 from documents.models import Document
 from documents.models import DocumentType
+from documents.models import PaperlessTask
 from documents.models import StoragePath
 from documents.models import Tag
 from documents.models import Workflow
@@ -74,19 +76,34 @@ def index_reindex(*, progress_bar_disable=False):
 
 
 @shared_task
-def train_classifier():
+def train_classifier(*, scheduled=True):
+    task = PaperlessTask.objects.create(
+        type=PaperlessTask.TaskType.SCHEDULED_TASK
+        if scheduled
+        else PaperlessTask.TaskType.MANUAL_TASK,
+        task_id=uuid.uuid4(),
+        task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
+        status=states.STARTED,
+        date_created=timezone.now(),
+        date_started=timezone.now(),
+    )
     if (
         not Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
         and not DocumentType.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
         and not Correspondent.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
         and not StoragePath.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
     ):
-        logger.info("No automatic matching items, not training")
+        result = "No automatic matching items, not training"
+        logger.info(result)
         # Special case, items were once auto and trained, so remove the model
         # and prevent its use again
         if settings.MODEL_FILE.exists():
             logger.info(f"Removing {settings.MODEL_FILE} so it won't be used")
             settings.MODEL_FILE.unlink()
+        task.status = states.SUCCESS
+        task.result = result
+        task.date_done = timezone.now()
+        task.save()
         return
 
     classifier = load_classifier()
@@ -100,11 +117,19 @@ def train_classifier():
                 f"Saving updated classifier model to {settings.MODEL_FILE}...",
             )
             classifier.save()
+            task.result = "Training completed successfully"
         else:
             logger.debug("Training data unchanged.")
+            task.result = "Training data unchanged"
+
+        task.status = states.SUCCESS
+        task.date_done = timezone.now()
+        task.save(update_fields=["status", "result", "date_done"])
 
     except Exception as e:
         logger.warning("Classifier error: " + str(e))
+        task.status = states.FAILURE
+        task.result = str(e)
 
 
 @shared_task(bind=True)
@@ -176,13 +201,16 @@ def consume_file(
 
 
 @shared_task
-def sanity_check():
-    messages = sanity_checker.check_sanity()
+def sanity_check(*, scheduled=True, raise_on_error=True):
+    messages = sanity_checker.check_sanity(scheduled=scheduled)
 
     messages.log_messages()
 
     if messages.has_error:
-        raise SanityCheckFailedException("Sanity check failed with errors. See log.")
+        message = "Sanity check exited with errors. See log."
+        if raise_on_error:
+            raise SanityCheckFailedException(message)
+        return message
     elif messages.has_warning:
         return "Sanity check exited with warnings. See log."
     elif len(messages) > 0:
index 89bc5ef8c5c75b3b5588cad10e55db50580d7504..9b7bf37adabb880201a60f146f833e220f7f4fd3 100644 (file)
@@ -1,18 +1,14 @@
 import os
-import tempfile
 from pathlib import Path
 from unittest import mock
 
+from celery import states
 from django.contrib.auth.models import User
 from django.test import override_settings
 from rest_framework import status
 from rest_framework.test import APITestCase
 
-from documents.classifier import ClassifierModelCorruptError
-from documents.classifier import DocumentClassifier
-from documents.classifier import load_classifier
-from documents.models import Document
-from documents.models import Tag
+from documents.models import PaperlessTask
 from paperless import version
 
 
@@ -193,7 +189,6 @@ class TestSystemStatus(APITestCase):
         self.assertEqual(response.data["tasks"]["index_status"], "ERROR")
         self.assertIsNotNone(response.data["tasks"]["index_error"])
 
-    @override_settings(DATA_DIR=Path("/tmp/does_not_exist/data/"))
     def test_system_status_classifier_ok(self):
         """
         GIVEN:
@@ -203,9 +198,11 @@ class TestSystemStatus(APITestCase):
         THEN:
             - The response contains an OK classifier status
         """
-        load_classifier()
-        test_classifier = DocumentClassifier()
-        test_classifier.save()
+        PaperlessTask.objects.create(
+            type=PaperlessTask.TaskType.SCHEDULED_TASK,
+            status=states.SUCCESS,
+            task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
+        )
         self.client.force_login(self.user)
         response = self.client.get(self.ENDPOINT)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -215,73 +212,101 @@ class TestSystemStatus(APITestCase):
     def test_system_status_classifier_warning(self):
         """
         GIVEN:
-            - The classifier does not exist yet
-            - > 0 documents and tags with auto matching exist
+            - No classifier task is found
         WHEN:
             - The user requests the system status
         THEN:
-            - The response contains an WARNING classifier status
+            - The response contains a WARNING classifier status
         """
-        with override_settings(MODEL_FILE=Path("does_not_exist")):
-            Document.objects.create(
-                title="Test Document",
-            )
-            Tag.objects.create(name="Test Tag", matching_algorithm=Tag.MATCH_AUTO)
-            self.client.force_login(self.user)
-            response = self.client.get(self.ENDPOINT)
-            self.assertEqual(response.status_code, status.HTTP_200_OK)
-            self.assertEqual(response.data["tasks"]["classifier_status"], "WARNING")
-            self.assertIsNotNone(response.data["tasks"]["classifier_error"])
+        self.client.force_login(self.user)
+        response = self.client.get(self.ENDPOINT)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(
+            response.data["tasks"]["classifier_status"],
+            "WARNING",
+        )
 
-    @mock.patch(
-        "documents.classifier.load_classifier",
-        side_effect=ClassifierModelCorruptError(),
-    )
-    def test_system_status_classifier_error(self, mock_load_classifier):
+    def test_system_status_classifier_error(self):
         """
         GIVEN:
-            - The classifier does exist but is corrupt
-            - > 0 documents and tags with auto matching exist
+            - An error occurred while loading the classifier
         WHEN:
             - The user requests the system status
         THEN:
             - The response contains an ERROR classifier status
         """
-        with (
-            tempfile.NamedTemporaryFile(
-                dir="/tmp",
-                delete=False,
-            ) as does_exist,
-            override_settings(MODEL_FILE=Path(does_exist.name)),
-        ):
-            Document.objects.create(
-                title="Test Document",
-            )
-            Tag.objects.create(
-                name="Test Tag",
-                matching_algorithm=Tag.MATCH_AUTO,
-            )
-            self.client.force_login(self.user)
-            response = self.client.get(self.ENDPOINT)
-            self.assertEqual(response.status_code, status.HTTP_200_OK)
-            self.assertEqual(
-                response.data["tasks"]["classifier_status"],
-                "ERROR",
-            )
-            self.assertIsNotNone(response.data["tasks"]["classifier_error"])
+        PaperlessTask.objects.create(
+            type=PaperlessTask.TaskType.SCHEDULED_TASK,
+            status=states.FAILURE,
+            task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
+            result="Classifier training failed",
+        )
+        self.client.force_login(self.user)
+        response = self.client.get(self.ENDPOINT)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(
+            response.data["tasks"]["classifier_status"],
+            "ERROR",
+        )
+        self.assertIsNotNone(response.data["tasks"]["classifier_error"])
 
-    def test_system_status_classifier_ok_no_objects(self):
+    def test_system_status_sanity_check_ok(self):
         """
         GIVEN:
-            - The classifier does not exist (and should not)
-            - No documents nor objects with auto matching exist
+            - The sanity check is successful
         WHEN:
             - The user requests the system status
         THEN:
-            - The response contains an OK classifier status
+            - The response contains an OK sanity check status
         """
-        with override_settings(MODEL_FILE=Path("does_not_exist")):
-            self.client.force_login(self.user)
-            response = self.client.get(self.ENDPOINT)
-            self.assertEqual(response.status_code, status.HTTP_200_OK)
-            self.assertEqual(response.data["tasks"]["classifier_status"], "OK")
+        PaperlessTask.objects.create(
+            type=PaperlessTask.TaskType.SCHEDULED_TASK,
+            status=states.SUCCESS,
+            task_name=PaperlessTask.TaskName.CHECK_SANITY,
+        )
+        self.client.force_login(self.user)
+        response = self.client.get(self.ENDPOINT)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data["tasks"]["sanity_check_status"], "OK")
+        self.assertIsNone(response.data["tasks"]["sanity_check_error"])
+
+    def test_system_status_sanity_check_warning(self):
+        """
+        GIVEN:
+            - No sanity check task is found
+        WHEN:
+            - The user requests the system status
+        THEN:
+            - The response contains a WARNING sanity check status
+        """
+        self.client.force_login(self.user)
+        response = self.client.get(self.ENDPOINT)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(
+            response.data["tasks"]["sanity_check_status"],
+            "WARNING",
+        )
+
+    def test_system_status_sanity_check_error(self):
+        """
+        GIVEN:
+            - The sanity check failed
+        WHEN:
+            - The user requests the system status
+        THEN:
+            - The response contains an ERROR sanity check status
+        """
+        PaperlessTask.objects.create(
+            type=PaperlessTask.TaskType.SCHEDULED_TASK,
+            status=states.FAILURE,
+            task_name=PaperlessTask.TaskName.CHECK_SANITY,
+            result="5 issues found.",
+        )
+        self.client.force_login(self.user)
+        response = self.client.get(self.ENDPOINT)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(
+            response.data["tasks"]["sanity_check_status"],
+            "ERROR",
+        )
+        self.assertIsNotNone(response.data["tasks"]["sanity_check_error"])
index 922067930d7d3841d19e6baa3a08abd0cab78048..c139d05dacc0f9cdada9cbddf684cfe710f24f76 100644 (file)
@@ -1,4 +1,5 @@
 import uuid
+from unittest import mock
 
 import celery
 from django.contrib.auth.models import Permission
@@ -8,6 +9,7 @@ from rest_framework.test import APITestCase
 
 from documents.models import PaperlessTask
 from documents.tests.utils import DirectoriesMixin
+from documents.views import TasksViewSet
 
 
 class TestTasks(DirectoriesMixin, APITestCase):
@@ -130,7 +132,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
         )
         self.assertEqual(response.status_code, status.HTTP_200_OK)
 
-        response = self.client.get(self.ENDPOINT)
+        response = self.client.get(self.ENDPOINT + "?acknowledged=false")
         self.assertEqual(len(response.data), 0)
 
     def test_tasks_owner_aware(self):
@@ -246,7 +248,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
         PaperlessTask.objects.create(
             task_id=str(uuid.uuid4()),
             task_file_name="test.pdf",
-            task_name="documents.tasks.some_task",
+            task_name=PaperlessTask.TaskName.CONSUME_FILE,
             status=celery.states.SUCCESS,
         )
 
@@ -272,7 +274,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
         PaperlessTask.objects.create(
             task_id=str(uuid.uuid4()),
             task_file_name="anothertest.pdf",
-            task_name="documents.tasks.some_task",
+            task_name=PaperlessTask.TaskName.CONSUME_FILE,
             status=celery.states.SUCCESS,
         )
 
@@ -309,3 +311,62 @@ class TestTasks(DirectoriesMixin, APITestCase):
         returned_data = response.data[0]
 
         self.assertEqual(returned_data["related_document"], "1234")
+
+    def test_run_train_classifier_task(self):
+        """
+        GIVEN:
+            - A superuser
+        WHEN:
+            - API call is made to run the train classifier task
+        THEN:
+            - The task is run
+        """
+        mock_train_classifier = mock.Mock(return_value="Task started")
+        TasksViewSet.TASK_AND_ARGS_BY_NAME = {
+            PaperlessTask.TaskName.TRAIN_CLASSIFIER: (
+                mock_train_classifier,
+                {"scheduled": False},
+            ),
+        }
+        response = self.client.post(
+            self.ENDPOINT + "run/",
+            {"task_name": PaperlessTask.TaskName.TRAIN_CLASSIFIER},
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data, {"result": "Task started"})
+        mock_train_classifier.assert_called_once_with(scheduled=False)
+
+        # mock error
+        mock_train_classifier.reset_mock()
+        mock_train_classifier.side_effect = Exception("Error")
+        response = self.client.post(
+            self.ENDPOINT + "run/",
+            {"task_name": PaperlessTask.TaskName.TRAIN_CLASSIFIER},
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
+        mock_train_classifier.assert_called_once_with(scheduled=False)
+
+    @mock.patch("documents.tasks.sanity_check")
+    def test_run_task_requires_superuser(self, mock_check_sanity):
+        """
+        GIVEN:
+            - A regular user
+        WHEN:
+            - API call is made to run a task
+        THEN:
+            - The task is not run
+        """
+        regular_user = User.objects.create_user(username="test")
+        regular_user.user_permissions.add(*Permission.objects.all())
+        self.client.logout()
+        self.client.force_authenticate(user=regular_user)
+
+        response = self.client.post(
+            self.ENDPOINT + "run/",
+            {"task_name": PaperlessTask.TaskName.CHECK_SANITY},
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+        mock_check_sanity.assert_not_called()
index a025fb9dc053e25c269e8a4bee3dceda441fdf3b..d94eb38480a3431e33ac90612a8329ff95e5db27 100644 (file)
@@ -68,7 +68,7 @@ class TestTaskSignalHandler(DirectoriesMixin, TestCase):
         self.assertIsNotNone(task)
         self.assertEqual(headers["id"], task.task_id)
         self.assertEqual("hello-999.pdf", task.task_file_name)
-        self.assertEqual("documents.tasks.consume_file", task.task_name)
+        self.assertEqual(PaperlessTask.TaskName.CONSUME_FILE, task.task_name)
         self.assertEqual(1, task.owner_id)
         self.assertEqual(celery.states.PENDING, task.status)
 
index 0f9f8511f01debcdebe1952ed345b002e2745800..348eb0db5b4dd412964d316d8bd821f3213bef0a 100644 (file)
@@ -118,6 +118,19 @@ class TestSanityCheck(DirectoriesMixin, TestCase):
         self.assertRaises(SanityCheckFailedException, tasks.sanity_check)
         m.assert_called_once()
 
+    @mock.patch("documents.tasks.sanity_checker.check_sanity")
+    def test_sanity_check_error_no_raise(self, m):
+        messages = SanityCheckMessages()
+        messages.error(None, "Some error")
+        m.return_value = messages
+        # No exception should be raised
+        result = tasks.sanity_check(raise_on_error=False)
+        self.assertEqual(
+            result,
+            "Sanity check exited with errors. See log.",
+        )
+        m.assert_called_once()
+
     @mock.patch("documents.tasks.sanity_checker.check_sanity")
     def test_sanity_check_warning(self, m):
         messages = SanityCheckMessages()
index 487ec84029777594a9b322c15290f09ec26dd4db..2d85ffc4e5c791cb045e647cddb6280fad187c83 100644 (file)
@@ -14,6 +14,7 @@ from urllib.parse import urlparse
 
 import httpx
 import pathvalidate
+from celery import states
 from django.conf import settings
 from django.contrib.auth.models import Group
 from django.contrib.auth.models import User
@@ -103,6 +104,7 @@ from documents.filters import DocumentsOrderingFilter
 from documents.filters import DocumentTypeFilterSet
 from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
 from documents.filters import ObjectOwnedPermissionsFilter
+from documents.filters import PaperlessTaskFilterSet
 from documents.filters import ShareLinkFilterSet
 from documents.filters import StoragePathFilterSet
 from documents.filters import TagFilterSet
@@ -144,6 +146,7 @@ from documents.serialisers import DocumentListSerializer
 from documents.serialisers import DocumentSerializer
 from documents.serialisers import DocumentTypeSerializer
 from documents.serialisers import PostDocumentSerializer
+from documents.serialisers import RunTaskViewSerializer
 from documents.serialisers import SavedViewSerializer
 from documents.serialisers import SearchResultSerializer
 from documents.serialisers import ShareLinkSerializer
@@ -160,6 +163,9 @@ from documents.serialisers import WorkflowTriggerSerializer
 from documents.signals import document_updated
 from documents.tasks import consume_file
 from documents.tasks import empty_trash
+from documents.tasks import index_optimize
+from documents.tasks import sanity_check
+from documents.tasks import train_classifier
 from documents.templating.filepath import validate_filepath_template_and_render
 from paperless import version
 from paperless.celery import app as celery_app
@@ -2276,16 +2282,27 @@ class RemoteVersionView(GenericAPIView):
 class TasksViewSet(ReadOnlyModelViewSet):
     permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
     serializer_class = TasksViewSerializer
-    filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
+    filter_backends = (
+        DjangoFilterBackend,
+        OrderingFilter,
+        ObjectOwnedOrGrantedPermissionsFilter,
+    )
+    filterset_class = PaperlessTaskFilterSet
+
+    TASK_AND_ARGS_BY_NAME = {
+        PaperlessTask.TaskName.INDEX_OPTIMIZE: (index_optimize, {}),
+        PaperlessTask.TaskName.TRAIN_CLASSIFIER: (
+            train_classifier,
+            {"scheduled": False},
+        ),
+        PaperlessTask.TaskName.CHECK_SANITY: (
+            sanity_check,
+            {"scheduled": False, "raise_on_error": False},
+        ),
+    }
 
     def get_queryset(self):
-        queryset = (
-            PaperlessTask.objects.filter(
-                acknowledged=False,
-            )
-            .order_by("date_created")
-            .reverse()
-        )
+        queryset = PaperlessTask.objects.all().order_by("-date_created")
         task_id = self.request.query_params.get("task_id")
         if task_id is not None:
             queryset = PaperlessTask.objects.filter(task_id=task_id)
@@ -2308,6 +2325,25 @@ class TasksViewSet(ReadOnlyModelViewSet):
         except Exception:
             return HttpResponseBadRequest()
 
+    @action(methods=["post"], detail=False)
+    def run(self, request):
+        serializer = RunTaskViewSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        task_name = serializer.validated_data.get("task_name")
+
+        if not request.user.is_superuser:
+            return HttpResponseForbidden("Insufficient permissions")
+
+        try:
+            task_func, task_args = self.TASK_AND_ARGS_BY_NAME[task_name]
+            result = task_func(**task_args)
+            return Response({"result": result})
+        except Exception as e:
+            logger.warning(f"An error occurred running task: {e!s}")
+            return HttpResponseServerError(
+                "Error running task, check logs for more detail.",
+            )
+
 
 class ShareLinkViewSet(ModelViewSet, PassUserMixin):
     model = ShareLink
@@ -2614,6 +2650,14 @@ class CustomFieldViewSet(ModelViewSet):
                             "last_trained": serializers.DateTimeField(),
                         },
                     ),
+                    "sanity_check": inline_serializer(
+                        name="SanityCheck",
+                        fields={
+                            "status": serializers.CharField(),
+                            "error": serializers.CharField(),
+                            "last_run": serializers.DateTimeField(),
+                        },
+                    ),
                 },
             ),
         },
@@ -2674,13 +2718,20 @@ class SystemStatusView(PassUserMixin):
                 )
                 redis_error = "Error connecting to redis, check logs for more detail."
 
+        celery_error = None
+        celery_url = None
         try:
             celery_ping = celery_app.control.inspect().ping()
-            first_worker_ping = celery_ping[next(iter(celery_ping.keys()))]
+            celery_url = next(iter(celery_ping.keys()))
+            first_worker_ping = celery_ping[celery_url]
             if first_worker_ping["ok"] == "pong":
                 celery_active = "OK"
-        except Exception:
+        except Exception as e:
             celery_active = "ERROR"
+            logger.exception(
+                f"System status detected a possible problem while connecting to celery: {e}",
+            )
+            celery_error = "Error connecting to celery, check logs for more detail."
 
         index_error = None
         try:
@@ -2697,55 +2748,43 @@ class SystemStatusView(PassUserMixin):
             )
             index_last_modified = None
 
-        classifier_error = None
-        classifier_status = None
-        try:
-            classifier = load_classifier(raise_exception=True)
-            if classifier is None:
-                # Make sure classifier should exist
-                docs_queryset = Document.objects.exclude(
-                    tags__is_inbox_tag=True,
-                )
-                if (
-                    docs_queryset.count() > 0
-                    and (
-                        Tag.objects.filter(matching_algorithm=Tag.MATCH_AUTO).exists()
-                        or DocumentType.objects.filter(
-                            matching_algorithm=Tag.MATCH_AUTO,
-                        ).exists()
-                        or Correspondent.objects.filter(
-                            matching_algorithm=Tag.MATCH_AUTO,
-                        ).exists()
-                        or StoragePath.objects.filter(
-                            matching_algorithm=Tag.MATCH_AUTO,
-                        ).exists()
-                    )
-                    and not settings.MODEL_FILE.exists()
-                ):
-                    # if classifier file doesn't exist just classify as a warning
-                    classifier_error = "Classifier file does not exist (yet). Re-training may be pending."
-                    classifier_status = "WARNING"
-                    raise FileNotFoundError(classifier_error)
-            classifier_status = "OK"
-            classifier_last_trained = (
-                make_aware(
-                    datetime.fromtimestamp(classifier.get_last_checked()),
-                )
-                if settings.MODEL_FILE.exists()
-                and classifier.get_last_checked() is not None
-                else None
+        last_trained_task = (
+            PaperlessTask.objects.filter(
+                task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
             )
-        except Exception as e:
-            if classifier_status is None:
-                classifier_status = "ERROR"
-            classifier_last_trained = None
-            if classifier_error is None:
-                classifier_error = (
-                    "Unable to load classifier, check logs for more detail."
-                )
-            logger.exception(
-                f"System status detected a possible problem while loading the classifier: {e}",
+            .order_by("-date_done")
+            .first()
+        )
+        classifier_status = "OK"
+        classifier_error = None
+        if last_trained_task is None:
+            classifier_status = "WARNING"
+            classifier_error = "No classifier training tasks found"
+        elif last_trained_task and last_trained_task.status == states.FAILURE:
+            classifier_status = "ERROR"
+            classifier_error = last_trained_task.result
+        classifier_last_trained = (
+            last_trained_task.date_done if last_trained_task else None
+        )
+
+        last_sanity_check = (
+            PaperlessTask.objects.filter(
+                task_name=PaperlessTask.TaskName.CHECK_SANITY,
             )
+            .order_by("-date_done")
+            .first()
+        )
+        sanity_check_status = "OK"
+        sanity_check_error = None
+        if last_sanity_check is None:
+            sanity_check_status = "WARNING"
+            sanity_check_error = "No sanity check tasks found"
+        elif last_sanity_check and last_sanity_check.status == states.FAILURE:
+            sanity_check_status = "ERROR"
+            sanity_check_error = last_sanity_check.result
+        sanity_check_last_run = (
+            last_sanity_check.date_done if last_sanity_check else None
+        )
 
         return Response(
             {
@@ -2773,12 +2812,17 @@ class SystemStatusView(PassUserMixin):
                     "redis_status": redis_status,
                     "redis_error": redis_error,
                     "celery_status": celery_active,
+                    "celery_url": celery_url,
+                    "celery_error": celery_error,
                     "index_status": index_status,
                     "index_last_modified": index_last_modified,
                     "index_error": index_error,
                     "classifier_status": classifier_status,
                     "classifier_last_trained": classifier_last_trained,
                     "classifier_error": classifier_error,
+                    "sanity_check_status": sanity_check_status,
+                    "sanity_check_last_run": sanity_check_last_run,
+                    "sanity_check_error": sanity_check_error,
                 },
             },
         )
index 32d60ac196abd3bd10224e7ac657111b1ddade30..5dad5273b2e9b2075cb95fbd718b2c2ace535eb3 100644 (file)
@@ -2,7 +2,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: paperless-ngx\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2025-02-11 18:43-0800\n"
+"POT-Creation-Date: 2025-02-25 11:07-0800\n"
 "PO-Revision-Date: 2022-02-17 04:17\n"
 "Last-Translator: \n"
 "Language-Team: English\n"
@@ -21,67 +21,67 @@ msgstr ""
 msgid "Documents"
 msgstr ""
 
-#: documents/filters.py:369
+#: documents/filters.py:370
 msgid "Value must be valid JSON."
 msgstr ""
 
-#: documents/filters.py:388
+#: documents/filters.py:389
 msgid "Invalid custom field query expression"
 msgstr ""
 
-#: documents/filters.py:398
+#: documents/filters.py:399
 msgid "Invalid expression list. Must be nonempty."
 msgstr ""
 
-#: documents/filters.py:419
+#: documents/filters.py:420
 msgid "Invalid logical operator {op!r}"
 msgstr ""
 
-#: documents/filters.py:433
+#: documents/filters.py:434
 msgid "Maximum number of query conditions exceeded."
 msgstr ""
 
-#: documents/filters.py:498
+#: documents/filters.py:499
 msgid "{name!r} is not a valid custom field."
 msgstr ""
 
-#: documents/filters.py:535
+#: documents/filters.py:536
 msgid "{data_type} does not support query expr {expr!r}."
 msgstr ""
 
-#: documents/filters.py:643
+#: documents/filters.py:644
 msgid "Maximum nesting depth exceeded."
 msgstr ""
 
-#: documents/filters.py:813
+#: documents/filters.py:829
 msgid "Custom field not found"
 msgstr ""
 
-#: documents/models.py:41 documents/models.py:806
+#: documents/models.py:41 documents/models.py:830
 msgid "owner"
 msgstr ""
 
-#: documents/models.py:58 documents/models.py:1017
+#: documents/models.py:58 documents/models.py:1041
 msgid "None"
 msgstr ""
 
-#: documents/models.py:59 documents/models.py:1018
+#: documents/models.py:59 documents/models.py:1042
 msgid "Any word"
 msgstr ""
 
-#: documents/models.py:60 documents/models.py:1019
+#: documents/models.py:60 documents/models.py:1043
 msgid "All words"
 msgstr ""
 
-#: documents/models.py:61 documents/models.py:1020
+#: documents/models.py:61 documents/models.py:1044
 msgid "Exact match"
 msgstr ""
 
-#: documents/models.py:62 documents/models.py:1021
+#: documents/models.py:62 documents/models.py:1045
 msgid "Regular expression"
 msgstr ""
 
-#: documents/models.py:63 documents/models.py:1022
+#: documents/models.py:63 documents/models.py:1046
 msgid "Fuzzy word"
 msgstr ""
 
@@ -89,20 +89,20 @@ msgstr ""
 msgid "Automatic"
 msgstr ""
 
-#: documents/models.py:67 documents/models.py:433 documents/models.py:1498
+#: documents/models.py:67 documents/models.py:433 documents/models.py:1526
 #: paperless_mail/models.py:23 paperless_mail/models.py:143
 msgid "name"
 msgstr ""
 
-#: documents/models.py:69 documents/models.py:1085
+#: documents/models.py:69 documents/models.py:1110
 msgid "match"
 msgstr ""
 
-#: documents/models.py:72 documents/models.py:1088
+#: documents/models.py:72 documents/models.py:1113
 msgid "matching algorithm"
 msgstr ""
 
-#: documents/models.py:77 documents/models.py:1093
+#: documents/models.py:77 documents/models.py:1118
 msgid "is insensitive"
 msgstr ""
 
@@ -168,7 +168,7 @@ msgstr ""
 msgid "title"
 msgstr ""
 
-#: documents/models.py:175 documents/models.py:720
+#: documents/models.py:175 documents/models.py:744
 msgid "content"
 msgstr ""
 
@@ -206,8 +206,8 @@ msgstr ""
 msgid "The number of pages of the document."
 msgstr ""
 
-#: documents/models.py:221 documents/models.py:401 documents/models.py:726
-#: documents/models.py:764 documents/models.py:835 documents/models.py:893
+#: documents/models.py:221 documents/models.py:401 documents/models.py:750
+#: documents/models.py:788 documents/models.py:859 documents/models.py:917
 msgid "created"
 msgstr ""
 
@@ -255,8 +255,8 @@ msgstr ""
 msgid "The position of this document in your physical document archive."
 msgstr ""
 
-#: documents/models.py:295 documents/models.py:737 documents/models.py:791
-#: documents/models.py:1541
+#: documents/models.py:295 documents/models.py:761 documents/models.py:815
+#: documents/models.py:1569
 msgid "document"
 msgstr ""
 
@@ -320,11 +320,11 @@ msgstr ""
 msgid "Title"
 msgstr ""
 
-#: documents/models.py:420 documents/models.py:1037
+#: documents/models.py:420 documents/models.py:1062
 msgid "Created"
 msgstr ""
 
-#: documents/models.py:421 documents/models.py:1036
+#: documents/models.py:421 documents/models.py:1061
 msgid "Added"
 msgstr ""
 
@@ -608,581 +608,621 @@ msgstr ""
 msgid "filter rules"
 msgstr ""
 
+#: documents/models.py:654
+msgid "Auto Task"
+msgstr ""
+
+#: documents/models.py:655
+msgid "Scheduled Task"
+msgstr ""
+
 #: documents/models.py:656
+msgid "Manual Task"
+msgstr ""
+
+#: documents/models.py:659
+msgid "Consume File"
+msgstr ""
+
+#: documents/models.py:660
+msgid "Train Classifier"
+msgstr ""
+
+#: documents/models.py:661
+msgid "Check Sanity"
+msgstr ""
+
+#: documents/models.py:662
+msgid "Index Optimize"
+msgstr ""
+
+#: documents/models.py:667
 msgid "Task ID"
 msgstr ""
 
-#: documents/models.py:657
+#: documents/models.py:668
 msgid "Celery ID for the Task that was run"
 msgstr ""
 
-#: documents/models.py:662
+#: documents/models.py:673
 msgid "Acknowledged"
 msgstr ""
 
-#: documents/models.py:663
+#: documents/models.py:674
 msgid "If the task is acknowledged via the frontend or API"
 msgstr ""
 
-#: documents/models.py:669
+#: documents/models.py:680
 msgid "Task Filename"
 msgstr ""
 
-#: documents/models.py:670
+#: documents/models.py:681
 msgid "Name of the file which the Task was run for"
 msgstr ""
 
-#: documents/models.py:676
+#: documents/models.py:688
 msgid "Task Name"
 msgstr ""
 
-#: documents/models.py:677
-msgid "Name of the Task which was run"
+#: documents/models.py:689
+msgid "Name of the task that was run"
 msgstr ""
 
-#: documents/models.py:684
+#: documents/models.py:696
 msgid "Task State"
 msgstr ""
 
-#: documents/models.py:685
+#: documents/models.py:697
 msgid "Current state of the task being run"
 msgstr ""
 
-#: documents/models.py:690
+#: documents/models.py:703
 msgid "Created DateTime"
 msgstr ""
 
-#: documents/models.py:691
+#: documents/models.py:704
 msgid "Datetime field when the task result was created in UTC"
 msgstr ""
 
-#: documents/models.py:696
+#: documents/models.py:710
 msgid "Started DateTime"
 msgstr ""
 
-#: documents/models.py:697
+#: documents/models.py:711
 msgid "Datetime field when the task was started in UTC"
 msgstr ""
 
-#: documents/models.py:702
+#: documents/models.py:717
 msgid "Completed DateTime"
 msgstr ""
 
-#: documents/models.py:703
+#: documents/models.py:718
 msgid "Datetime field when the task was completed in UTC"
 msgstr ""
 
-#: documents/models.py:708
+#: documents/models.py:724
 msgid "Result Data"
 msgstr ""
 
-#: documents/models.py:710
+#: documents/models.py:726
 msgid "The data returned by the task"
 msgstr ""
 
-#: documents/models.py:722
-msgid "Note for the document"
+#: documents/models.py:734
+msgid "Task Type"
+msgstr ""
+
+#: documents/models.py:735
+msgid "The type of task that was run"
 msgstr ""
 
 #: documents/models.py:746
+msgid "Note for the document"
+msgstr ""
+
+#: documents/models.py:770
 msgid "user"
 msgstr ""
 
-#: documents/models.py:751
+#: documents/models.py:775
 msgid "note"
 msgstr ""
 
-#: documents/models.py:752
+#: documents/models.py:776
 msgid "notes"
 msgstr ""
 
-#: documents/models.py:760
+#: documents/models.py:784
 msgid "Archive"
 msgstr ""
 
-#: documents/models.py:761
+#: documents/models.py:785
 msgid "Original"
 msgstr ""
 
-#: documents/models.py:772 paperless_mail/models.py:75
+#: documents/models.py:796 paperless_mail/models.py:75
 msgid "expiration"
 msgstr ""
 
-#: documents/models.py:779
+#: documents/models.py:803
 msgid "slug"
 msgstr ""
 
-#: documents/models.py:811
+#: documents/models.py:835
 msgid "share link"
 msgstr ""
 
-#: documents/models.py:812
+#: documents/models.py:836
 msgid "share links"
 msgstr ""
 
-#: documents/models.py:824
+#: documents/models.py:848
 msgid "String"
 msgstr ""
 
-#: documents/models.py:825
+#: documents/models.py:849
 msgid "URL"
 msgstr ""
 
-#: documents/models.py:826
+#: documents/models.py:850
 msgid "Date"
 msgstr ""
 
-#: documents/models.py:827
+#: documents/models.py:851
 msgid "Boolean"
 msgstr ""
 
-#: documents/models.py:828
+#: documents/models.py:852
 msgid "Integer"
 msgstr ""
 
-#: documents/models.py:829
+#: documents/models.py:853
 msgid "Float"
 msgstr ""
 
-#: documents/models.py:830
+#: documents/models.py:854
 msgid "Monetary"
 msgstr ""
 
-#: documents/models.py:831
+#: documents/models.py:855
 msgid "Document Link"
 msgstr ""
 
-#: documents/models.py:832
+#: documents/models.py:856
 msgid "Select"
 msgstr ""
 
-#: documents/models.py:844
+#: documents/models.py:868
 msgid "data type"
 msgstr ""
 
-#: documents/models.py:851
+#: documents/models.py:875
 msgid "extra data"
 msgstr ""
 
-#: documents/models.py:855
+#: documents/models.py:879
 msgid "Extra data for the custom field, such as select options"
 msgstr ""
 
-#: documents/models.py:861
+#: documents/models.py:885
 msgid "custom field"
 msgstr ""
 
-#: documents/models.py:862
+#: documents/models.py:886
 msgid "custom fields"
 msgstr ""
 
-#: documents/models.py:959
+#: documents/models.py:983
 msgid "custom field instance"
 msgstr ""
 
-#: documents/models.py:960
+#: documents/models.py:984
 msgid "custom field instances"
 msgstr ""
 
-#: documents/models.py:1025
+#: documents/models.py:1049
 msgid "Consumption Started"
 msgstr ""
 
-#: documents/models.py:1026
+#: documents/models.py:1050
 msgid "Document Added"
 msgstr ""
 
-#: documents/models.py:1027
+#: documents/models.py:1051
 msgid "Document Updated"
 msgstr ""
 
-#: documents/models.py:1028
+#: documents/models.py:1052
 msgid "Scheduled"
 msgstr ""
 
-#: documents/models.py:1031
+#: documents/models.py:1055
 msgid "Consume Folder"
 msgstr ""
 
-#: documents/models.py:1032
+#: documents/models.py:1056
 msgid "Api Upload"
 msgstr ""
 
-#: documents/models.py:1033
+#: documents/models.py:1057
 msgid "Mail Fetch"
 msgstr ""
 
-#: documents/models.py:1038
+#: documents/models.py:1058
+msgid "Web UI"
+msgstr ""
+
+#: documents/models.py:1063
 msgid "Modified"
 msgstr ""
 
-#: documents/models.py:1039
+#: documents/models.py:1064
 msgid "Custom Field"
 msgstr ""
 
-#: documents/models.py:1042
+#: documents/models.py:1067
 msgid "Workflow Trigger Type"
 msgstr ""
 
-#: documents/models.py:1054
+#: documents/models.py:1079
 msgid "filter path"
 msgstr ""
 
-#: documents/models.py:1059
+#: documents/models.py:1084
 msgid ""
 "Only consume documents with a path that matches this if specified. Wildcards "
 "specified as * are allowed. Case insensitive."
 msgstr ""
 
-#: documents/models.py:1066
+#: documents/models.py:1091
 msgid "filter filename"
 msgstr ""
 
-#: documents/models.py:1071 paperless_mail/models.py:200
+#: documents/models.py:1096 paperless_mail/models.py:200
 msgid ""
 "Only consume documents which entirely match this filename if specified. "
 "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
 msgstr ""
 
-#: documents/models.py:1082
+#: documents/models.py:1107
 msgid "filter documents from this mail rule"
 msgstr ""
 
-#: documents/models.py:1098
+#: documents/models.py:1123
 msgid "has these tag(s)"
 msgstr ""
 
-#: documents/models.py:1106
+#: documents/models.py:1131
 msgid "has this document type"
 msgstr ""
 
-#: documents/models.py:1114
+#: documents/models.py:1139
 msgid "has this correspondent"
 msgstr ""
 
-#: documents/models.py:1118
+#: documents/models.py:1143
 msgid "schedule offset days"
 msgstr ""
 
-#: documents/models.py:1121
+#: documents/models.py:1146
 msgid "The number of days to offset the schedule trigger by."
 msgstr ""
 
-#: documents/models.py:1126
+#: documents/models.py:1151
 msgid "schedule is recurring"
 msgstr ""
 
-#: documents/models.py:1129
+#: documents/models.py:1154
 msgid "If the schedule should be recurring."
 msgstr ""
 
-#: documents/models.py:1134
+#: documents/models.py:1159
 msgid "schedule recurring delay in days"
 msgstr ""
 
-#: documents/models.py:1138
+#: documents/models.py:1163
 msgid "The number of days between recurring schedule triggers."
 msgstr ""
 
-#: documents/models.py:1143
+#: documents/models.py:1168
 msgid "schedule date field"
 msgstr ""
 
-#: documents/models.py:1148
+#: documents/models.py:1173
 msgid "The field to check for a schedule trigger."
 msgstr ""
 
-#: documents/models.py:1157
+#: documents/models.py:1182
 msgid "schedule date custom field"
 msgstr ""
 
-#: documents/models.py:1161
+#: documents/models.py:1186
 msgid "workflow trigger"
 msgstr ""
 
-#: documents/models.py:1162
+#: documents/models.py:1187
 msgid "workflow triggers"
 msgstr ""
 
-#: documents/models.py:1170
+#: documents/models.py:1195
 msgid "email subject"
 msgstr ""
 
-#: documents/models.py:1174
+#: documents/models.py:1199
 msgid ""
 "The subject of the email, can include some placeholders, see documentation."
 msgstr ""
 
-#: documents/models.py:1180
+#: documents/models.py:1205
 msgid "email body"
 msgstr ""
 
-#: documents/models.py:1183
+#: documents/models.py:1208
 msgid ""
 "The body (message) of the email, can include some placeholders, see "
 "documentation."
 msgstr ""
 
-#: documents/models.py:1189
+#: documents/models.py:1214
 msgid "emails to"
 msgstr ""
 
-#: documents/models.py:1192
+#: documents/models.py:1217
 msgid "The destination email addresses, comma separated."
 msgstr ""
 
-#: documents/models.py:1198
+#: documents/models.py:1223
 msgid "include document in email"
 msgstr ""
 
-#: documents/models.py:1207
+#: documents/models.py:1234
 msgid "webhook url"
 msgstr ""
 
-#: documents/models.py:1209
+#: documents/models.py:1237
 msgid "The destination URL for the notification."
 msgstr ""
 
-#: documents/models.py:1214
+#: documents/models.py:1242
 msgid "use parameters"
 msgstr ""
 
-#: documents/models.py:1219
+#: documents/models.py:1247
 msgid "send as JSON"
 msgstr ""
 
-#: documents/models.py:1223
+#: documents/models.py:1251
 msgid "webhook parameters"
 msgstr ""
 
-#: documents/models.py:1226
+#: documents/models.py:1254
 msgid "The parameters to send with the webhook URL if body not used."
 msgstr ""
 
-#: documents/models.py:1230
+#: documents/models.py:1258
 msgid "webhook body"
 msgstr ""
 
-#: documents/models.py:1233
+#: documents/models.py:1261
 msgid "The body to send with the webhook URL if parameters not used."
 msgstr ""
 
-#: documents/models.py:1237
+#: documents/models.py:1265
 msgid "webhook headers"
 msgstr ""
 
-#: documents/models.py:1240
+#: documents/models.py:1268
 msgid "The headers to send with the webhook URL."
 msgstr ""
 
-#: documents/models.py:1245
+#: documents/models.py:1273
 msgid "include document in webhook"
 msgstr ""
 
-#: documents/models.py:1256
+#: documents/models.py:1284
 msgid "Assignment"
 msgstr ""
 
-#: documents/models.py:1260
+#: documents/models.py:1288
 msgid "Removal"
 msgstr ""
 
-#: documents/models.py:1264 documents/templates/account/password_reset.html:15
+#: documents/models.py:1292 documents/templates/account/password_reset.html:15
 msgid "Email"
 msgstr ""
 
-#: documents/models.py:1268
+#: documents/models.py:1296
 msgid "Webhook"
 msgstr ""
 
-#: documents/models.py:1272
+#: documents/models.py:1300
 msgid "Workflow Action Type"
 msgstr ""
 
-#: documents/models.py:1278
+#: documents/models.py:1306
 msgid "assign title"
 msgstr ""
 
-#: documents/models.py:1283
+#: documents/models.py:1311
 msgid ""
 "Assign a document title, can include some placeholders, see documentation."
 msgstr ""
 
-#: documents/models.py:1292 paperless_mail/models.py:274
+#: documents/models.py:1320 paperless_mail/models.py:274
 msgid "assign this tag"
 msgstr ""
 
-#: documents/models.py:1301 paperless_mail/models.py:282
+#: documents/models.py:1329 paperless_mail/models.py:282
 msgid "assign this document type"
 msgstr ""
 
-#: documents/models.py:1310 paperless_mail/models.py:296
+#: documents/models.py:1338 paperless_mail/models.py:296
 msgid "assign this correspondent"
 msgstr ""
 
-#: documents/models.py:1319
+#: documents/models.py:1347
 msgid "assign this storage path"
 msgstr ""
 
-#: documents/models.py:1328
+#: documents/models.py:1356
 msgid "assign this owner"
 msgstr ""
 
-#: documents/models.py:1335
+#: documents/models.py:1363
 msgid "grant view permissions to these users"
 msgstr ""
 
-#: documents/models.py:1342
+#: documents/models.py:1370
 msgid "grant view permissions to these groups"
 msgstr ""
 
-#: documents/models.py:1349
+#: documents/models.py:1377
 msgid "grant change permissions to these users"
 msgstr ""
 
-#: documents/models.py:1356
+#: documents/models.py:1384
 msgid "grant change permissions to these groups"
 msgstr ""
 
-#: documents/models.py:1363
+#: documents/models.py:1391
 msgid "assign these custom fields"
 msgstr ""
 
-#: documents/models.py:1370
+#: documents/models.py:1398
 msgid "remove these tag(s)"
 msgstr ""
 
-#: documents/models.py:1375
+#: documents/models.py:1403
 msgid "remove all tags"
 msgstr ""
 
-#: documents/models.py:1382
+#: documents/models.py:1410
 msgid "remove these document type(s)"
 msgstr ""
 
-#: documents/models.py:1387
+#: documents/models.py:1415
 msgid "remove all document types"
 msgstr ""
 
-#: documents/models.py:1394
+#: documents/models.py:1422
 msgid "remove these correspondent(s)"
 msgstr ""
 
-#: documents/models.py:1399
+#: documents/models.py:1427
 msgid "remove all correspondents"
 msgstr ""
 
-#: documents/models.py:1406
+#: documents/models.py:1434
 msgid "remove these storage path(s)"
 msgstr ""
 
-#: documents/models.py:1411
+#: documents/models.py:1439
 msgid "remove all storage paths"
 msgstr ""
 
-#: documents/models.py:1418
+#: documents/models.py:1446
 msgid "remove these owner(s)"
 msgstr ""
 
-#: documents/models.py:1423
+#: documents/models.py:1451
 msgid "remove all owners"
 msgstr ""
 
-#: documents/models.py:1430
+#: documents/models.py:1458
 msgid "remove view permissions for these users"
 msgstr ""
 
-#: documents/models.py:1437
+#: documents/models.py:1465
 msgid "remove view permissions for these groups"
 msgstr ""
 
-#: documents/models.py:1444
+#: documents/models.py:1472
 msgid "remove change permissions for these users"
 msgstr ""
 
-#: documents/models.py:1451
+#: documents/models.py:1479
 msgid "remove change permissions for these groups"
 msgstr ""
 
-#: documents/models.py:1456
+#: documents/models.py:1484
 msgid "remove all permissions"
 msgstr ""
 
-#: documents/models.py:1463
+#: documents/models.py:1491
 msgid "remove these custom fields"
 msgstr ""
 
-#: documents/models.py:1468
+#: documents/models.py:1496
 msgid "remove all custom fields"
 msgstr ""
 
-#: documents/models.py:1477
+#: documents/models.py:1505
 msgid "email"
 msgstr ""
 
-#: documents/models.py:1486
+#: documents/models.py:1514
 msgid "webhook"
 msgstr ""
 
-#: documents/models.py:1490
+#: documents/models.py:1518
 msgid "workflow action"
 msgstr ""
 
-#: documents/models.py:1491
+#: documents/models.py:1519
 msgid "workflow actions"
 msgstr ""
 
-#: documents/models.py:1500 paperless_mail/models.py:145
+#: documents/models.py:1528 paperless_mail/models.py:145
 msgid "order"
 msgstr ""
 
-#: documents/models.py:1506
+#: documents/models.py:1534
 msgid "triggers"
 msgstr ""
 
-#: documents/models.py:1513
+#: documents/models.py:1541
 msgid "actions"
 msgstr ""
 
-#: documents/models.py:1516 paperless_mail/models.py:154
+#: documents/models.py:1544 paperless_mail/models.py:154
 msgid "enabled"
 msgstr ""
 
-#: documents/models.py:1527
+#: documents/models.py:1555
 msgid "workflow"
 msgstr ""
 
-#: documents/models.py:1531
+#: documents/models.py:1559
 msgid "workflow trigger type"
 msgstr ""
 
-#: documents/models.py:1545
+#: documents/models.py:1573
 msgid "date run"
 msgstr ""
 
-#: documents/models.py:1551
+#: documents/models.py:1579
 msgid "workflow run"
 msgstr ""
 
-#: documents/models.py:1552
+#: documents/models.py:1580
 msgid "workflow runs"
 msgstr ""
 
-#: documents/serialisers.py:127
+#: documents/serialisers.py:128
 #, python-format
 msgid "Invalid regular expression: %(error)s"
 msgstr ""
 
-#: documents/serialisers.py:553
+#: documents/serialisers.py:554
 msgid "Invalid color."
 msgstr ""
 
-#: documents/serialisers.py:1554
+#: documents/serialisers.py:1570
 #, python-format
 msgid "File type %(type)s not supported"
 msgstr ""
 
-#: documents/serialisers.py:1643
+#: documents/serialisers.py:1659
 msgid "Invalid variable detected."
 msgstr ""
 
@@ -1402,17 +1442,23 @@ msgstr ""
 msgid "As a final step, please complete the following form:"
 msgstr ""
 
-#: documents/validators.py:17
+#: documents/validators.py:24
 #, python-brace-format
 msgid "Unable to parse URI {value}, missing scheme"
 msgstr ""
 
-#: documents/validators.py:22
+#: documents/validators.py:29
 #, python-brace-format
 msgid "Unable to parse URI {value}, missing net location or path"
 msgstr ""
 
-#: documents/validators.py:27
+#: documents/validators.py:36
+msgid ""
+"URI scheme '{parts.scheme}' is not allowed. Allowed schemes: {', '."
+"join(allowed_schemes)}"
+msgstr ""
+
+#: documents/validators.py:45
 #, python-brace-format
 msgid "Unable to parse URI {value}"
 msgstr ""
@@ -1701,7 +1747,7 @@ msgstr ""
 msgid "Chinese Traditional"
 msgstr ""
 
-#: paperless/urls.py:364
+#: paperless/urls.py:369
 msgid "Paperless-ngx administration"
 msgstr ""