mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Compare commits
	
		
			24 Commits
		
	
	
		
			6a1060f4d2
			...
			feature-pa
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					a0a9e0c6c8 | ||
| 
						 | 
					1c7c703e5f | ||
| 
						 | 
					53e9e910d8 | ||
| 
						 | 
					9fe611a24c | ||
| 
						 | 
					31e71aab83 | ||
| 
						 | 
					7e7ce97d10 | ||
| 
						 | 
					e06adc58c7 | ||
| 
						 | 
					7170ac31b7 | ||
| 
						 | 
					a0aa78c788 | ||
| 
						 | 
					f3438914cc | ||
| 
						 | 
					e1b944ce6b | ||
| 
						 | 
					0add5aab0e | ||
| 
						 | 
					c9adc74fa9 | ||
| 
						 | 
					32abfbfc0a | ||
| 
						 | 
					7f02f782f4 | ||
| 
						 | 
					7c3f011e84 | ||
| 
						 | 
					5c68177960 | ||
| 
						 | 
					7a4666783e | ||
| 
						 | 
					372825c271 | ||
| 
						 | 
					abfddd6931 | ||
| 
						 | 
					b3d49dbf12 | ||
| 
						 | 
					673839265d | ||
| 
						 | 
					f31df22ab6 | ||
| 
						 | 
					f897447a65 | 
@@ -4099,6 +4099,18 @@
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">111</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">165</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">189</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">213</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">30</context>
 | 
			
		||||
@@ -5548,7 +5560,7 @@
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">156</context>
 | 
			
		||||
          <context context-type="linenumber">231</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
 | 
			
		||||
@@ -5943,77 +5955,98 @@
 | 
			
		||||
        <source>Migration Status</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">56</context>
 | 
			
		||||
          <context context-type="linenumber">65</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="7489316373554112115" datatype="html">
 | 
			
		||||
        <source>Up to date</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">59</context>
 | 
			
		||||
          <context context-type="linenumber">69</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="7881311375431899727" datatype="html">
 | 
			
		||||
        <source>Latest Migration</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">64</context>
 | 
			
		||||
          <context context-type="linenumber">74</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4632965004151576238" datatype="html">
 | 
			
		||||
        <source>Pending Migrations</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">66</context>
 | 
			
		||||
          <context context-type="linenumber">76</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6904866445262015585" datatype="html">
 | 
			
		||||
        <source>Tasks</source>
 | 
			
		||||
      <trans-unit id="2790343143501919450" datatype="html">
 | 
			
		||||
        <source>Tasks Queue</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">83</context>
 | 
			
		||||
          <context context-type="linenumber">94</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6911698235105017958" datatype="html">
 | 
			
		||||
        <source>Redis Status</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">87</context>
 | 
			
		||||
          <context context-type="linenumber">98</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="5349496739889768589" datatype="html">
 | 
			
		||||
        <source>Celery Status</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">96</context>
 | 
			
		||||
          <context context-type="linenumber">116</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="2041675390931385838" datatype="html">
 | 
			
		||||
        <source>Health</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">142</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="31377277941774469" datatype="html">
 | 
			
		||||
        <source>Search Index</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">105</context>
 | 
			
		||||
          <context context-type="linenumber">146</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="4089509911694721896" datatype="html">
 | 
			
		||||
        <source>Last Updated</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">119</context>
 | 
			
		||||
          <context context-type="linenumber">163</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="46628344485199198" datatype="html">
 | 
			
		||||
        <source>Classifier</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">121</context>
 | 
			
		||||
          <context context-type="linenumber">168</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6096684179126491743" datatype="html">
 | 
			
		||||
        <source>Last Trained</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">139</context>
 | 
			
		||||
          <context context-type="linenumber">187</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6427836860962380759" datatype="html">
 | 
			
		||||
        <source>Sanity Checker</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">192</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6578747070254776938" datatype="html">
 | 
			
		||||
        <source>Last Run</source>
 | 
			
		||||
        <context-group purpose="location">
 | 
			
		||||
          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
 | 
			
		||||
          <context context-type="linenumber">211</context>
 | 
			
		||||
        </context-group>
 | 
			
		||||
      </trans-unit>
 | 
			
		||||
      <trans-unit id="6732151329960766506" datatype="html">
 | 
			
		||||
 
 | 
			
		||||
@@ -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))
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<div class="modal-header">
 | 
			
		||||
  <h5 class="modal-title" id="modal-basic-title" i18n>System Status</h5>
 | 
			
		||||
  <h6 class="modal-title" id="modal-basic-title" i18n>System Status</h6>
 | 
			
		||||
  <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
 | 
			
		||||
</div>
 | 
			
		||||
<div class="modal-body">
 | 
			
		||||
@@ -11,11 +11,11 @@
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  } @else {
 | 
			
		||||
    <div class="row row-cols-1 row-cols-md-3 g-3">
 | 
			
		||||
      <div class="col">
 | 
			
		||||
    <div class="row row-cols-1 row-cols-md-4 g-3">
 | 
			
		||||
      <div class="col-4">
 | 
			
		||||
        <div class="card bg-light h-100">
 | 
			
		||||
          <div class="card-header">
 | 
			
		||||
            <h5 class="card-title mb-0" i18n>Environment</h5>
 | 
			
		||||
            <h6 class="card-title mb-0" i18n>Environment</h6>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="card-body">
 | 
			
		||||
            <dl class="card-text">
 | 
			
		||||
@@ -38,37 +38,96 @@
 | 
			
		||||
      <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>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="databaseStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
                  {{status.database.status}}
 | 
			
		||||
                  @if (status.database.status === 'OK') {
 | 
			
		||||
                    <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
 | 
			
		||||
                  }
 | 
			
		||||
                </div>
 | 
			
		||||
                <ng-template #databaseStatus>
 | 
			
		||||
                  @if (status.database.status === 'OK') {
 | 
			
		||||
                    {{status.database.url}}
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    {{status.database.url}}: {{status.database.error}}
 | 
			
		||||
                  }
 | 
			
		||||
                </ng-template>
 | 
			
		||||
              </dd>
 | 
			
		||||
              <dt i18n>Migration Status</dt>
 | 
			
		||||
              <dd class="d-flex align-items-center">
 | 
			
		||||
                @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>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
                  @if (status.database.migration_status.unapplied_migrations.length === 0) {
 | 
			
		||||
                    <ng-container i18n>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></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 #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>
 | 
			
		||||
                </div>
 | 
			
		||||
              </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>Tasks Queue</h6>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="card-body">
 | 
			
		||||
            <dl class="card-text">
 | 
			
		||||
              <dt i18n>Redis Status</dt>
 | 
			
		||||
              <dd>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="redisStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
                  {{status.tasks.redis_status}}
 | 
			
		||||
                  @if (status.tasks.redis_status === 'OK') {
 | 
			
		||||
                    <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
 | 
			
		||||
                  }
 | 
			
		||||
                </div>
 | 
			
		||||
                <ng-template #redisStatus>
 | 
			
		||||
                  @if (status.tasks.redis_status === 'OK') {
 | 
			
		||||
                    {{status.tasks.redis_url}}
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    {{status.tasks.redis_url}}: {{status.tasks.redis_error}}
 | 
			
		||||
                  }
 | 
			
		||||
                </ng-template>
 | 
			
		||||
              </dd>
 | 
			
		||||
              <dt i18n>Celery Status</dt>
 | 
			
		||||
              <dd>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="celeryStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
                  {{status.tasks.celery_status}}
 | 
			
		||||
                  @if (status.tasks.celery_status === 'OK') {
 | 
			
		||||
                    <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
 | 
			
		||||
                  }
 | 
			
		||||
                </div>
 | 
			
		||||
                <ng-template #celeryStatus>
 | 
			
		||||
                  @if (status.tasks.celery_status === 'OK') {
 | 
			
		||||
                    {{status.tasks.celery_url}}
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    {{status.tasks.celery_error}}
 | 
			
		||||
                  }
 | 
			
		||||
                </ng-template>
 | 
			
		||||
              </dd>
 | 
			
		||||
@@ -80,63 +139,79 @@
 | 
			
		||||
      <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>Health</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>
 | 
			
		||||
              <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>
 | 
			
		||||
              <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>
 | 
			
		||||
              <dd>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
                  {{status.tasks.index_status}}
 | 
			
		||||
                  @if (status.tasks.index_status === 'OK') {
 | 
			
		||||
                    @if (isStale(status.tasks.index_last_modified)) {
 | 
			
		||||
                      <i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></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>
 | 
			
		||||
                  }
 | 
			
		||||
                } @else {
 | 
			
		||||
                  <i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.index_error}}" triggers="mouseenter:mouseleave"></i-bs>
 | 
			
		||||
                }
 | 
			
		||||
                </div>
 | 
			
		||||
              </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>
 | 
			
		||||
              <dd>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
                  {{status.tasks.classifier_status}}
 | 
			
		||||
                  @if (status.tasks.classifier_status === 'OK') {
 | 
			
		||||
                    @if (isStale(status.tasks.classifier_last_trained)) {
 | 
			
		||||
                      <i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></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>
 | 
			
		||||
                  }
 | 
			
		||||
                </div>
 | 
			
		||||
              </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>
 | 
			
		||||
                <div class="badge text-uppercase bg-info text-dark" [ngbPopover]="sanityCheckerStatus" triggers="mouseenter:mouseleave">
 | 
			
		||||
                  {{status.tasks.sanity_check_status}}
 | 
			
		||||
                  @if (status.tasks.sanity_check_status === 'OK') {
 | 
			
		||||
                    @if (isStale(status.tasks.sanity_check_last_run)) {
 | 
			
		||||
                      <i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
 | 
			
		||||
                    } @else {
 | 
			
		||||
                      <i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
 | 
			
		||||
                    }
 | 
			
		||||
                  } @else {
 | 
			
		||||
                    <i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
 | 
			
		||||
                    [class.text-danger]="status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR"
 | 
			
		||||
                    [class.text-warning]="status.tasks.sanity_check_status === SystemStatusItemStatus.WARNING"></i-bs>
 | 
			
		||||
                  }
 | 
			
		||||
                </div>
 | 
			
		||||
              </dd>
 | 
			
		||||
              <ng-template #sanityCheckerStatus>
 | 
			
		||||
                @if (status.tasks.sanity_check_status === 'OK') {
 | 
			
		||||
                  <h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_last_run | customDate:'medium'}}</span>
 | 
			
		||||
                } @else {
 | 
			
		||||
                  <h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_error}}</span>
 | 
			
		||||
                }
 | 
			
		||||
              </ng-template>
 | 
			
		||||
            </dl>
 | 
			
		||||
          </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,3 @@
 | 
			
		||||
.border-primary {
 | 
			
		||||
  --bs-border-color: var(--bs-primary);
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -36,12 +36,17 @@ const status: SystemStatus = {
 | 
			
		||||
    redis_status: SystemStatusItemStatus.ERROR,
 | 
			
		||||
    redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
 | 
			
		||||
    celery_status: SystemStatusItemStatus.ERROR,
 | 
			
		||||
    celery_url: 'celery@localhost',
 | 
			
		||||
    celery_error: 'Error connecting to celery@localhost',
 | 
			
		||||
    index_status: SystemStatusItemStatus.OK,
 | 
			
		||||
    index_last_modified: new Date().toISOString(),
 | 
			
		||||
    index_error: null,
 | 
			
		||||
    classifier_status: SystemStatusItemStatus.OK,
 | 
			
		||||
    classifier_last_trained: new Date().toISOString(),
 | 
			
		||||
    classifier_error: null,
 | 
			
		||||
    sanity_check_status: SystemStatusItemStatus.OK,
 | 
			
		||||
    sanity_check_last_run: new Date().toISOString(),
 | 
			
		||||
    sanity_check_error: null,
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,15 @@
 | 
			
		||||
import { ObjectWithId } from './object-with-id'
 | 
			
		||||
 | 
			
		||||
export enum PaperlessTaskType {
 | 
			
		||||
  // just file tasks, for now
 | 
			
		||||
  File = 'file',
 | 
			
		||||
  Auto = 'auto_task',
 | 
			
		||||
  ScheduledTask = 'scheduled_task',
 | 
			
		||||
  ManualTask = 'manual_task',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum PaperlessTaskName {
 | 
			
		||||
  ConsumeFile = 'consume_file',
 | 
			
		||||
  TrainClassifier = 'train_classifier',
 | 
			
		||||
  SanityCheck = 'check_sanity',
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum PaperlessTaskStatus {
 | 
			
		||||
@@ -23,6 +30,8 @@ export interface PaperlessTask extends ObjectWithId {
 | 
			
		||||
 | 
			
		||||
  task_file_name: string
 | 
			
		||||
 | 
			
		||||
  task_name: PaperlessTaskName
 | 
			
		||||
 | 
			
		||||
  date_created: Date
 | 
			
		||||
 | 
			
		||||
  date_done?: Date
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -4,8 +4,8 @@ import { Subject } from 'rxjs'
 | 
			
		||||
import { first, takeUntil } from 'rxjs/operators'
 | 
			
		||||
import {
 | 
			
		||||
  PaperlessTask,
 | 
			
		||||
  PaperlessTaskName,
 | 
			
		||||
  PaperlessTaskStatus,
 | 
			
		||||
  PaperlessTaskType,
 | 
			
		||||
} from 'src/app/data/paperless-task'
 | 
			
		||||
import { environment } from 'src/environments/environment'
 | 
			
		||||
 | 
			
		||||
@@ -54,10 +54,14 @@ export class TasksService {
 | 
			
		||||
    this.loading = true
 | 
			
		||||
 | 
			
		||||
    this.http
 | 
			
		||||
      .get<PaperlessTask[]>(`${this.baseUrl}tasks/`)
 | 
			
		||||
      .get<PaperlessTask[]>(
 | 
			
		||||
        `${this.baseUrl}tasks/?task_name=consume_file&acknowledged=false`
 | 
			
		||||
      )
 | 
			
		||||
      .pipe(takeUntil(this.unsubscribeNotifer), first())
 | 
			
		||||
      .subscribe((r) => {
 | 
			
		||||
        this.fileTasks = r.filter((t) => t.type == PaperlessTaskType.File) // they're all File tasks, for now
 | 
			
		||||
        this.fileTasks = r.filter(
 | 
			
		||||
          (t) => t.task_name == PaperlessTaskName.ConsumeFile
 | 
			
		||||
        )
 | 
			
		||||
        this.loading = false
 | 
			
		||||
      })
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -21,10 +21,12 @@
 | 
			
		||||
  --pngx-success-darken-10: hsl(152, 69%, 11%); // based on success #198754
 | 
			
		||||
  --pngx-bg-alt: #fff;
 | 
			
		||||
  --pngx-bg-darker: var(--bs-gray-100);
 | 
			
		||||
  --pngx-bg-alt2: var(--bs-gray-200);
 | 
			
		||||
  --pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
 | 
			
		||||
  --pngx-bg-disabled: #f7f7f7;
 | 
			
		||||
  --pngx-focus-alpha: 0.3;
 | 
			
		||||
  --pngx-toast-max-width: 360px;
 | 
			
		||||
  --bs-info: var(--pngx-bg-alt2);
 | 
			
		||||
  --bs-info-rgb: 233, 236, 239;
 | 
			
		||||
  @media screen and (min-width: 1024px) {
 | 
			
		||||
    --pngx-toast-max-width: 450px;
 | 
			
		||||
  }
 | 
			
		||||
@@ -71,8 +73,15 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@mixin dark-mode {
 | 
			
		||||
  --bs-body-color: #{$text-color-dark-bg};
 | 
			
		||||
  --pngx-body-color-accent: #{$text-color-dark-bg-accent};
 | 
			
		||||
  --pngx-bg-alt: #242529;
 | 
			
		||||
  --pngx-bg-alt2: #232323;
 | 
			
		||||
  --pngx-bg-darker: #101216;
 | 
			
		||||
  --pngx-bg-disabled: var(--pngx-bg-alt);
 | 
			
		||||
  --pngx-focus-alpha: 0.6;
 | 
			
		||||
  --pngx-primary-faded: var(--pngx-primary-darken-15);
 | 
			
		||||
  --pngx-primary-text-contrast: var(--bs-body-color);
 | 
			
		||||
  --bs-body-color: #{$text-color-dark-bg};
 | 
			
		||||
  --bs-secondary-color: #6c757d;
 | 
			
		||||
  --bs-danger: #b71631;
 | 
			
		||||
  --bs-danger-rgb: 183, 22, 49;
 | 
			
		||||
@@ -80,15 +89,10 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
 | 
			
		||||
  --bs-body-bg-rgb: 22, 22, 24;
 | 
			
		||||
  --bs-light: #1c1c1f;
 | 
			
		||||
  --bs-light-rgb: 28, 28, 31;
 | 
			
		||||
  --bs-info: var(--pngx-bg-alt);
 | 
			
		||||
  --bs-info-rgb: 36, 36, 39;
 | 
			
		||||
  --bs-border-color: #47494f;
 | 
			
		||||
  --pngx-bg-alt2: #232323;
 | 
			
		||||
  --pngx-bg-darker: #101216;
 | 
			
		||||
  --bs-tertiary-bg: var(--pngx-bg-darker);
 | 
			
		||||
  --pngx-bg-alt: #242529;
 | 
			
		||||
  --pngx-bg-disabled: var(--pngx-bg-alt);
 | 
			
		||||
  --pngx-focus-alpha: 0.6;
 | 
			
		||||
  --pngx-primary-faded: var(--pngx-primary-darken-15);
 | 
			
		||||
  --pngx-primary-text-contrast: var(--bs-body-color);
 | 
			
		||||
  --bs-dark-border-subtle: var(--pngx-bg-darker);
 | 
			
		||||
  --bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
import logging
 | 
			
		||||
import pickle
 | 
			
		||||
import re
 | 
			
		||||
import time
 | 
			
		||||
import warnings
 | 
			
		||||
from collections.abc import Iterator
 | 
			
		||||
from hashlib import sha256
 | 
			
		||||
@@ -142,19 +141,6 @@ class DocumentClassifier:
 | 
			
		||||
                ):
 | 
			
		||||
                    raise IncompatibleClassifierVersionError("sklearn version update")
 | 
			
		||||
 | 
			
		||||
    def set_last_checked(self) -> None:
 | 
			
		||||
        # save a timestamp of the last time we checked for retraining to a file
 | 
			
		||||
        with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("w") as f:
 | 
			
		||||
            f.write(str(time.time()))
 | 
			
		||||
 | 
			
		||||
    def get_last_checked(self) -> float | None:
 | 
			
		||||
        # load the timestamp of the last time we checked for retraining
 | 
			
		||||
        try:
 | 
			
		||||
            with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("r") as f:
 | 
			
		||||
                return float(f.read())
 | 
			
		||||
        except FileNotFoundError:  # pragma: no cover
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    def save(self) -> None:
 | 
			
		||||
        target_file: Path = settings.MODEL_FILE
 | 
			
		||||
        target_file_temp: Path = target_file.with_suffix(".pickle.part")
 | 
			
		||||
@@ -175,7 +161,6 @@ class DocumentClassifier:
 | 
			
		||||
            pickle.dump(self.storage_path_classifier, f)
 | 
			
		||||
 | 
			
		||||
        target_file_temp.rename(target_file)
 | 
			
		||||
        self.set_last_checked()
 | 
			
		||||
 | 
			
		||||
    def train(self) -> bool:
 | 
			
		||||
        # Get non-inbox documents
 | 
			
		||||
@@ -244,7 +229,6 @@ class DocumentClassifier:
 | 
			
		||||
            and self.last_doc_change_time >= latest_doc_change
 | 
			
		||||
        ) and self.last_auto_type_hash == hasher.digest():
 | 
			
		||||
            logger.info("No updates since last training")
 | 
			
		||||
            self.set_last_checked()
 | 
			
		||||
            # Set the classifier information into the cache
 | 
			
		||||
            # Caching for 50 minutes, so slightly less than the normal retrain time
 | 
			
		||||
            cache.set(
 | 
			
		||||
 
 | 
			
		||||
@@ -35,6 +35,7 @@ from documents.models import CustomFieldInstance
 | 
			
		||||
from documents.models import Document
 | 
			
		||||
from documents.models import DocumentType
 | 
			
		||||
from documents.models import Log
 | 
			
		||||
from documents.models import PaperlessTask
 | 
			
		||||
from documents.models import ShareLink
 | 
			
		||||
from documents.models import StoragePath
 | 
			
		||||
from documents.models import Tag
 | 
			
		||||
@@ -770,6 +771,21 @@ class ShareLinkFilterSet(FilterSet):
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PaperlessTaskFilterSet(FilterSet):
 | 
			
		||||
    acknowledged = BooleanFilter(
 | 
			
		||||
        label="Acknowledged",
 | 
			
		||||
        field_name="acknowledged",
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = PaperlessTask
 | 
			
		||||
        fields = {
 | 
			
		||||
            "type": ["exact"],
 | 
			
		||||
            "task_name": ["exact"],
 | 
			
		||||
            "status": ["exact"],
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
 | 
			
		||||
    """
 | 
			
		||||
    A filter backend that limits results to those where the requesting user
 | 
			
		||||
 
 | 
			
		||||
@@ -10,4 +10,4 @@ class Command(BaseCommand):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def handle(self, *args, **options):
 | 
			
		||||
        train_classifier()
 | 
			
		||||
        train_classifier(scheduled=False)
 | 
			
		||||
 
 | 
			
		||||
@@ -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()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
# Generated by Django 5.1.6 on 2025-02-20 04:55
 | 
			
		||||
# Generated by Django 5.1.6 on 2025-02-21 16:34
 | 
			
		||||
 | 
			
		||||
import multiselectfield.db.fields
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
@@ -16,12 +16,51 @@ def update_workflow_sources(apps, schema_editor):
 | 
			
		||||
            trigger.save()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def make_existing_tasks_consume_auto(apps, schema_editor):
 | 
			
		||||
    PaperlessTask = apps.get_model("documents", "PaperlessTask")
 | 
			
		||||
    PaperlessTask.objects.all().update(type="auto_task", task_name="consume_file")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("documents", "1062_alter_savedviewfilterrule_rule_type"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="paperlesstask",
 | 
			
		||||
            name="type",
 | 
			
		||||
            field=models.CharField(
 | 
			
		||||
                choices=[
 | 
			
		||||
                    ("auto_task", "Auto Task"),
 | 
			
		||||
                    ("scheduled_task", "Scheduled Task"),
 | 
			
		||||
                    ("manual_task", "Manual Task"),
 | 
			
		||||
                ],
 | 
			
		||||
                default="auto_task",
 | 
			
		||||
                help_text="The type of task that was run",
 | 
			
		||||
                max_length=30,
 | 
			
		||||
                verbose_name="Task Type",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="paperlesstask",
 | 
			
		||||
            name="task_name",
 | 
			
		||||
            field=models.CharField(
 | 
			
		||||
                choices=[
 | 
			
		||||
                    ("consume_file", "Consume File"),
 | 
			
		||||
                    ("train_classifier", "Train Classifier"),
 | 
			
		||||
                    ("check_sanity", "Check Sanity"),
 | 
			
		||||
                ],
 | 
			
		||||
                help_text="Name of the task that was run",
 | 
			
		||||
                max_length=255,
 | 
			
		||||
                null=True,
 | 
			
		||||
                verbose_name="Task Name",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.RunPython(
 | 
			
		||||
            code=make_existing_tasks_consume_auto,
 | 
			
		||||
            reverse_code=migrations.RunPython.noop,
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="workflowactionwebhook",
 | 
			
		||||
            name="url",
 | 
			
		||||
@@ -650,6 +650,16 @@ class PaperlessTask(ModelWithOwner):
 | 
			
		||||
    ALL_STATES = sorted(states.ALL_STATES)
 | 
			
		||||
    TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
 | 
			
		||||
 | 
			
		||||
    class TaskType(models.TextChoices):
 | 
			
		||||
        AUTO = ("auto_task", _("Auto Task"))
 | 
			
		||||
        SCHEDULED_TASK = ("scheduled_task", _("Scheduled Task"))
 | 
			
		||||
        MANUAL_TASK = ("manual_task", _("Manual Task"))
 | 
			
		||||
 | 
			
		||||
    class TaskName(models.TextChoices):
 | 
			
		||||
        CONSUME_FILE = ("consume_file", _("Consume File"))
 | 
			
		||||
        TRAIN_CLASSIFIER = ("train_classifier", _("Train Classifier"))
 | 
			
		||||
        CHECK_SANITY = ("check_sanity", _("Check Sanity"))
 | 
			
		||||
 | 
			
		||||
    task_id = models.CharField(
 | 
			
		||||
        max_length=255,
 | 
			
		||||
        unique=True,
 | 
			
		||||
@@ -673,8 +683,9 @@ class PaperlessTask(ModelWithOwner):
 | 
			
		||||
    task_name = models.CharField(
 | 
			
		||||
        null=True,
 | 
			
		||||
        max_length=255,
 | 
			
		||||
        choices=TaskName.choices,
 | 
			
		||||
        verbose_name=_("Task Name"),
 | 
			
		||||
        help_text=_("Name of the Task which was run"),
 | 
			
		||||
        help_text=_("Name of the task that was run"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    status = models.CharField(
 | 
			
		||||
@@ -684,24 +695,28 @@ class PaperlessTask(ModelWithOwner):
 | 
			
		||||
        verbose_name=_("Task State"),
 | 
			
		||||
        help_text=_("Current state of the task being run"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    date_created = models.DateTimeField(
 | 
			
		||||
        null=True,
 | 
			
		||||
        default=timezone.now,
 | 
			
		||||
        verbose_name=_("Created DateTime"),
 | 
			
		||||
        help_text=_("Datetime field when the task result was created in UTC"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    date_started = models.DateTimeField(
 | 
			
		||||
        null=True,
 | 
			
		||||
        default=None,
 | 
			
		||||
        verbose_name=_("Started DateTime"),
 | 
			
		||||
        help_text=_("Datetime field when the task was started in UTC"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    date_done = models.DateTimeField(
 | 
			
		||||
        null=True,
 | 
			
		||||
        default=None,
 | 
			
		||||
        verbose_name=_("Completed DateTime"),
 | 
			
		||||
        help_text=_("Datetime field when the task was completed in UTC"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    result = models.TextField(
 | 
			
		||||
        null=True,
 | 
			
		||||
        default=None,
 | 
			
		||||
@@ -711,6 +726,14 @@ class PaperlessTask(ModelWithOwner):
 | 
			
		||||
        ),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    type = models.CharField(
 | 
			
		||||
        max_length=30,
 | 
			
		||||
        choices=TaskType.choices,
 | 
			
		||||
        default=TaskType.AUTO,
 | 
			
		||||
        verbose_name=_("Task Type"),
 | 
			
		||||
        help_text=_("The type of task that was run"),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        return f"Task {self.task_id}"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -1704,6 +1704,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
 | 
			
		||||
        fields = (
 | 
			
		||||
            "id",
 | 
			
		||||
            "task_id",
 | 
			
		||||
            "task_name",
 | 
			
		||||
            "task_file_name",
 | 
			
		||||
            "date_created",
 | 
			
		||||
            "date_done",
 | 
			
		||||
@@ -1715,12 +1716,6 @@ class TasksViewSerializer(OwnedObjectSerializer):
 | 
			
		||||
            "owner",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    type = serializers.SerializerMethodField()
 | 
			
		||||
 | 
			
		||||
    def get_type(self, obj) -> str:
 | 
			
		||||
        # just file tasks, for now
 | 
			
		||||
        return "file"
 | 
			
		||||
 | 
			
		||||
    related_document = serializers.SerializerMethodField()
 | 
			
		||||
    created_doc_re = re.compile(r"New document id (\d+) created")
 | 
			
		||||
    duplicate_doc_re = re.compile(r"It is a duplicate of .* \(#(\d+)\)")
 | 
			
		||||
@@ -1728,20 +1723,21 @@ 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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1221,10 +1221,11 @@ def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
 | 
			
		||||
        user_id = overrides.owner_id if overrides else None
 | 
			
		||||
 | 
			
		||||
        PaperlessTask.objects.create(
 | 
			
		||||
            type=PaperlessTask.TaskType.AUTO,
 | 
			
		||||
            task_id=headers["id"],
 | 
			
		||||
            status=states.PENDING,
 | 
			
		||||
            task_file_name=task_file_name,
 | 
			
		||||
            task_name=headers["task"],
 | 
			
		||||
            task_name=PaperlessTask.TaskName.CONSUME_FILE,
 | 
			
		||||
            result=None,
 | 
			
		||||
            date_created=timezone.now(),
 | 
			
		||||
            date_started=None,
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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"])
 | 
			
		||||
 
 | 
			
		||||
@@ -130,7 +130,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_200_OK)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(self.ENDPOINT)
 | 
			
		||||
        response = self.client.get(self.ENDPOINT + "?acknowledged=false")
 | 
			
		||||
        self.assertEqual(len(response.data), 0)
 | 
			
		||||
 | 
			
		||||
    def test_tasks_owner_aware(self):
 | 
			
		||||
@@ -246,7 +246,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
 | 
			
		||||
        PaperlessTask.objects.create(
 | 
			
		||||
            task_id=str(uuid.uuid4()),
 | 
			
		||||
            task_file_name="test.pdf",
 | 
			
		||||
            task_name="documents.tasks.some_task",
 | 
			
		||||
            task_name=PaperlessTask.TaskName.CONSUME_FILE,
 | 
			
		||||
            status=celery.states.SUCCESS,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@@ -272,7 +272,7 @@ class TestTasks(DirectoriesMixin, APITestCase):
 | 
			
		||||
        PaperlessTask.objects.create(
 | 
			
		||||
            task_id=str(uuid.uuid4()),
 | 
			
		||||
            task_file_name="anothertest.pdf",
 | 
			
		||||
            task_name="documents.tasks.some_task",
 | 
			
		||||
            task_name=PaperlessTask.TaskName.CONSUME_FILE,
 | 
			
		||||
            status=celery.states.SUCCESS,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -15,6 +15,7 @@ from urllib.parse import quote
 | 
			
		||||
from urllib.parse import urlparse
 | 
			
		||||
 | 
			
		||||
import pathvalidate
 | 
			
		||||
from celery import states
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.contrib.auth.models import Group
 | 
			
		||||
from django.contrib.auth.models import User
 | 
			
		||||
@@ -103,6 +104,7 @@ from documents.filters import DocumentsOrderingFilter
 | 
			
		||||
from documents.filters import DocumentTypeFilterSet
 | 
			
		||||
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
 | 
			
		||||
from documents.filters import ObjectOwnedPermissionsFilter
 | 
			
		||||
from documents.filters import PaperlessTaskFilterSet
 | 
			
		||||
from documents.filters import ShareLinkFilterSet
 | 
			
		||||
from documents.filters import StoragePathFilterSet
 | 
			
		||||
from documents.filters import TagFilterSet
 | 
			
		||||
@@ -2224,16 +2226,15 @@ class RemoteVersionView(GenericAPIView):
 | 
			
		||||
class TasksViewSet(ReadOnlyModelViewSet):
 | 
			
		||||
    permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
 | 
			
		||||
    serializer_class = TasksViewSerializer
 | 
			
		||||
    filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
 | 
			
		||||
    filter_backends = (
 | 
			
		||||
        DjangoFilterBackend,
 | 
			
		||||
        OrderingFilter,
 | 
			
		||||
        ObjectOwnedOrGrantedPermissionsFilter,
 | 
			
		||||
    )
 | 
			
		||||
    filterset_class = PaperlessTaskFilterSet
 | 
			
		||||
 | 
			
		||||
    def get_queryset(self):
 | 
			
		||||
        queryset = (
 | 
			
		||||
            PaperlessTask.objects.filter(
 | 
			
		||||
                acknowledged=False,
 | 
			
		||||
            )
 | 
			
		||||
            .order_by("date_created")
 | 
			
		||||
            .reverse()
 | 
			
		||||
        )
 | 
			
		||||
        queryset = PaperlessTask.objects.all().order_by("-date_created")
 | 
			
		||||
        task_id = self.request.query_params.get("task_id")
 | 
			
		||||
        if task_id is not None:
 | 
			
		||||
            queryset = PaperlessTask.objects.filter(task_id=task_id)
 | 
			
		||||
@@ -2562,6 +2563,14 @@ class CustomFieldViewSet(ModelViewSet):
 | 
			
		||||
                            "last_trained": serializers.DateTimeField(),
 | 
			
		||||
                        },
 | 
			
		||||
                    ),
 | 
			
		||||
                    "sanity_check": inline_serializer(
 | 
			
		||||
                        name="SanityCheck",
 | 
			
		||||
                        fields={
 | 
			
		||||
                            "status": serializers.CharField(),
 | 
			
		||||
                            "error": serializers.CharField(),
 | 
			
		||||
                            "last_run": serializers.DateTimeField(),
 | 
			
		||||
                        },
 | 
			
		||||
                    ),
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
        },
 | 
			
		||||
@@ -2570,6 +2579,17 @@ class CustomFieldViewSet(ModelViewSet):
 | 
			
		||||
class SystemStatusView(PassUserMixin):
 | 
			
		||||
    permission_classes = (IsAuthenticated,)
 | 
			
		||||
 | 
			
		||||
    def _get_next_scheduled_task_schedule(
 | 
			
		||||
        self,
 | 
			
		||||
        schedule: dict,
 | 
			
		||||
        task_name: str,
 | 
			
		||||
        last_run,
 | 
			
		||||
    ) -> datetime | None:
 | 
			
		||||
        # example: {'Check all e-mail accounts': {'task': 'paperless_mail.tasks.process_mail_accounts', 'schedule': <crontab: */10 * * * * (m/h/dM/MY/d)>, 'options': {'expires': 540.0}}, 'Train the classifier': {'task': 'documents.tasks.train_classifier', 'schedule': <crontab: 5 */1 * * * (m/h/dM/MY/d)>, 'options': {'expires': 3540.0}}, 'Optimize the index': {'task': 'documents.tasks.index_optimize', 'schedule': <crontab: 0 0 * * * (m/h/dM/MY/d)>, 'options': {'expires': 82800.0}}, 'Perform sanity check': {'task': 'documents.tasks.sanity_check', 'schedule': <crontab: 30 0 * * sun (m/h/dM/MY/d)>, 'options': {'expires': 601200.0}}, 'Empty trash': {'task': 'documents.tasks.empty_trash', 'schedule': <crontab: 0 1 * * * (m/h/dM/MY/d)>, 'options': {'expires': 82800.0}}, 'Check and run scheduled workflows': {'task': 'documents.tasks.check_scheduled_workflows', 'schedule': <crontab: 5 */1 * * * (m/h/dM/MY/d)>, 'options': {'expires': 3540.0}}}
 | 
			
		||||
        for _, task_data in schedule.items():
 | 
			
		||||
            if task_data["task"] and task_data["task"].find(task_name) != -1:
 | 
			
		||||
                return task_data["schedule"]
 | 
			
		||||
 | 
			
		||||
    def get(self, request, format=None):
 | 
			
		||||
        if not request.user.is_staff:
 | 
			
		||||
            return HttpResponseForbidden("Insufficient permissions")
 | 
			
		||||
@@ -2622,13 +2642,22 @@ class SystemStatusView(PassUserMixin):
 | 
			
		||||
                )
 | 
			
		||||
                redis_error = "Error connecting to redis, check logs for more detail."
 | 
			
		||||
 | 
			
		||||
        celery_error = None
 | 
			
		||||
        celery_url = None
 | 
			
		||||
        schedule = None
 | 
			
		||||
        try:
 | 
			
		||||
            celery_ping = celery_app.control.inspect().ping()
 | 
			
		||||
            first_worker_ping = celery_ping[next(iter(celery_ping.keys()))]
 | 
			
		||||
            celery_url = next(iter(celery_ping.keys()))
 | 
			
		||||
            first_worker_ping = celery_ping[celery_url]
 | 
			
		||||
            schedule = celery_app.conf.beat_schedule
 | 
			
		||||
            if first_worker_ping["ok"] == "pong":
 | 
			
		||||
                celery_active = "OK"
 | 
			
		||||
        except Exception:
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            celery_active = "ERROR"
 | 
			
		||||
            logger.exception(
 | 
			
		||||
                f"System status detected a possible problem while connecting to celery: {e}",
 | 
			
		||||
            )
 | 
			
		||||
            celery_error = "Error connecting to celery, check logs for more detail."
 | 
			
		||||
 | 
			
		||||
        index_error = None
 | 
			
		||||
        try:
 | 
			
		||||
@@ -2645,54 +2674,72 @@ class SystemStatusView(PassUserMixin):
 | 
			
		||||
            )
 | 
			
		||||
            index_last_modified = None
 | 
			
		||||
 | 
			
		||||
        classifier_error = None
 | 
			
		||||
        classifier_status = None
 | 
			
		||||
        try:
 | 
			
		||||
            classifier = load_classifier(raise_exception=True)
 | 
			
		||||
            if classifier is None:
 | 
			
		||||
                # Make sure classifier should exist
 | 
			
		||||
                docs_queryset = Document.objects.exclude(
 | 
			
		||||
                    tags__is_inbox_tag=True,
 | 
			
		||||
                )
 | 
			
		||||
                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
 | 
			
		||||
        classifier_next_training = None
 | 
			
		||||
        if last_trained_task is None:
 | 
			
		||||
            classifier_status = "WARNING"
 | 
			
		||||
            classifier_error = "No classifier training tasks found"
 | 
			
		||||
        elif last_trained_task and last_trained_task.status == states.FAILURE:
 | 
			
		||||
            classifier_status = "ERROR"
 | 
			
		||||
            classifier_error = last_trained_task.result
 | 
			
		||||
        classifier_last_trained = (
 | 
			
		||||
            last_trained_task.date_done if last_trained_task else None
 | 
			
		||||
        )
 | 
			
		||||
        last_scheduled_trained_task = (
 | 
			
		||||
            PaperlessTask.objects.filter(
 | 
			
		||||
                task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
 | 
			
		||||
                type=PaperlessTask.TaskType.SCHEDULED_TASK,
 | 
			
		||||
            )
 | 
			
		||||
            .order_by("-date_done")
 | 
			
		||||
            .first()
 | 
			
		||||
        )
 | 
			
		||||
        if last_scheduled_trained_task and schedule:
 | 
			
		||||
            classifier_next_training: datetime = self._get_next_scheduled_task_schedule(
 | 
			
		||||
                schedule=schedule,
 | 
			
		||||
                task_name=PaperlessTask.TaskName.TRAIN_CLASSIFIER,
 | 
			
		||||
                last_run=last_trained_task.date_done,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        last_sanity_check = (
 | 
			
		||||
            PaperlessTask.objects.filter(
 | 
			
		||||
                task_name=PaperlessTask.TaskName.CHECK_SANITY,
 | 
			
		||||
            )
 | 
			
		||||
            .order_by("-date_done")
 | 
			
		||||
            .first()
 | 
			
		||||
        )
 | 
			
		||||
        sanity_check_status = "OK"
 | 
			
		||||
        sanity_check_error = None
 | 
			
		||||
        sanity_check_next_run = None
 | 
			
		||||
        if last_sanity_check is None:
 | 
			
		||||
            sanity_check_status = "WARNING"
 | 
			
		||||
            sanity_check_error = "No sanity check tasks found"
 | 
			
		||||
        elif last_sanity_check and last_sanity_check.status == states.FAILURE:
 | 
			
		||||
            sanity_check_status = "ERROR"
 | 
			
		||||
            sanity_check_error = last_sanity_check.result
 | 
			
		||||
        sanity_check_last_run = (
 | 
			
		||||
            last_sanity_check.date_done if last_sanity_check else None
 | 
			
		||||
        )
 | 
			
		||||
        last_scheduled_sanity_check = (
 | 
			
		||||
            PaperlessTask.objects.filter(
 | 
			
		||||
                task_name=PaperlessTask.TaskName.CHECK_SANITY,
 | 
			
		||||
                type=PaperlessTask.TaskType.SCHEDULED_TASK,
 | 
			
		||||
            )
 | 
			
		||||
            .order_by("-date_done")
 | 
			
		||||
            .first()
 | 
			
		||||
        )
 | 
			
		||||
        if last_scheduled_sanity_check and schedule:
 | 
			
		||||
            sanity_check_next_run: datetime = self._get_next_scheduled_task_schedule(
 | 
			
		||||
                schedule=schedule,
 | 
			
		||||
                task_name=PaperlessTask.TaskName.CHECK_SANITY,
 | 
			
		||||
                last_run=last_sanity_check.date_done,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        return Response(
 | 
			
		||||
@@ -2721,12 +2768,19 @@ class SystemStatusView(PassUserMixin):
 | 
			
		||||
                    "redis_status": redis_status,
 | 
			
		||||
                    "redis_error": redis_error,
 | 
			
		||||
                    "celery_status": celery_active,
 | 
			
		||||
                    "celery_url": celery_url,
 | 
			
		||||
                    "celery_error": celery_error,
 | 
			
		||||
                    "index_status": index_status,
 | 
			
		||||
                    "index_last_modified": index_last_modified,
 | 
			
		||||
                    "index_error": index_error,
 | 
			
		||||
                    "classifier_status": classifier_status,
 | 
			
		||||
                    "classifier_last_trained": classifier_last_trained,
 | 
			
		||||
                    "classifier_next_training": classifier_next_training,
 | 
			
		||||
                    "classifier_error": classifier_error,
 | 
			
		||||
                    "sanity_check_status": sanity_check_status,
 | 
			
		||||
                    "sanity_check_last_run": sanity_check_last_run,
 | 
			
		||||
                    "sanity_check_next_run": sanity_check_next_run,
 | 
			
		||||
                    "sanity_check_error": sanity_check_error,
 | 
			
		||||
                },
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: paperless-ngx\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: \n"
 | 
			
		||||
"POT-Creation-Date: 2025-02-11 18:43-0800\n"
 | 
			
		||||
"POT-Creation-Date: 2025-02-14 15:45-0800\n"
 | 
			
		||||
"PO-Revision-Date: 2022-02-17 04:17\n"
 | 
			
		||||
"Last-Translator: \n"
 | 
			
		||||
"Language-Team: English\n"
 | 
			
		||||
@@ -21,67 +21,67 @@ msgstr ""
 | 
			
		||||
msgid "Documents"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/filters.py:369
 | 
			
		||||
#: documents/filters.py:370
 | 
			
		||||
msgid "Value must be valid JSON."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/filters.py:388
 | 
			
		||||
#: documents/filters.py:389
 | 
			
		||||
msgid "Invalid custom field query expression"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/filters.py:398
 | 
			
		||||
#: documents/filters.py:399
 | 
			
		||||
msgid "Invalid expression list. Must be nonempty."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/filters.py:419
 | 
			
		||||
#: documents/filters.py:420
 | 
			
		||||
msgid "Invalid logical operator {op!r}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/filters.py:433
 | 
			
		||||
#: documents/filters.py:434
 | 
			
		||||
msgid "Maximum number of query conditions exceeded."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/filters.py:498
 | 
			
		||||
#: documents/filters.py:499
 | 
			
		||||
msgid "{name!r} is not a valid custom field."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/filters.py:535
 | 
			
		||||
#: documents/filters.py:536
 | 
			
		||||
msgid "{data_type} does not support query expr {expr!r}."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/filters.py:643
 | 
			
		||||
#: documents/filters.py:644
 | 
			
		||||
msgid "Maximum nesting depth exceeded."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/filters.py:813
 | 
			
		||||
#: documents/filters.py:829
 | 
			
		||||
msgid "Custom field not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:41 documents/models.py:806
 | 
			
		||||
#: documents/models.py:41 documents/models.py:829
 | 
			
		||||
msgid "owner"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:58 documents/models.py:1017
 | 
			
		||||
#: documents/models.py:58 documents/models.py:1040
 | 
			
		||||
msgid "None"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:59 documents/models.py:1018
 | 
			
		||||
#: documents/models.py:59 documents/models.py:1041
 | 
			
		||||
msgid "Any word"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:60 documents/models.py:1019
 | 
			
		||||
#: documents/models.py:60 documents/models.py:1042
 | 
			
		||||
msgid "All words"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:61 documents/models.py:1020
 | 
			
		||||
#: documents/models.py:61 documents/models.py:1043
 | 
			
		||||
msgid "Exact match"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:62 documents/models.py:1021
 | 
			
		||||
#: documents/models.py:62 documents/models.py:1044
 | 
			
		||||
msgid "Regular expression"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:63 documents/models.py:1022
 | 
			
		||||
#: documents/models.py:63 documents/models.py:1045
 | 
			
		||||
msgid "Fuzzy word"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -89,20 +89,20 @@ msgstr ""
 | 
			
		||||
msgid "Automatic"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:67 documents/models.py:433 documents/models.py:1498
 | 
			
		||||
#: documents/models.py:67 documents/models.py:433 documents/models.py:1521
 | 
			
		||||
#: paperless_mail/models.py:23 paperless_mail/models.py:143
 | 
			
		||||
msgid "name"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:69 documents/models.py:1085
 | 
			
		||||
#: documents/models.py:69 documents/models.py:1108
 | 
			
		||||
msgid "match"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:72 documents/models.py:1088
 | 
			
		||||
#: documents/models.py:72 documents/models.py:1111
 | 
			
		||||
msgid "matching algorithm"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:77 documents/models.py:1093
 | 
			
		||||
#: documents/models.py:77 documents/models.py:1116
 | 
			
		||||
msgid "is insensitive"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -168,7 +168,7 @@ msgstr ""
 | 
			
		||||
msgid "title"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:175 documents/models.py:720
 | 
			
		||||
#: documents/models.py:175 documents/models.py:743
 | 
			
		||||
msgid "content"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -206,8 +206,8 @@ msgstr ""
 | 
			
		||||
msgid "The number of pages of the document."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:221 documents/models.py:401 documents/models.py:726
 | 
			
		||||
#: documents/models.py:764 documents/models.py:835 documents/models.py:893
 | 
			
		||||
#: documents/models.py:221 documents/models.py:401 documents/models.py:749
 | 
			
		||||
#: documents/models.py:787 documents/models.py:858 documents/models.py:916
 | 
			
		||||
msgid "created"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -255,8 +255,8 @@ msgstr ""
 | 
			
		||||
msgid "The position of this document in your physical document archive."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:295 documents/models.py:737 documents/models.py:791
 | 
			
		||||
#: documents/models.py:1541
 | 
			
		||||
#: documents/models.py:295 documents/models.py:760 documents/models.py:814
 | 
			
		||||
#: documents/models.py:1564
 | 
			
		||||
msgid "document"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -320,11 +320,11 @@ msgstr ""
 | 
			
		||||
msgid "Title"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:420 documents/models.py:1037
 | 
			
		||||
#: documents/models.py:420 documents/models.py:1060
 | 
			
		||||
msgid "Created"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:421 documents/models.py:1036
 | 
			
		||||
#: documents/models.py:421 documents/models.py:1059
 | 
			
		||||
msgid "Added"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -608,563 +608,595 @@ msgstr ""
 | 
			
		||||
msgid "filter rules"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:654
 | 
			
		||||
msgid "Auto Task"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:655
 | 
			
		||||
msgid "Scheduled Task"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:656
 | 
			
		||||
msgid "Manual Task"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:659
 | 
			
		||||
msgid "Consume File"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:660
 | 
			
		||||
msgid "Train Classifier"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:661
 | 
			
		||||
msgid "Check Sanity"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:666
 | 
			
		||||
msgid "Task ID"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:657
 | 
			
		||||
#: documents/models.py:667
 | 
			
		||||
msgid "Celery ID for the Task that was run"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:662
 | 
			
		||||
#: documents/models.py:672
 | 
			
		||||
msgid "Acknowledged"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:663
 | 
			
		||||
#: documents/models.py:673
 | 
			
		||||
msgid "If the task is acknowledged via the frontend or API"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:669
 | 
			
		||||
#: documents/models.py:679
 | 
			
		||||
msgid "Task Filename"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:670
 | 
			
		||||
#: documents/models.py:680
 | 
			
		||||
msgid "Name of the file which the Task was run for"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:676
 | 
			
		||||
#: documents/models.py:687
 | 
			
		||||
msgid "Task Name"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:677
 | 
			
		||||
msgid "Name of the Task which was run"
 | 
			
		||||
#: documents/models.py:688
 | 
			
		||||
msgid "Name of the task that was run"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:684
 | 
			
		||||
#: documents/models.py:695
 | 
			
		||||
msgid "Task State"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:685
 | 
			
		||||
#: documents/models.py:696
 | 
			
		||||
msgid "Current state of the task being run"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:690
 | 
			
		||||
#: documents/models.py:702
 | 
			
		||||
msgid "Created DateTime"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:691
 | 
			
		||||
#: documents/models.py:703
 | 
			
		||||
msgid "Datetime field when the task result was created in UTC"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:696
 | 
			
		||||
#: documents/models.py:709
 | 
			
		||||
msgid "Started DateTime"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:697
 | 
			
		||||
#: documents/models.py:710
 | 
			
		||||
msgid "Datetime field when the task was started in UTC"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:702
 | 
			
		||||
#: documents/models.py:716
 | 
			
		||||
msgid "Completed DateTime"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:703
 | 
			
		||||
#: documents/models.py:717
 | 
			
		||||
msgid "Datetime field when the task was completed in UTC"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:708
 | 
			
		||||
#: documents/models.py:723
 | 
			
		||||
msgid "Result Data"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:710
 | 
			
		||||
#: documents/models.py:725
 | 
			
		||||
msgid "The data returned by the task"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:722
 | 
			
		||||
#: documents/models.py:733
 | 
			
		||||
msgid "Task Type"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:734
 | 
			
		||||
msgid "The type of task that was run"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:745
 | 
			
		||||
msgid "Note for the document"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:746
 | 
			
		||||
#: documents/models.py:769
 | 
			
		||||
msgid "user"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:751
 | 
			
		||||
#: documents/models.py:774
 | 
			
		||||
msgid "note"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:752
 | 
			
		||||
#: documents/models.py:775
 | 
			
		||||
msgid "notes"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:760
 | 
			
		||||
#: documents/models.py:783
 | 
			
		||||
msgid "Archive"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:761
 | 
			
		||||
#: documents/models.py:784
 | 
			
		||||
msgid "Original"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:772 paperless_mail/models.py:75
 | 
			
		||||
#: documents/models.py:795 paperless_mail/models.py:75
 | 
			
		||||
msgid "expiration"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:779
 | 
			
		||||
#: documents/models.py:802
 | 
			
		||||
msgid "slug"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:811
 | 
			
		||||
#: documents/models.py:834
 | 
			
		||||
msgid "share link"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:812
 | 
			
		||||
#: documents/models.py:835
 | 
			
		||||
msgid "share links"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:824
 | 
			
		||||
#: documents/models.py:847
 | 
			
		||||
msgid "String"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:825
 | 
			
		||||
#: documents/models.py:848
 | 
			
		||||
msgid "URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:826
 | 
			
		||||
#: documents/models.py:849
 | 
			
		||||
msgid "Date"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:827
 | 
			
		||||
#: documents/models.py:850
 | 
			
		||||
msgid "Boolean"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:828
 | 
			
		||||
#: documents/models.py:851
 | 
			
		||||
msgid "Integer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:829
 | 
			
		||||
#: documents/models.py:852
 | 
			
		||||
msgid "Float"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:830
 | 
			
		||||
#: documents/models.py:853
 | 
			
		||||
msgid "Monetary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:831
 | 
			
		||||
#: documents/models.py:854
 | 
			
		||||
msgid "Document Link"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:832
 | 
			
		||||
#: documents/models.py:855
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:844
 | 
			
		||||
#: documents/models.py:867
 | 
			
		||||
msgid "data type"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:851
 | 
			
		||||
#: documents/models.py:874
 | 
			
		||||
msgid "extra data"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:855
 | 
			
		||||
#: documents/models.py:878
 | 
			
		||||
msgid "Extra data for the custom field, such as select options"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:861
 | 
			
		||||
#: documents/models.py:884
 | 
			
		||||
msgid "custom field"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:862
 | 
			
		||||
#: documents/models.py:885
 | 
			
		||||
msgid "custom fields"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:959
 | 
			
		||||
#: documents/models.py:982
 | 
			
		||||
msgid "custom field instance"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:960
 | 
			
		||||
#: documents/models.py:983
 | 
			
		||||
msgid "custom field instances"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1025
 | 
			
		||||
#: documents/models.py:1048
 | 
			
		||||
msgid "Consumption Started"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1026
 | 
			
		||||
#: documents/models.py:1049
 | 
			
		||||
msgid "Document Added"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1027
 | 
			
		||||
#: documents/models.py:1050
 | 
			
		||||
msgid "Document Updated"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1028
 | 
			
		||||
#: documents/models.py:1051
 | 
			
		||||
msgid "Scheduled"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1031
 | 
			
		||||
#: documents/models.py:1054
 | 
			
		||||
msgid "Consume Folder"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1032
 | 
			
		||||
#: documents/models.py:1055
 | 
			
		||||
msgid "Api Upload"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1033
 | 
			
		||||
#: documents/models.py:1056
 | 
			
		||||
msgid "Mail Fetch"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1038
 | 
			
		||||
#: documents/models.py:1061
 | 
			
		||||
msgid "Modified"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1039
 | 
			
		||||
#: documents/models.py:1062
 | 
			
		||||
msgid "Custom Field"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1042
 | 
			
		||||
#: documents/models.py:1065
 | 
			
		||||
msgid "Workflow Trigger Type"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1054
 | 
			
		||||
#: documents/models.py:1077
 | 
			
		||||
msgid "filter path"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1059
 | 
			
		||||
#: documents/models.py:1082
 | 
			
		||||
msgid ""
 | 
			
		||||
"Only consume documents with a path that matches this if specified. Wildcards "
 | 
			
		||||
"specified as * are allowed. Case insensitive."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1066
 | 
			
		||||
#: documents/models.py:1089
 | 
			
		||||
msgid "filter filename"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1071 paperless_mail/models.py:200
 | 
			
		||||
#: documents/models.py:1094 paperless_mail/models.py:200
 | 
			
		||||
msgid ""
 | 
			
		||||
"Only consume documents which entirely match this filename if specified. "
 | 
			
		||||
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1082
 | 
			
		||||
#: documents/models.py:1105
 | 
			
		||||
msgid "filter documents from this mail rule"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1098
 | 
			
		||||
#: documents/models.py:1121
 | 
			
		||||
msgid "has these tag(s)"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1106
 | 
			
		||||
#: documents/models.py:1129
 | 
			
		||||
msgid "has this document type"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1114
 | 
			
		||||
#: documents/models.py:1137
 | 
			
		||||
msgid "has this correspondent"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1118
 | 
			
		||||
#: documents/models.py:1141
 | 
			
		||||
msgid "schedule offset days"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1121
 | 
			
		||||
#: documents/models.py:1144
 | 
			
		||||
msgid "The number of days to offset the schedule trigger by."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1126
 | 
			
		||||
#: documents/models.py:1149
 | 
			
		||||
msgid "schedule is recurring"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1129
 | 
			
		||||
#: documents/models.py:1152
 | 
			
		||||
msgid "If the schedule should be recurring."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1134
 | 
			
		||||
#: documents/models.py:1157
 | 
			
		||||
msgid "schedule recurring delay in days"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1138
 | 
			
		||||
#: documents/models.py:1161
 | 
			
		||||
msgid "The number of days between recurring schedule triggers."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1143
 | 
			
		||||
#: documents/models.py:1166
 | 
			
		||||
msgid "schedule date field"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1148
 | 
			
		||||
#: documents/models.py:1171
 | 
			
		||||
msgid "The field to check for a schedule trigger."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1157
 | 
			
		||||
#: documents/models.py:1180
 | 
			
		||||
msgid "schedule date custom field"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1161
 | 
			
		||||
#: documents/models.py:1184
 | 
			
		||||
msgid "workflow trigger"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1162
 | 
			
		||||
#: documents/models.py:1185
 | 
			
		||||
msgid "workflow triggers"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1170
 | 
			
		||||
#: documents/models.py:1193
 | 
			
		||||
msgid "email subject"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1174
 | 
			
		||||
#: documents/models.py:1197
 | 
			
		||||
msgid ""
 | 
			
		||||
"The subject of the email, can include some placeholders, see documentation."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1180
 | 
			
		||||
#: documents/models.py:1203
 | 
			
		||||
msgid "email body"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1183
 | 
			
		||||
#: documents/models.py:1206
 | 
			
		||||
msgid ""
 | 
			
		||||
"The body (message) of the email, can include some placeholders, see "
 | 
			
		||||
"documentation."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1189
 | 
			
		||||
#: documents/models.py:1212
 | 
			
		||||
msgid "emails to"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1192
 | 
			
		||||
#: documents/models.py:1215
 | 
			
		||||
msgid "The destination email addresses, comma separated."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1198
 | 
			
		||||
#: documents/models.py:1221
 | 
			
		||||
msgid "include document in email"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1207
 | 
			
		||||
#: documents/models.py:1230
 | 
			
		||||
msgid "webhook url"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1209
 | 
			
		||||
#: documents/models.py:1232
 | 
			
		||||
msgid "The destination URL for the notification."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1214
 | 
			
		||||
#: documents/models.py:1237
 | 
			
		||||
msgid "use parameters"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1219
 | 
			
		||||
#: documents/models.py:1242
 | 
			
		||||
msgid "send as JSON"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1223
 | 
			
		||||
#: documents/models.py:1246
 | 
			
		||||
msgid "webhook parameters"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1226
 | 
			
		||||
#: documents/models.py:1249
 | 
			
		||||
msgid "The parameters to send with the webhook URL if body not used."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1230
 | 
			
		||||
#: documents/models.py:1253
 | 
			
		||||
msgid "webhook body"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1233
 | 
			
		||||
#: documents/models.py:1256
 | 
			
		||||
msgid "The body to send with the webhook URL if parameters not used."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1237
 | 
			
		||||
#: documents/models.py:1260
 | 
			
		||||
msgid "webhook headers"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1240
 | 
			
		||||
#: documents/models.py:1263
 | 
			
		||||
msgid "The headers to send with the webhook URL."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1245
 | 
			
		||||
#: documents/models.py:1268
 | 
			
		||||
msgid "include document in webhook"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1256
 | 
			
		||||
#: documents/models.py:1279
 | 
			
		||||
msgid "Assignment"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1260
 | 
			
		||||
#: documents/models.py:1283
 | 
			
		||||
msgid "Removal"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1264 documents/templates/account/password_reset.html:15
 | 
			
		||||
#: documents/models.py:1287 documents/templates/account/password_reset.html:15
 | 
			
		||||
msgid "Email"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1268
 | 
			
		||||
#: documents/models.py:1291
 | 
			
		||||
msgid "Webhook"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1272
 | 
			
		||||
#: documents/models.py:1295
 | 
			
		||||
msgid "Workflow Action Type"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1278
 | 
			
		||||
#: documents/models.py:1301
 | 
			
		||||
msgid "assign title"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1283
 | 
			
		||||
#: documents/models.py:1306
 | 
			
		||||
msgid ""
 | 
			
		||||
"Assign a document title, can include some placeholders, see documentation."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1292 paperless_mail/models.py:274
 | 
			
		||||
#: documents/models.py:1315 paperless_mail/models.py:274
 | 
			
		||||
msgid "assign this tag"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1301 paperless_mail/models.py:282
 | 
			
		||||
#: documents/models.py:1324 paperless_mail/models.py:282
 | 
			
		||||
msgid "assign this document type"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1310 paperless_mail/models.py:296
 | 
			
		||||
#: documents/models.py:1333 paperless_mail/models.py:296
 | 
			
		||||
msgid "assign this correspondent"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1319
 | 
			
		||||
#: documents/models.py:1342
 | 
			
		||||
msgid "assign this storage path"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1328
 | 
			
		||||
#: documents/models.py:1351
 | 
			
		||||
msgid "assign this owner"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1335
 | 
			
		||||
#: documents/models.py:1358
 | 
			
		||||
msgid "grant view permissions to these users"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1342
 | 
			
		||||
#: documents/models.py:1365
 | 
			
		||||
msgid "grant view permissions to these groups"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1349
 | 
			
		||||
#: documents/models.py:1372
 | 
			
		||||
msgid "grant change permissions to these users"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1356
 | 
			
		||||
#: documents/models.py:1379
 | 
			
		||||
msgid "grant change permissions to these groups"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1363
 | 
			
		||||
#: documents/models.py:1386
 | 
			
		||||
msgid "assign these custom fields"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1370
 | 
			
		||||
#: documents/models.py:1393
 | 
			
		||||
msgid "remove these tag(s)"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1375
 | 
			
		||||
#: documents/models.py:1398
 | 
			
		||||
msgid "remove all tags"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1382
 | 
			
		||||
#: documents/models.py:1405
 | 
			
		||||
msgid "remove these document type(s)"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1387
 | 
			
		||||
#: documents/models.py:1410
 | 
			
		||||
msgid "remove all document types"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1394
 | 
			
		||||
#: documents/models.py:1417
 | 
			
		||||
msgid "remove these correspondent(s)"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1399
 | 
			
		||||
#: documents/models.py:1422
 | 
			
		||||
msgid "remove all correspondents"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1406
 | 
			
		||||
#: documents/models.py:1429
 | 
			
		||||
msgid "remove these storage path(s)"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1411
 | 
			
		||||
#: documents/models.py:1434
 | 
			
		||||
msgid "remove all storage paths"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1418
 | 
			
		||||
#: documents/models.py:1441
 | 
			
		||||
msgid "remove these owner(s)"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1423
 | 
			
		||||
#: documents/models.py:1446
 | 
			
		||||
msgid "remove all owners"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1430
 | 
			
		||||
#: documents/models.py:1453
 | 
			
		||||
msgid "remove view permissions for these users"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1437
 | 
			
		||||
#: documents/models.py:1460
 | 
			
		||||
msgid "remove view permissions for these groups"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1444
 | 
			
		||||
#: documents/models.py:1467
 | 
			
		||||
msgid "remove change permissions for these users"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1451
 | 
			
		||||
#: documents/models.py:1474
 | 
			
		||||
msgid "remove change permissions for these groups"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1456
 | 
			
		||||
#: documents/models.py:1479
 | 
			
		||||
msgid "remove all permissions"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1463
 | 
			
		||||
#: documents/models.py:1486
 | 
			
		||||
msgid "remove these custom fields"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1468
 | 
			
		||||
#: documents/models.py:1491
 | 
			
		||||
msgid "remove all custom fields"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1477
 | 
			
		||||
#: documents/models.py:1500
 | 
			
		||||
msgid "email"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1486
 | 
			
		||||
#: documents/models.py:1509
 | 
			
		||||
msgid "webhook"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1490
 | 
			
		||||
#: documents/models.py:1513
 | 
			
		||||
msgid "workflow action"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1491
 | 
			
		||||
#: documents/models.py:1514
 | 
			
		||||
msgid "workflow actions"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1500 paperless_mail/models.py:145
 | 
			
		||||
#: documents/models.py:1523 paperless_mail/models.py:145
 | 
			
		||||
msgid "order"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1506
 | 
			
		||||
#: documents/models.py:1529
 | 
			
		||||
msgid "triggers"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1513
 | 
			
		||||
#: documents/models.py:1536
 | 
			
		||||
msgid "actions"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1516 paperless_mail/models.py:154
 | 
			
		||||
#: documents/models.py:1539 paperless_mail/models.py:154
 | 
			
		||||
msgid "enabled"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1527
 | 
			
		||||
#: documents/models.py:1550
 | 
			
		||||
msgid "workflow"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1531
 | 
			
		||||
#: documents/models.py:1554
 | 
			
		||||
msgid "workflow trigger type"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1545
 | 
			
		||||
#: documents/models.py:1568
 | 
			
		||||
msgid "date run"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1551
 | 
			
		||||
#: documents/models.py:1574
 | 
			
		||||
msgid "workflow run"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/models.py:1552
 | 
			
		||||
#: documents/models.py:1575
 | 
			
		||||
msgid "workflow runs"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -1402,21 +1434,6 @@ msgstr ""
 | 
			
		||||
msgid "As a final step, please complete the following form:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/validators.py:17
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Unable to parse URI {value}, missing scheme"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/validators.py:22
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Unable to parse URI {value}, missing net location or path"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: documents/validators.py:27
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Unable to parse URI {value}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: paperless/apps.py:10
 | 
			
		||||
msgid "Paperless"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user