mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-20 01:33:22 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a0880e6f | ||
|
|
ac5a39a3d4 | ||
|
|
a424dace43 | ||
|
|
751299895e |
+1
-2
@@ -302,8 +302,7 @@ The following methods are supported:
|
|||||||
- `delete`
|
- `delete`
|
||||||
- No `parameters` required
|
- No `parameters` required
|
||||||
- `reprocess`
|
- `reprocess`
|
||||||
- Optional `parameters`: `{ "remote_ocr": true }` to send the documents to the
|
- No `parameters` required
|
||||||
remote OCR engine, see [Remote OCR](usage.md#remote-ocr). Defaults to false.
|
|
||||||
- `set_permissions`
|
- `set_permissions`
|
||||||
- Requires `parameters`:
|
- Requires `parameters`:
|
||||||
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||||
|
|||||||
@@ -2066,18 +2066,6 @@ password. All of these options come from their similarly-named [Django settings]
|
|||||||
|
|
||||||
Defaults to None.
|
Defaults to None.
|
||||||
|
|
||||||
#### [`PAPERLESS_REMOTE_OCR_MODE=<str>`](#PAPERLESS_REMOTE_OCR_MODE) {#PAPERLESS_REMOTE_OCR_MODE}
|
|
||||||
|
|
||||||
: Which documents are sent to the remote OCR engine.
|
|
||||||
|
|
||||||
- `always`: every document of a supported file type is sent to the remote
|
|
||||||
engine, bypassing the local OCR engine.
|
|
||||||
- `workflow_only`: documents are processed locally unless a workflow
|
|
||||||
explicitly enables remote OCR for them, letting you use the remote engine
|
|
||||||
selectively.
|
|
||||||
|
|
||||||
Defaults to "always".
|
|
||||||
|
|
||||||
## AI {#ai}
|
## AI {#ai}
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||||
|
|||||||
@@ -456,20 +456,6 @@ def score(
|
|||||||
return 10
|
return 10
|
||||||
```
|
```
|
||||||
|
|
||||||
**Remote services**
|
|
||||||
|
|
||||||
If your parser sends document content to a remote service, declare it:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class MyCustomParser:
|
|
||||||
uses_remote_service = True
|
|
||||||
```
|
|
||||||
|
|
||||||
Paperless-ngx excludes such parsers when the document being consumed has not
|
|
||||||
been marked for remote processing, so users can keep remote OCR off by default
|
|
||||||
and enable it selectively with a workflow. Parsers that do not declare the
|
|
||||||
attribute are treated as fully local and are always considered.
|
|
||||||
|
|
||||||
**Archive and rendition flags**
|
**Archive and rendition flags**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
+1
-8
@@ -1090,18 +1090,11 @@ Paperless-ngx supports performing OCR on documents using remote services. At the
|
|||||||
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
|
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
|
||||||
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
|
This is of course a paid service (with a free tier) which requires an Azure account and subscription. Azure AI is not affiliated with
|
||||||
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
|
Paperless-ngx in any way. When enabled, Paperless-ngx will automatically send appropriate documents to Azure for OCR processing, bypassing
|
||||||
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. These
|
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
|
||||||
settings can be supplied as environment variables or via **Application Configuration**.
|
|
||||||
|
|
||||||
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
|
Additionally, when using a commercial service with this feature, consider both potential costs as well as any associated file size
|
||||||
or page limitations (e.g. with a free tier).
|
or page limitations (e.g. with a free tier).
|
||||||
|
|
||||||
By default, every document of a supported file type is sent to the remote engine. To use it more selectively, set the
|
|
||||||
[remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) to `workflow_only`. Documents are then processed locally
|
|
||||||
unless a workflow explicitly enables remote OCR for them, so you can limit the remote engine to particular documents.
|
|
||||||
|
|
||||||
Setting the mode to `workflow_only` also allows the **Reprocess** actions to selectively use remote OCR for individual documents.
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Paperless-ngx consists of the following components:
|
Paperless-ngx consists of the following components:
|
||||||
|
|||||||
+10
-10
@@ -8488,67 +8488,67 @@
|
|||||||
<source>Delete version</source>
|
<source>Delete version</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
|
||||||
<context context-type="linenumber">145,146</context>
|
<context context-type="linenumber">146,147</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9111284310237278846" datatype="html">
|
<trans-unit id="9111284310237278846" datatype="html">
|
||||||
<source>Error deleting version</source>
|
<source>Error deleting version</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||||
<context context-type="linenumber">158</context>
|
<context context-type="linenumber">164</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1450293275912542759" datatype="html">
|
<trans-unit id="1450293275912542759" datatype="html">
|
||||||
<source>Error updating version label</source>
|
<source>Error updating version label</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||||
<context context-type="linenumber">191</context>
|
<context context-type="linenumber">197</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3567158019134759286" datatype="html">
|
<trans-unit id="3567158019134759286" datatype="html">
|
||||||
<source>Uploading new version. Processing will happen in the background.</source>
|
<source>Uploading new version. Processing will happen in the background.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||||
<context context-type="linenumber">213</context>
|
<context context-type="linenumber">219</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1549684689040001385" datatype="html">
|
<trans-unit id="1549684689040001385" datatype="html">
|
||||||
<source>Missing task ID.</source>
|
<source>Missing task ID.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||||
<context context-type="linenumber">226</context>
|
<context context-type="linenumber">232</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="205445381296663832" datatype="html">
|
<trans-unit id="205445381296663832" datatype="html">
|
||||||
<source>Upload failed.</source>
|
<source>Upload failed.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||||
<context context-type="linenumber">248</context>
|
<context context-type="linenumber">254</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||||
<context context-type="linenumber">272</context>
|
<context context-type="linenumber">278</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8350861980330256599" datatype="html">
|
<trans-unit id="8350861980330256599" datatype="html">
|
||||||
<source>Error uploading new version</source>
|
<source>Error uploading new version</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||||
<context context-type="linenumber">275</context>
|
<context context-type="linenumber">281</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4050545381573552340" datatype="html">
|
<trans-unit id="4050545381573552340" datatype="html">
|
||||||
<source>Existing document added as a version.</source>
|
<source>Existing document added as a version.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||||
<context context-type="linenumber">318</context>
|
<context context-type="linenumber">324</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8552490241798412439" datatype="html">
|
<trans-unit id="8552490241798412439" datatype="html">
|
||||||
<source>Error adding existing document as a version</source>
|
<source>Error adding existing document as a version</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.ts</context>
|
||||||
<context context-type="linenumber">323</context>
|
<context context-type="linenumber">329</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6299008920007331381" datatype="html">
|
<trans-unit id="6299008920007331381" datatype="html">
|
||||||
|
|||||||
@@ -14,48 +14,43 @@
|
|||||||
<a ngbNavLink>{{category}}</a>
|
<a ngbNavLink>{{category}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
@for (section of getCategorySections(category); track section) {
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||||
@if (section) {
|
@for (option of getCategoryOptions(category); track option.key) {
|
||||||
<h5 class="mt-4 mb-3">{{section}}</h5>
|
<div class="col">
|
||||||
}
|
<div class="card bg-light">
|
||||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
<div class="card-body">
|
||||||
@for (option of getCategoryOptions(category, section); track option.key) {
|
<div class="card-title d-flex align-items-center">
|
||||||
<div class="col">
|
<h6 class="mb-0">
|
||||||
<div class="card bg-light">
|
{{option.title}}
|
||||||
<div class="card-body">
|
</h6>
|
||||||
<div class="card-title d-flex align-items-center">
|
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||||
<h6 class="mb-0">
|
<i-bs name="info-circle"></i-bs>
|
||||||
{{option.title}}
|
</a>
|
||||||
</h6>
|
@if (isSet(option.key)) {
|
||||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||||
<i-bs name="info-circle"></i-bs>
|
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||||
</a>
|
</button>
|
||||||
@if (isSet(option.key)) {
|
|
||||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
|
||||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="mb-n3">
|
|
||||||
@switch (option.type) {
|
|
||||||
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
|
||||||
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
|
||||||
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
|
||||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
|
||||||
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
|
||||||
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
|
||||||
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (option.note) {
|
|
||||||
<div class="form-text fst-italic">{{option.note}}</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-n3">
|
||||||
|
@switch (option.type) {
|
||||||
|
@case (ConfigOptionType.Select) { <pngx-input-select [formControlName]="option.key" [error]="errors[option.key]" [items]="option.choices" [allowNull]="true"></pngx-input-select> }
|
||||||
|
@case (ConfigOptionType.Number) { <pngx-input-number [formControlName]="option.key" [error]="errors[option.key]" [showAdd]="false"></pngx-input-number> }
|
||||||
|
@case (ConfigOptionType.Boolean) { <pngx-input-switch [formControlName]="option.key" [error]="errors[option.key]" [showUnsetNote]="true" [horizontal]="true" title="Enable" i18n-title></pngx-input-switch> }
|
||||||
|
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
|
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
|
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
||||||
|
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (option.note) {
|
||||||
|
<div class="form-text fst-italic">{{option.note}}</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
|||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import {
|
import { OutputTypeConfig } from 'src/app/data/paperless-config'
|
||||||
ConfigCategory,
|
|
||||||
ConfigSection,
|
|
||||||
OutputTypeConfig,
|
|
||||||
} from 'src/app/data/paperless-config'
|
|
||||||
import { ConfigService } from 'src/app/services/config.service'
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -162,24 +158,4 @@ describe('ConfigComponent', () => {
|
|||||||
component.resetOption('barcodes_enabled')
|
component.resetOption('barcodes_enabled')
|
||||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should group options into sections within a category, or not', () => {
|
|
||||||
const sections = component.getCategorySections(ConfigCategory.OCR)
|
|
||||||
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
|
||||||
expect(
|
|
||||||
component
|
|
||||||
.getCategoryOptions(ConfigCategory.OCR)
|
|
||||||
.map((option) => option.key)
|
|
||||||
).toContain('output_type')
|
|
||||||
expect(
|
|
||||||
component
|
|
||||||
.getCategoryOptions(ConfigCategory.OCR, ConfigSection.RemoteOCR)
|
|
||||||
.map((option) => option.key)
|
|
||||||
).toEqual([
|
|
||||||
'remote_ocr_engine',
|
|
||||||
'remote_ocr_api_key',
|
|
||||||
'remote_ocr_endpoint',
|
|
||||||
'remote_ocr_mode',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,20 +74,8 @@ export class ConfigComponent
|
|||||||
return Object.values(ConfigCategory)
|
return Object.values(ConfigCategory)
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategorySections(category: string): string[] {
|
getCategoryOptions(category: string): ConfigOption[] {
|
||||||
return [
|
return PaperlessConfigOptions.filter((o) => o.category === category)
|
||||||
...new Set(
|
|
||||||
PaperlessConfigOptions.filter((o) => o.category === category).map(
|
|
||||||
(o) => o.section ?? null // null means no section
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
getCategoryOptions(category: string, section: string = null): ConfigOption[] {
|
|
||||||
return PaperlessConfigOptions.filter(
|
|
||||||
(o) => o.category === category && (o.section ?? null) === section
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initialConfig: PaperlessConfig
|
initialConfig: PaperlessConfig
|
||||||
|
|||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
<div class="modal-header">
|
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
@if (messageBold) {
|
|
||||||
<p class="text-break"><b>{{messageBold}}</b></p>
|
|
||||||
}
|
|
||||||
@if (message) {
|
|
||||||
<p class="mb-0 text-break" [innerHTML]="message"></p>
|
|
||||||
}
|
|
||||||
@if (showRemoteOcr) {
|
|
||||||
<div class="form-check mt-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="reprocessRemoteOcr" [(ngModel)]="remoteOcr" />
|
|
||||||
<label class="form-check-label" for="reprocessRemoteOcr" i18n>Use remote OCR</label>
|
|
||||||
<div class="form-text" i18n>Sends the document to the configured remote OCR service, which may incur costs.</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
|
||||||
{{btnCaption}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
-72
@@ -1,72 +0,0 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { RemoteOCRModeConfig } from 'src/app/data/paperless-config'
|
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
|
||||||
import { ReprocessConfirmDialogComponent } from './reprocess-confirm-dialog.component'
|
|
||||||
|
|
||||||
describe('ReprocessConfirmDialogComponent', () => {
|
|
||||||
let component: ReprocessConfirmDialogComponent
|
|
||||||
let fixture: ComponentFixture<ReprocessConfirmDialogComponent>
|
|
||||||
let settingsService: SettingsService
|
|
||||||
|
|
||||||
const createComponent = (configured: boolean, mode: string) => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, configured)
|
|
||||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_MODE, mode)
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ReprocessConfirmDialogComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
fixture.detectChanges()
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [
|
|
||||||
NgbActiveModal,
|
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
],
|
|
||||||
imports: [ReprocessConfirmDialogComponent],
|
|
||||||
}).compileComponents()
|
|
||||||
|
|
||||||
settingsService = TestBed.inject(SettingsService)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not request remote OCR by default', () => {
|
|
||||||
createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY)
|
|
||||||
|
|
||||||
expect(component.remoteOcr).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not offer remote OCR when no engine is configured', () => {
|
|
||||||
createComponent(false, RemoteOCRModeConfig.WORKFLOW_ONLY)
|
|
||||||
|
|
||||||
expect(component.showRemoteOcr).toBeFalsy()
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('#reprocessRemoteOcr')
|
|
||||||
).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not offer remote OCR when it already handles every document', () => {
|
|
||||||
createComponent(true, RemoteOCRModeConfig.ALWAYS)
|
|
||||||
|
|
||||||
expect(component.showRemoteOcr).toBeFalsy()
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('#reprocessRemoteOcr')
|
|
||||||
).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should offer remote OCR when configured and selective', () => {
|
|
||||||
createComponent(true, RemoteOCRModeConfig.WORKFLOW_ONLY)
|
|
||||||
|
|
||||||
expect(component.showRemoteOcr).toBeTruthy()
|
|
||||||
const checkbox = fixture.nativeElement.querySelector('#reprocessRemoteOcr')
|
|
||||||
expect(checkbox).not.toBeNull()
|
|
||||||
|
|
||||||
checkbox.click()
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(component.remoteOcr).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-reprocess-confirm-dialog',
|
|
||||||
templateUrl: './reprocess-confirm-dialog.component.html',
|
|
||||||
imports: [FormsModule],
|
|
||||||
})
|
|
||||||
export class ReprocessConfirmDialogComponent extends ConfirmDialogComponent {
|
|
||||||
private settings = inject(SettingsService)
|
|
||||||
|
|
||||||
remoteOcr: boolean = false
|
|
||||||
|
|
||||||
public get showRemoteOcr(): boolean {
|
|
||||||
// Hidden when it is not configured, or when it already handles every document anyway.
|
|
||||||
return this.settings.remoteOCRIsSelectable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -963,24 +963,12 @@ describe('DocumentDetailComponent', () => {
|
|||||||
component.reprocess()
|
component.reprocess()
|
||||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||||
openModal.componentInstance.confirmClicked.next()
|
openModal.componentInstance.confirmClicked.next()
|
||||||
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
|
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] })
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
expect(modalCloseSpy).toHaveBeenCalled()
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should pass remote OCR choice when reprocessing', () => {
|
|
||||||
initNormally()
|
|
||||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
|
||||||
reprocessSpy.mockReturnValue(of(true))
|
|
||||||
let openModal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
|
||||||
component.reprocess()
|
|
||||||
openModal.componentInstance.remoteOcr = true
|
|
||||||
openModal.componentInstance.confirmClicked.next()
|
|
||||||
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show error if redo ocr call fails', () => {
|
it('should show error if redo ocr call fails', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
|||||||
import * as UTIF from 'utif'
|
import * as UTIF from 'utif'
|
||||||
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
|
||||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
@@ -1399,7 +1398,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
reprocess() {
|
reprocess() {
|
||||||
let modal = this.modalService.open(ReprocessConfirmDialogComponent, {
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Reprocess confirm`
|
modal.componentInstance.title = $localize`Reprocess confirm`
|
||||||
@@ -1410,10 +1409,7 @@ export class DocumentDetailComponent
|
|||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.reprocessDocuments(
|
.reprocessDocuments({ documents: [this.document().id] })
|
||||||
{ documents: [this.document().id] },
|
|
||||||
modal.componentInstance.remoteOcr
|
|
||||||
)
|
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
|
|||||||
+1
@@ -141,6 +141,7 @@
|
|||||||
i18n-confirmMessage
|
i18n-confirmMessage
|
||||||
[disabled]="!userIsOwner || !userCanEdit"
|
[disabled]="!userIsOwner || !userCanEdit"
|
||||||
(confirm)="deleteVersion(version.id)"
|
(confirm)="deleteVersion(version.id)"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"
|
||||||
>
|
>
|
||||||
<span class="visually-hidden" i18n>Delete version</span>
|
<span class="visually-hidden" i18n>Delete version</span>
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
|
|||||||
+41
@@ -1,10 +1,16 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { SimpleChange, signal } from '@angular/core'
|
import { SimpleChange, signal } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { By } from '@angular/platform-browser'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, of, throwError } from 'rxjs'
|
import { Subject, of, throwError } from 'rxjs'
|
||||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -31,6 +37,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
let finished$: Subject<{ taskId: string }>
|
let finished$: Subject<{ taskId: string }>
|
||||||
let failed$: Subject<{ taskId: string; message?: string }>
|
let failed$: Subject<{ taskId: string; message?: string }>
|
||||||
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
|
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
|
||||||
|
let permissionsService: jest.Mocked<
|
||||||
|
Pick<PermissionsService, 'currentUserCan'>
|
||||||
|
>
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
finished$ = new Subject<{ taskId: string }>()
|
finished$ = new Subject<{ taskId: string }>()
|
||||||
@@ -47,6 +56,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
showError: jest.fn(),
|
showError: jest.fn(),
|
||||||
showInfo: jest.fn(),
|
showInfo: jest.fn(),
|
||||||
}
|
}
|
||||||
|
permissionsService = {
|
||||||
|
currentUserCan: jest.fn().mockReturnValue(true),
|
||||||
|
}
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -73,6 +85,10 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
provide: NgbModal,
|
provide: NgbModal,
|
||||||
useValue: modalService,
|
useValue: modalService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: PermissionsService,
|
||||||
|
useValue: permissionsService,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: WebsocketStatusService,
|
provide: WebsocketStatusService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -143,6 +159,31 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not show version delete buttons without document delete permission', () => {
|
||||||
|
fixture.destroy()
|
||||||
|
permissionsService.currentUserCan.mockReturnValue(false)
|
||||||
|
fixture = TestBed.createComponent(DocumentVersionDropdownComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
component.documentId = 3
|
||||||
|
component.selectedVersionId = 3
|
||||||
|
component.userIsOwner = true
|
||||||
|
component.userCanEdit = true
|
||||||
|
component.versions = [
|
||||||
|
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||||
|
{ id: 10, is_root: false, checksum: 'bbbb' },
|
||||||
|
]
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(permissionsService.currentUserCan).toHaveBeenCalledWith(
|
||||||
|
PermissionAction.Delete,
|
||||||
|
PermissionType.Document
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.queryAll(By.css('pngx-confirm-button'))
|
||||||
|
).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('beginEditingVersion should set active row and draft label', () => {
|
it('beginEditingVersion should set active row and draft label', () => {
|
||||||
component.userCanEdit = true
|
component.userCanEdit = true
|
||||||
component.userIsOwner = true
|
component.userIsOwner = true
|
||||||
|
|||||||
+7
-1
@@ -25,6 +25,7 @@ import {
|
|||||||
tap,
|
tap,
|
||||||
} from 'rxjs/operators'
|
} from 'rxjs/operators'
|
||||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||||
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
WebsocketStatusService,
|
WebsocketStatusService,
|
||||||
} from 'src/app/services/websocket-status.service'
|
} from 'src/app/services/websocket-status.service'
|
||||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||||
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog/add-existing-document-version-dialog.component'
|
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog/add-existing-document-version-dialog.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -44,11 +46,15 @@ import { AddExistingDocumentVersionDialogComponent } from './add-existing-docume
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
|
IfPermissionsDirective,
|
||||||
SlicePipe,
|
SlicePipe,
|
||||||
CustomDatePipe,
|
CustomDatePipe,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
export class DocumentVersionDropdownComponent
|
||||||
|
extends ComponentWithPermissions
|
||||||
|
implements OnChanges, OnDestroy
|
||||||
|
{
|
||||||
UploadState = UploadState
|
UploadState = UploadState
|
||||||
|
|
||||||
@Input() documentId: number
|
@Input() documentId: number
|
||||||
|
|||||||
@@ -1122,7 +1122,6 @@ describe('BulkEditorComponent', () => {
|
|||||||
req.flush(true)
|
req.flush(true)
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
remote_ocr: false,
|
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import { flattenTags } from 'src/app/utils/flatten-tags'
|
|||||||
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||||
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
|
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
|
||||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
|
||||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
@@ -917,7 +916,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
reprocessSelected() {
|
reprocessSelected() {
|
||||||
let modal = this.modalService.open(ReprocessConfirmDialogComponent, {
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Reprocess confirm`
|
modal.componentInstance.title = $localize`Reprocess confirm`
|
||||||
@@ -931,10 +930,7 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.reprocessDocuments(
|
this.documentService.reprocessDocuments(this.getSelectionQuery())
|
||||||
this.getSelectionQuery(),
|
|
||||||
modal.componentInstance.remoteOcr
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,10 +54,6 @@ export const ConfigCategory = {
|
|||||||
AI: $localize`AI Settings`,
|
AI: $localize`AI Settings`,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfigSection = {
|
|
||||||
RemoteOCR: $localize`Remote OCR`,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LLMEmbeddingBackendConfig = {
|
export const LLMEmbeddingBackendConfig = {
|
||||||
OPENAI_LIKE: 'openai-like',
|
OPENAI_LIKE: 'openai-like',
|
||||||
HUGGINGFACE: 'huggingface',
|
HUGGINGFACE: 'huggingface',
|
||||||
@@ -69,15 +65,6 @@ export const LLMBackendConfig = {
|
|||||||
OLLAMA: 'ollama',
|
OLLAMA: 'ollama',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RemoteOCREngineConfig = {
|
|
||||||
AZURE_AI: 'azureai',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RemoteOCRModeConfig = {
|
|
||||||
ALWAYS: 'always',
|
|
||||||
WORKFLOW_ONLY: 'workflow_only',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfigOption {
|
export interface ConfigOption {
|
||||||
key: string
|
key: string
|
||||||
title: string
|
title: string
|
||||||
@@ -85,7 +72,6 @@ export interface ConfigOption {
|
|||||||
choices?: Array<{ id: string; name: string }>
|
choices?: Array<{ id: string; name: string }>
|
||||||
config_key?: string
|
config_key?: string
|
||||||
category: string
|
category: string
|
||||||
section?: string
|
|
||||||
note?: string
|
note?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,43 +181,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
|||||||
config_key: 'PAPERLESS_OCR_USER_ARGS',
|
config_key: 'PAPERLESS_OCR_USER_ARGS',
|
||||||
category: ConfigCategory.OCR,
|
category: ConfigCategory.OCR,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'remote_ocr_engine',
|
|
||||||
title: $localize`Remote OCR Engine`,
|
|
||||||
type: ConfigOptionType.Select,
|
|
||||||
choices: mapToItems(RemoteOCREngineConfig),
|
|
||||||
config_key: 'PAPERLESS_REMOTE_OCR_ENGINE',
|
|
||||||
category: ConfigCategory.OCR,
|
|
||||||
section: ConfigSection.RemoteOCR,
|
|
||||||
note: $localize`Enabling remote OCR sends documents to a third-party service for processing. Consider the privacy implications as well as potential costs before enabling.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'remote_ocr_api_key',
|
|
||||||
title: $localize`Remote OCR API Key`,
|
|
||||||
type: ConfigOptionType.Password,
|
|
||||||
config_key: 'PAPERLESS_REMOTE_OCR_API_KEY',
|
|
||||||
category: ConfigCategory.OCR,
|
|
||||||
section: ConfigSection.RemoteOCR,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'remote_ocr_endpoint',
|
|
||||||
title: $localize`Remote OCR Endpoint`,
|
|
||||||
type: ConfigOptionType.String,
|
|
||||||
config_key: 'PAPERLESS_REMOTE_OCR_ENDPOINT',
|
|
||||||
category: ConfigCategory.OCR,
|
|
||||||
section: ConfigSection.RemoteOCR,
|
|
||||||
note: $localize`Required when using the Azure AI engine.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'remote_ocr_mode',
|
|
||||||
title: $localize`Remote OCR Mode`,
|
|
||||||
type: ConfigOptionType.Select,
|
|
||||||
choices: mapToItems(RemoteOCRModeConfig),
|
|
||||||
config_key: 'PAPERLESS_REMOTE_OCR_MODE',
|
|
||||||
category: ConfigCategory.OCR,
|
|
||||||
section: ConfigSection.RemoteOCR,
|
|
||||||
note: $localize`Which documents are sent to the remote engine. Use 'workflow_only' to keep remote OCR off unless a workflow enables it for a document.`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'app_logo',
|
key: 'app_logo',
|
||||||
title: $localize`Application Logo`,
|
title: $localize`Application Logo`,
|
||||||
@@ -449,10 +398,6 @@ export interface PaperlessConfig extends ObjectWithId {
|
|||||||
barcode_enable_tag: boolean
|
barcode_enable_tag: boolean
|
||||||
barcode_tag_mapping: object
|
barcode_tag_mapping: object
|
||||||
barcode_tag_split: boolean
|
barcode_tag_split: boolean
|
||||||
remote_ocr_engine: string
|
|
||||||
remote_ocr_api_key: string
|
|
||||||
remote_ocr_endpoint: string
|
|
||||||
remote_ocr_mode: string
|
|
||||||
ai_enabled: boolean
|
ai_enabled: boolean
|
||||||
llm_embedding_backend: string
|
llm_embedding_backend: string
|
||||||
llm_embedding_model: string
|
llm_embedding_model: string
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
|
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
|
||||||
import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types'
|
import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types'
|
||||||
import { RemoteOCRModeConfig } from './paperless-config'
|
|
||||||
import { User } from './user'
|
import { User } from './user'
|
||||||
|
|
||||||
export interface UiSettings {
|
export interface UiSettings {
|
||||||
@@ -95,8 +94,6 @@ export const SETTINGS_KEYS = {
|
|||||||
OUTLOOK_OAUTH_URL: 'outlook_oauth_url',
|
OUTLOOK_OAUTH_URL: 'outlook_oauth_url',
|
||||||
EMAIL_ENABLED: 'email_enabled',
|
EMAIL_ENABLED: 'email_enabled',
|
||||||
AI_ENABLED: 'ai_enabled',
|
AI_ENABLED: 'ai_enabled',
|
||||||
REMOTE_OCR_CONFIGURED: 'remote_ocr:configured',
|
|
||||||
REMOTE_OCR_MODE: 'remote_ocr:mode',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SETTINGS: UiSetting[] = [
|
export const SETTINGS: UiSetting[] = [
|
||||||
@@ -350,14 +347,4 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
default: PdfEditorEditMode.Create,
|
default: PdfEditorEditMode.Create,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: SETTINGS_KEYS.REMOTE_OCR_CONFIGURED,
|
|
||||||
type: 'boolean',
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: SETTINGS_KEYS.REMOTE_OCR_MODE,
|
|
||||||
type: 'string',
|
|
||||||
default: RemoteOCRModeConfig.ALWAYS,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -284,21 +284,6 @@ describe(`DocumentService`, () => {
|
|||||||
expect(req.request.method).toEqual('POST')
|
expect(req.request.method).toEqual('POST')
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: ids,
|
documents: ids,
|
||||||
remote_ocr: false,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should request remote OCR when reprocessing with it enabled', () => {
|
|
||||||
const ids = [1, 2, 3]
|
|
||||||
subscription = service
|
|
||||||
.reprocessDocuments({ documents: ids }, true)
|
|
||||||
.subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/reprocess/`
|
|
||||||
)
|
|
||||||
expect(req.request.body).toEqual({
|
|
||||||
documents: ids,
|
|
||||||
remote_ocr: true,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -349,13 +349,9 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
reprocessDocuments(
|
reprocessDocuments(selection: DocumentSelectionQuery) {
|
||||||
selection: DocumentSelectionQuery,
|
|
||||||
remoteOcr: boolean = false
|
|
||||||
) {
|
|
||||||
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
|
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
|
||||||
...selection,
|
...selection,
|
||||||
remote_ocr: remoteOcr,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { environment } from 'src/environments/environment'
|
|||||||
import { CustomFieldDataType } from '../data/custom-field'
|
import { CustomFieldDataType } from '../data/custom-field'
|
||||||
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
|
||||||
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
||||||
import { PermissionsService } from './permissions.service'
|
import { PermissionsService } from './permissions.service'
|
||||||
import { CustomFieldsService } from './rest/custom-fields.service'
|
import { CustomFieldsService } from './rest/custom-fields.service'
|
||||||
@@ -435,26 +434,4 @@ describe('SettingsService', () => {
|
|||||||
).name
|
).name
|
||||||
).toEqual(customFields[0].name)
|
).toEqual(customFields[0].name)
|
||||||
})
|
})
|
||||||
it('should offer remote OCR only when configured and selective', () => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, false)
|
|
||||||
settingsService.set(
|
|
||||||
SETTINGS_KEYS.REMOTE_OCR_MODE,
|
|
||||||
RemoteOCRModeConfig.WORKFLOW_ONLY
|
|
||||||
)
|
|
||||||
expect(settingsService.remoteOCRIsSelectable).toBeFalsy()
|
|
||||||
|
|
||||||
// configured, but already handling every document
|
|
||||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, true)
|
|
||||||
settingsService.set(
|
|
||||||
SETTINGS_KEYS.REMOTE_OCR_MODE,
|
|
||||||
RemoteOCRModeConfig.ALWAYS
|
|
||||||
)
|
|
||||||
expect(settingsService.remoteOCRIsSelectable).toBeFalsy()
|
|
||||||
|
|
||||||
settingsService.set(
|
|
||||||
SETTINGS_KEYS.REMOTE_OCR_MODE,
|
|
||||||
RemoteOCRModeConfig.WORKFLOW_ONLY
|
|
||||||
)
|
|
||||||
expect(settingsService.remoteOCRIsSelectable).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from 'src/app/utils/color'
|
} from 'src/app/utils/color'
|
||||||
import { DEFAULT_APP_TITLE, environment } from 'src/environments/environment'
|
import { DEFAULT_APP_TITLE, environment } from 'src/environments/environment'
|
||||||
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import {
|
import {
|
||||||
PAPERLESS_GREEN_HEX,
|
PAPERLESS_GREEN_HEX,
|
||||||
@@ -688,17 +687,6 @@ export class SettingsService {
|
|||||||
return this.settingIsSet(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)
|
return this.settingIsSet(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Offering remote OCR as a choice only makes sense when an engine
|
|
||||||
* is configured but is not already handling every document.
|
|
||||||
*/
|
|
||||||
get remoteOCRIsSelectable(): boolean {
|
|
||||||
return (
|
|
||||||
this.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
|
||||||
this.get(SETTINGS_KEYS.REMOTE_OCR_MODE) !== RemoteOCRModeConfig.ALWAYS
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
offerTour(): boolean {
|
offerTour(): boolean {
|
||||||
return this.dashboardIsEmpty() && !this.get(SETTINGS_KEYS.TOUR_COMPLETE)
|
return this.dashboardIsEmpty() && !this.get(SETTINGS_KEYS.TOUR_COMPLETE)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -399,16 +399,10 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
|||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
def reprocess(doc_ids: list[int]) -> Literal["OK"]:
|
||||||
"""
|
|
||||||
Re-run parsing for the given documents.
|
|
||||||
|
|
||||||
Consumption workflows do not run here, so ``remote_ocr`` is how the user
|
|
||||||
asks for the remote engine when it is not configured to handle everything.
|
|
||||||
"""
|
|
||||||
for document_id in doc_ids:
|
for document_id in doc_ids:
|
||||||
update_document_content_maybe_archive_file.apply_async(
|
update_document_content_maybe_archive_file.apply_async(
|
||||||
kwargs={"document_id": document_id, "remote_ocr": remote_ocr},
|
kwargs={"document_id": document_id},
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
|
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ from documents.utils import copy_basic_file_stats
|
|||||||
from documents.utils import copy_file_with_basic_stats
|
from documents.utils import copy_file_with_basic_stats
|
||||||
from documents.utils import run_subprocess
|
from documents.utils import run_subprocess
|
||||||
from paperless.config import OcrConfig
|
from paperless.config import OcrConfig
|
||||||
from paperless.config import RemoteOCRConfig
|
|
||||||
from paperless.models import ArchiveFileGenerationChoices
|
from paperless.models import ArchiveFileGenerationChoices
|
||||||
from paperless.parsers import ParserContext
|
from paperless.parsers import ParserContext
|
||||||
from paperless.parsers import ParserProtocol
|
from paperless.parsers import ParserProtocol
|
||||||
@@ -452,19 +451,12 @@ class ConsumerPlugin(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.error(f"Error attempting to clean PDF: {e}")
|
self.log.error(f"Error attempting to clean PDF: {e}")
|
||||||
|
|
||||||
# Workflows have already run at this point, so the metadata knows
|
|
||||||
# whether this document was singled out for remote OCR
|
|
||||||
allow_remote = (
|
|
||||||
self.metadata.remote_ocr or RemoteOCRConfig().remote_ocr_by_default
|
|
||||||
)
|
|
||||||
|
|
||||||
# Based on the mime type, get the parser for that type
|
# Based on the mime type, get the parser for that type
|
||||||
parser_class: type[ParserProtocol] | None = (
|
parser_class: type[ParserProtocol] | None = (
|
||||||
get_parser_registry().get_parser_for_file(
|
get_parser_registry().get_parser_for_file(
|
||||||
mime_type,
|
mime_type,
|
||||||
self.filename,
|
self.filename,
|
||||||
self.working_copy,
|
self.working_copy,
|
||||||
allow_remote=allow_remote,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not parser_class:
|
if not parser_class:
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ class DocumentMetadataOverrides:
|
|||||||
skip_asn_if_exists: bool = False
|
skip_asn_if_exists: bool = False
|
||||||
version_label: str | None = None
|
version_label: str | None = None
|
||||||
actor_id: int | None = None
|
actor_id: int | None = None
|
||||||
remote_ocr: bool = False
|
|
||||||
|
|
||||||
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
|
||||||
"""
|
"""
|
||||||
@@ -58,8 +57,6 @@ class DocumentMetadataOverrides:
|
|||||||
self.actor_id = other.actor_id
|
self.actor_id = other.actor_id
|
||||||
if other.skip_asn_if_exists:
|
if other.skip_asn_if_exists:
|
||||||
self.skip_asn_if_exists = True
|
self.skip_asn_if_exists = True
|
||||||
if other.remote_ocr:
|
|
||||||
self.remote_ocr = True
|
|
||||||
if other.version_label is not None:
|
if other.version_label is not None:
|
||||||
self.version_label = other.version_label
|
self.version_label = other.version_label
|
||||||
|
|
||||||
|
|||||||
@@ -266,11 +266,7 @@ class WriteBatch:
|
|||||||
if self._lock is not None:
|
if self._lock is not None:
|
||||||
self._lock.release()
|
self._lock.release()
|
||||||
|
|
||||||
def add_or_update(
|
def add_or_update(self, document: Document) -> None:
|
||||||
self,
|
|
||||||
document: Document,
|
|
||||||
effective_content: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Add or update a document in the batch.
|
Add or update a document in the batch.
|
||||||
|
|
||||||
@@ -280,11 +276,9 @@ class WriteBatch:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
document: Django Document instance to index
|
document: Django Document instance to index
|
||||||
effective_content: Override document.content for indexing (used when
|
|
||||||
re-indexing with newer OCR text from document versions)
|
|
||||||
"""
|
"""
|
||||||
self.remove(document.pk)
|
self.remove(document.pk)
|
||||||
doc = self._backend._build_tantivy_doc(document, effective_content)
|
doc = self._backend._build_tantivy_doc(document)
|
||||||
self._writer.add_document(doc)
|
self._writer.add_document(doc)
|
||||||
|
|
||||||
def remove(self, doc_id: int) -> None:
|
def remove(self, doc_id: int) -> None:
|
||||||
@@ -425,18 +419,17 @@ class TantivyBackend:
|
|||||||
def _build_tantivy_doc(
|
def _build_tantivy_doc(
|
||||||
self,
|
self,
|
||||||
document: Document,
|
document: Document,
|
||||||
effective_content: str | None = None,
|
|
||||||
viewer_ids: list[int] | None = None,
|
viewer_ids: list[int] | None = None,
|
||||||
viewer_group_ids: list[int] | None = None,
|
viewer_group_ids: list[int] | None = None,
|
||||||
) -> tantivy.Document:
|
) -> tantivy.Document:
|
||||||
"""Build a tantivy Document from a Django Document instance.
|
"""Build a tantivy Document from a Django Document instance.
|
||||||
|
|
||||||
``effective_content`` overrides ``document.content`` for indexing —
|
A root document is indexed with its effective content, i.e. the newest
|
||||||
used when re-indexing a root document with a newer version's OCR text.
|
version's OCR text, so it is never indexed with its own outdated text.
|
||||||
|
Annotate the queryset with ``annotate_effective_content`` when indexing
|
||||||
|
more than a couple of documents, to resolve that without a query each.
|
||||||
"""
|
"""
|
||||||
content = (
|
content = document.get_effective_content() or ""
|
||||||
effective_content if effective_content is not None else document.content
|
|
||||||
)
|
|
||||||
|
|
||||||
doc = tantivy.Document()
|
doc = tantivy.Document()
|
||||||
|
|
||||||
@@ -584,11 +577,7 @@ class TantivyBackend:
|
|||||||
|
|
||||||
return doc
|
return doc
|
||||||
|
|
||||||
def add_or_update(
|
def add_or_update(self, document: Document) -> None:
|
||||||
self,
|
|
||||||
document: Document,
|
|
||||||
effective_content: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Add or update a single document with file locking.
|
Add or update a single document with file locking.
|
||||||
|
|
||||||
@@ -601,12 +590,11 @@ class TantivyBackend:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
document: Django Document instance to index
|
document: Django Document instance to index
|
||||||
effective_content: Override document.content for indexing
|
|
||||||
"""
|
"""
|
||||||
self._ensure_open()
|
self._ensure_open()
|
||||||
try:
|
try:
|
||||||
with self.batch_update(lock_timeout=_LOCK_TIMEOUT_SECONDS) as batch:
|
with self.batch_update(lock_timeout=_LOCK_TIMEOUT_SECONDS) as batch:
|
||||||
batch.add_or_update(document, effective_content)
|
batch.add_or_update(document)
|
||||||
except SearchIndexLockError:
|
except SearchIndexLockError:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Search index lock exhausted for document %d after %d attempts; "
|
"Search index lock exhausted for document %d after %d attempts; "
|
||||||
@@ -1027,7 +1015,6 @@ class TantivyBackend:
|
|||||||
):
|
):
|
||||||
doc = self._build_tantivy_doc(
|
doc = self._build_tantivy_doc(
|
||||||
document,
|
document,
|
||||||
document.get_effective_content(),
|
|
||||||
viewer_ids=viewer_ids,
|
viewer_ids=viewer_ids,
|
||||||
viewer_group_ids=viewer_group_ids,
|
viewer_group_ids=viewer_group_ids,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1795,7 +1795,7 @@ class DeleteDocumentsSerializer(DocumentSelectionSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
||||||
remote_ocr = serializers.BooleanField(required=False, default=False)
|
pass
|
||||||
|
|
||||||
|
|
||||||
class BulkEditSerializer(
|
class BulkEditSerializer(
|
||||||
@@ -2137,13 +2137,6 @@ class BulkEditSerializer(
|
|||||||
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
f"Page {op['page']} is out of bounds for document with {doc.page_count} pages.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _validate_parameters_reprocess(self, parameters) -> None:
|
|
||||||
if "remote_ocr" in parameters:
|
|
||||||
if not isinstance(parameters["remote_ocr"], bool):
|
|
||||||
raise serializers.ValidationError("remote_ocr must be a boolean")
|
|
||||||
else:
|
|
||||||
parameters["remote_ocr"] = False
|
|
||||||
|
|
||||||
def validate_parameters_remove_password(self, parameters):
|
def validate_parameters_remove_password(self, parameters):
|
||||||
if "password" not in parameters:
|
if "password" not in parameters:
|
||||||
raise serializers.ValidationError("password not specified")
|
raise serializers.ValidationError("password not specified")
|
||||||
@@ -2208,8 +2201,6 @@ class BulkEditSerializer(
|
|||||||
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
|
||||||
elif method == bulk_edit.remove_password:
|
elif method == bulk_edit.remove_password:
|
||||||
self.validate_parameters_remove_password(parameters)
|
self.validate_parameters_remove_password(parameters)
|
||||||
elif method == bulk_edit.reprocess:
|
|
||||||
self._validate_parameters_reprocess(parameters)
|
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|||||||
@@ -794,10 +794,12 @@ def cleanup_user_deletion(sender, instance: User | Group, **kwargs) -> None:
|
|||||||
def add_to_index(sender, document, **kwargs) -> None:
|
def add_to_index(sender, document, **kwargs) -> None:
|
||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
|
||||||
get_backend().add_or_update(
|
# A newly consumed version is not searchable on its own, its content
|
||||||
document,
|
# becomes the effective_content of the root document
|
||||||
effective_content=document.get_effective_content(),
|
if document.root_document_id:
|
||||||
)
|
document = document.root_document
|
||||||
|
|
||||||
|
get_backend().add_or_update(document)
|
||||||
|
|
||||||
|
|
||||||
def run_workflows_added(
|
def run_workflows_added(
|
||||||
|
|||||||
+7
-15
@@ -64,9 +64,9 @@ from documents.signals.handlers import send_websocket_document_updated
|
|||||||
from documents.utils import IterWrapper
|
from documents.utils import IterWrapper
|
||||||
from documents.utils import compute_checksum
|
from documents.utils import compute_checksum
|
||||||
from documents.utils import identity
|
from documents.utils import identity
|
||||||
|
from documents.versioning import annotate_effective_content
|
||||||
from documents.workflows.utils import get_workflows_for_trigger
|
from documents.workflows.utils import get_workflows_for_trigger
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless.config import RemoteOCRConfig
|
|
||||||
from paperless.logging import consume_task_id
|
from paperless.logging import consume_task_id
|
||||||
from paperless.parsers import ParserContext
|
from paperless.parsers import ParserContext
|
||||||
from paperless.parsers.registry import get_parser_registry
|
from paperless.parsers.registry import get_parser_registry
|
||||||
@@ -115,10 +115,7 @@ def index_document(self, document_id: int) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
with get_backend().batch_update() as batch:
|
with get_backend().batch_update() as batch:
|
||||||
batch.add_or_update(
|
batch.add_or_update(document)
|
||||||
document,
|
|
||||||
effective_content=document.get_effective_content(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@shared_task(
|
@shared_task(
|
||||||
@@ -313,7 +310,10 @@ def bulk_update_documents(document_ids) -> None:
|
|||||||
from documents.search import get_backend
|
from documents.search import get_backend
|
||||||
|
|
||||||
document_ids = list(document_ids)
|
document_ids = list(document_ids)
|
||||||
documents = Document.objects.filter(id__in=document_ids)
|
# Annotated so indexing below doesn't query the versions of each document
|
||||||
|
documents = annotate_effective_content(
|
||||||
|
Document.objects.filter(id__in=document_ids),
|
||||||
|
)
|
||||||
|
|
||||||
for doc in documents:
|
for doc in documents:
|
||||||
clear_document_caches(doc.pk)
|
clear_document_caches(doc.pk)
|
||||||
@@ -338,17 +338,10 @@ def bulk_update_documents(document_ids) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def update_document_content_maybe_archive_file(
|
def update_document_content_maybe_archive_file(document_id) -> None:
|
||||||
document_id,
|
|
||||||
*,
|
|
||||||
remote_ocr: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""
|
"""
|
||||||
Re-creates OCR content and thumbnail for a document, and archive file if
|
Re-creates OCR content and thumbnail for a document, and archive file if
|
||||||
it exists.
|
it exists.
|
||||||
|
|
||||||
Remote OCR is used only when the engine is configured to handle everything
|
|
||||||
or if explicitly asked for via ``remote_ocr``.
|
|
||||||
"""
|
"""
|
||||||
document = Document.objects.get(id=document_id)
|
document = Document.objects.get(id=document_id)
|
||||||
|
|
||||||
@@ -358,7 +351,6 @@ def update_document_content_maybe_archive_file(
|
|||||||
mime_type,
|
mime_type,
|
||||||
document.original_filename or "",
|
document.original_filename or "",
|
||||||
document.source_path,
|
document.source_path,
|
||||||
allow_remote=remote_ocr or RemoteOCRConfig().remote_ocr_by_default,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not parser_class:
|
if not parser_class:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from documents.search._backend import TantivyBackend
|
|||||||
from documents.search._backend import WriteBatch
|
from documents.search._backend import WriteBatch
|
||||||
from documents.search._backend import get_backend
|
from documents.search._backend import get_backend
|
||||||
from documents.search._backend import reset_backend
|
from documents.search._backend import reset_backend
|
||||||
|
from documents.signals.handlers import add_to_index
|
||||||
from documents.tests.factories import CorrespondentFactory
|
from documents.tests.factories import CorrespondentFactory
|
||||||
from documents.tests.factories import DocumentFactory
|
from documents.tests.factories import DocumentFactory
|
||||||
from documents.tests.factories import DocumentTypeFactory
|
from documents.tests.factories import DocumentTypeFactory
|
||||||
@@ -1030,6 +1031,81 @@ class TestHighlightHits:
|
|||||||
assert len(hits) == 0
|
assert len(hits) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestVersionIndexing:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document whose new version has just been consumed, e.g. by
|
||||||
|
the password removal workflow action
|
||||||
|
WHEN:
|
||||||
|
- The consumption finished signal is handled
|
||||||
|
THEN:
|
||||||
|
- The root document is indexed with the new version's content, since
|
||||||
|
versions are not searchable on their own
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_consumed_version_updates_root_entry(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
mocker: MockerFixture,
|
||||||
|
) -> None:
|
||||||
|
root = Document.objects.create(
|
||||||
|
title="Statement",
|
||||||
|
content="",
|
||||||
|
checksum="VER1",
|
||||||
|
pk=90,
|
||||||
|
)
|
||||||
|
backend.add_or_update(root)
|
||||||
|
version = Document.objects.create(
|
||||||
|
title="Statement",
|
||||||
|
content="unprotected statement text",
|
||||||
|
checksum="VER2",
|
||||||
|
pk=91,
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
)
|
||||||
|
mocker.patch("documents.search.get_backend", return_value=backend)
|
||||||
|
|
||||||
|
add_to_index(sender=None, document=version)
|
||||||
|
|
||||||
|
assert backend.search_ids("unprotected", user=None) == [root.pk]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectiveContentIndexing:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A root document with a newer version
|
||||||
|
WHEN:
|
||||||
|
- The root document is indexed
|
||||||
|
THEN:
|
||||||
|
- The newest version's content is indexed, never the root's own
|
||||||
|
outdated text
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_root_is_indexed_with_latest_version_content(
|
||||||
|
self,
|
||||||
|
backend: TantivyBackend,
|
||||||
|
) -> None:
|
||||||
|
root = Document.objects.create(
|
||||||
|
title="Statement",
|
||||||
|
content="stale original text",
|
||||||
|
checksum="EFF1",
|
||||||
|
pk=95,
|
||||||
|
)
|
||||||
|
Document.objects.create(
|
||||||
|
title="Statement",
|
||||||
|
content="latest version text",
|
||||||
|
checksum="EFF2",
|
||||||
|
pk=96,
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
backend.add_or_update(root)
|
||||||
|
|
||||||
|
assert backend.search_ids("latest", user=None) == [root.pk]
|
||||||
|
assert backend.search_ids("stale", user=None) == []
|
||||||
|
|
||||||
|
|
||||||
class TestIndexDirectoryGarbageCollection:
|
class TestIndexDirectoryGarbageCollection:
|
||||||
"""Regression tests for Tantivy segment files leaking on disk when
|
"""Regression tests for Tantivy segment files leaking on disk when
|
||||||
multiple long-lived worker processes (Granian/Celery) take turns writing
|
multiple long-lived worker processes (Granian/Celery) take turns writing
|
||||||
|
|||||||
@@ -72,10 +72,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
"barcode_enable_tag": None,
|
"barcode_enable_tag": None,
|
||||||
"barcode_tag_mapping": None,
|
"barcode_tag_mapping": None,
|
||||||
"barcode_tag_split": None,
|
"barcode_tag_split": None,
|
||||||
"remote_ocr_engine": None,
|
|
||||||
"remote_ocr_api_key": None,
|
|
||||||
"remote_ocr_endpoint": None,
|
|
||||||
"remote_ocr_mode": None,
|
|
||||||
"ai_enabled": False,
|
"ai_enabled": False,
|
||||||
"llm_embedding_backend": None,
|
"llm_embedding_backend": None,
|
||||||
"llm_embedding_model": None,
|
"llm_embedding_model": None,
|
||||||
@@ -874,49 +870,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
config.refresh_from_db()
|
config.refresh_from_db()
|
||||||
self.assertEqual(config.llm_api_key, None)
|
self.assertEqual(config.llm_api_key, None)
|
||||||
|
|
||||||
def test_update_remote_ocr_api_key(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Existing config with remote_ocr_api_key specified
|
|
||||||
WHEN:
|
|
||||||
- API to update remote_ocr_api_key is called with all *s
|
|
||||||
- API to update remote_ocr_api_key is called with empty string
|
|
||||||
THEN:
|
|
||||||
- remote_ocr_api_key is unchanged
|
|
||||||
- remote_ocr_api_key is set to None
|
|
||||||
"""
|
|
||||||
config = ApplicationConfiguration.objects.first()
|
|
||||||
assert config is not None
|
|
||||||
config.remote_ocr_api_key = "1234567890"
|
|
||||||
config.save()
|
|
||||||
|
|
||||||
# Test with all *
|
|
||||||
response = self.client.patch(
|
|
||||||
f"{self.ENDPOINT}1/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"remote_ocr_api_key": "*" * 32,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
config.refresh_from_db()
|
|
||||||
self.assertEqual(config.remote_ocr_api_key, "1234567890")
|
|
||||||
# Test with empty string
|
|
||||||
response = self.client.patch(
|
|
||||||
f"{self.ENDPOINT}1/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"remote_ocr_api_key": "",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
config.refresh_from_db()
|
|
||||||
self.assertEqual(config.remote_ocr_api_key, None)
|
|
||||||
|
|
||||||
def test_enable_ai_index_triggers_update(self) -> None:
|
def test_enable_ai_index_triggers_update(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -532,29 +532,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
m.assert_called_once()
|
m.assert_called_once()
|
||||||
args, kwargs = m.call_args
|
args, kwargs = m.call_args
|
||||||
self.assertEqual(args[0], [self.doc1.id])
|
self.assertEqual(args[0], [self.doc1.id])
|
||||||
self.assertEqual(kwargs, {"remote_ocr": False})
|
self.assertEqual(len(kwargs), 0)
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.reprocess")
|
|
||||||
def test_reprocess_documents_endpoint_remote_ocr(self, m) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- API data to reprocess a document with remote OCR requested
|
|
||||||
WHEN:
|
|
||||||
- API is called
|
|
||||||
THEN:
|
|
||||||
- reprocess is called with remote_ocr=True
|
|
||||||
"""
|
|
||||||
self.setup_mock(m, "reprocess")
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/reprocess/",
|
|
||||||
json.dumps({"documents": [self.doc1.id], "remote_ocr": True}),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
m.assert_called_once()
|
|
||||||
args, kwargs = m.call_args
|
|
||||||
self.assertEqual(args[0], [self.doc1.id])
|
|
||||||
self.assertEqual(kwargs, {"remote_ocr": True})
|
|
||||||
|
|
||||||
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
@mock.patch("documents.serialisers.bulk_edit.set_storage_path")
|
||||||
def test_api_set_storage_path(self, m) -> None:
|
def test_api_set_storage_path(self, m) -> None:
|
||||||
@@ -1575,29 +1553,6 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_legacy_bulk_edit_reprocess_invalid_remote_ocr(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- The deprecated bulk_edit endpoint with a non-boolean remote_ocr
|
|
||||||
WHEN:
|
|
||||||
- API is called
|
|
||||||
THEN:
|
|
||||||
- The request is rejected rather than passed through to the task
|
|
||||||
"""
|
|
||||||
response = self.client.post(
|
|
||||||
"/api/documents/bulk_edit/",
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"documents": [self.doc1.id],
|
|
||||||
"method": "reprocess",
|
|
||||||
"parameters": {"remote_ocr": "yes please"},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
@mock.patch("documents.views.bulk_edit.edit_pdf")
|
||||||
def test_edit_pdf(self, m) -> None:
|
def test_edit_pdf(self, m) -> None:
|
||||||
self.setup_mock(m, "edit_pdf")
|
self.setup_mock(m, "edit_pdf")
|
||||||
|
|||||||
@@ -60,10 +60,6 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
|||||||
},
|
},
|
||||||
"email_enabled": False,
|
"email_enabled": False,
|
||||||
"ai_enabled": False,
|
"ai_enabled": False,
|
||||||
"remote_ocr": {
|
|
||||||
"configured": False,
|
|
||||||
"mode": "always",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -158,50 +154,6 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
|||||||
str(response.data["settings"]),
|
str(response.data["settings"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
@override_settings(
|
|
||||||
REMOTE_OCR_ENGINE="azureai",
|
|
||||||
REMOTE_OCR_API_KEY="somekey",
|
|
||||||
REMOTE_OCR_ENDPOINT="https://example.cognitiveservices.azure.com",
|
|
||||||
REMOTE_OCR_MODE="workflow_only",
|
|
||||||
)
|
|
||||||
def test_settings_reports_remote_ocr_when_configured(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A fully configured remote OCR engine in workflow_only mode
|
|
||||||
WHEN:
|
|
||||||
- The ui_settings endpoint is called
|
|
||||||
THEN:
|
|
||||||
- The UI is told remote OCR is available and selective, so it can
|
|
||||||
offer it where it would actually change something
|
|
||||||
"""
|
|
||||||
response = self.client.get(self.ENDPOINT, format="json")
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertEqual(
|
|
||||||
response.data["settings"]["remote_ocr"],
|
|
||||||
{"configured": True, "mode": "workflow_only"},
|
|
||||||
)
|
|
||||||
|
|
||||||
@override_settings(
|
|
||||||
REMOTE_OCR_ENGINE="azureai",
|
|
||||||
REMOTE_OCR_API_KEY=None,
|
|
||||||
REMOTE_OCR_ENDPOINT=None,
|
|
||||||
)
|
|
||||||
def test_settings_reports_remote_ocr_incompletely_configured(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An engine named but missing its endpoint and API key
|
|
||||||
WHEN:
|
|
||||||
- The ui_settings endpoint is called
|
|
||||||
THEN:
|
|
||||||
- It is reported as not configured, matching what the parser
|
|
||||||
registry will actually do
|
|
||||||
"""
|
|
||||||
response = self.client.get(self.ENDPOINT, format="json")
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
self.assertFalse(response.data["settings"]["remote_ocr"]["configured"])
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
OAUTH_CALLBACK_BASE_URL="http://localhost:8000",
|
OAUTH_CALLBACK_BASE_URL="http://localhost:8000",
|
||||||
GMAIL_OAUTH_CLIENT_ID="abc123",
|
GMAIL_OAUTH_CLIENT_ID="abc123",
|
||||||
|
|||||||
@@ -1782,56 +1782,3 @@ class TestPDFActions(DirectoriesMixin, TestCase):
|
|||||||
|
|
||||||
self.assertIn("wrong password", str(exc.exception))
|
self.assertIn("wrong password", str(exc.exception))
|
||||||
self.assertIn("Error removing password from document", cm.output[0])
|
self.assertIn("Error removing password from document", cm.output[0])
|
||||||
|
|
||||||
|
|
||||||
class TestBulkEditReprocess(DirectoriesMixin, TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
self.doc = Document.objects.create(
|
|
||||||
title="test",
|
|
||||||
checksum="A",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file")
|
|
||||||
def test_reprocess_defaults_to_local(self, mock_task: mock.Mock) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A reprocess request that says nothing about remote OCR
|
|
||||||
WHEN:
|
|
||||||
- reprocess is called
|
|
||||||
THEN:
|
|
||||||
- The task is queued without asking for the remote engine
|
|
||||||
"""
|
|
||||||
result = bulk_edit.reprocess([self.doc.id])
|
|
||||||
|
|
||||||
self.assertEqual(result, "OK")
|
|
||||||
mock_task.apply_async.assert_called_once()
|
|
||||||
_, kwargs = mock_task.apply_async.call_args
|
|
||||||
self.assertEqual(
|
|
||||||
kwargs["kwargs"],
|
|
||||||
{"document_id": self.doc.id, "remote_ocr": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
@mock.patch("documents.bulk_edit.update_document_content_maybe_archive_file")
|
|
||||||
def test_reprocess_passes_remote_ocr(self, mock_task: mock.Mock) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- A reprocess request that explicitly asks for remote OCR
|
|
||||||
WHEN:
|
|
||||||
- reprocess is called
|
|
||||||
THEN:
|
|
||||||
- The request is forwarded to the task for every document
|
|
||||||
"""
|
|
||||||
other = Document.objects.create(
|
|
||||||
title="test2",
|
|
||||||
checksum="B",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
bulk_edit.reprocess([self.doc.id, other.id], remote_ocr=True)
|
|
||||||
|
|
||||||
self.assertEqual(mock_task.apply_async.call_count, 2)
|
|
||||||
for call in mock_task.apply_async.call_args_list:
|
|
||||||
self.assertTrue(call.kwargs["kwargs"]["remote_ocr"])
|
|
||||||
|
|||||||
@@ -1559,72 +1559,6 @@ class PostConsumeTestCase(DirectoriesMixin, GetConsumerMixin, TestCase):
|
|||||||
consumer.run_post_consume_script(doc)
|
consumer.run_post_consume_script(doc)
|
||||||
|
|
||||||
|
|
||||||
class TestConsumerRemoteOCR(
|
|
||||||
DirectoriesMixin,
|
|
||||||
FileSystemAssertsMixin,
|
|
||||||
GetConsumerMixin,
|
|
||||||
TestCase,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
The consumer resolves the remote OCR mode and the per-document request from
|
|
||||||
workflows into the allow_remote flag it hands to the parser registry.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
patcher = mock.patch("documents.consumer.get_parser_registry")
|
|
||||||
self.mock_registry = patcher.start()
|
|
||||||
self.mock_registry.return_value.get_parser_for_file.return_value = DummyParser
|
|
||||||
self.addCleanup(patcher.stop)
|
|
||||||
|
|
||||||
def _consume(self, *, overrides: DocumentMetadataOverrides | None = None) -> bool:
|
|
||||||
src = (
|
|
||||||
Path(__file__).parent
|
|
||||||
/ "samples"
|
|
||||||
/ "documents"
|
|
||||||
/ "originals"
|
|
||||||
/ "0000001.pdf"
|
|
||||||
)
|
|
||||||
dst = self.dirs.scratch_dir / "sample.pdf"
|
|
||||||
shutil.copy(src, dst)
|
|
||||||
|
|
||||||
with self.get_consumer(dst, overrides=overrides) as consumer:
|
|
||||||
consumer.run()
|
|
||||||
|
|
||||||
_, kwargs = self.mock_registry.return_value.get_parser_for_file.call_args
|
|
||||||
return kwargs["allow_remote"]
|
|
||||||
|
|
||||||
@override_settings(REMOTE_OCR_MODE="always")
|
|
||||||
def test_always_mode_allows_remote(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: Remote OCR mode is 'always'.
|
|
||||||
WHEN: A document is consumed without any workflow asking for it.
|
|
||||||
THEN: The registry is allowed to pick the remote parser.
|
|
||||||
"""
|
|
||||||
self.assertTrue(self._consume())
|
|
||||||
|
|
||||||
@override_settings(REMOTE_OCR_MODE="workflow_only")
|
|
||||||
def test_workflow_only_mode_denies_remote_by_default(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: Remote OCR mode is 'workflow_only'.
|
|
||||||
WHEN: A document is consumed and nothing asked for remote OCR.
|
|
||||||
THEN: The remote parser is excluded.
|
|
||||||
"""
|
|
||||||
self.assertFalse(self._consume())
|
|
||||||
|
|
||||||
@override_settings(REMOTE_OCR_MODE="workflow_only")
|
|
||||||
def test_workflow_only_mode_allows_remote_when_requested(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: Remote OCR mode is 'workflow_only'.
|
|
||||||
WHEN: A workflow set remote_ocr on the metadata overrides.
|
|
||||||
THEN: The registry is allowed to pick the remote parser.
|
|
||||||
"""
|
|
||||||
self.assertTrue(
|
|
||||||
self._consume(overrides=DocumentMetadataOverrides(remote_ocr=True)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMetadataOverrides(TestCase):
|
class TestMetadataOverrides(TestCase):
|
||||||
def test_update_skip_asn_if_exists(self) -> None:
|
def test_update_skip_asn_if_exists(self) -> None:
|
||||||
base = DocumentMetadataOverrides()
|
base = DocumentMetadataOverrides()
|
||||||
@@ -1632,20 +1566,6 @@ class TestMetadataOverrides(TestCase):
|
|||||||
base.update(incoming)
|
base.update(incoming)
|
||||||
self.assertTrue(base.skip_asn_if_exists)
|
self.assertTrue(base.skip_asn_if_exists)
|
||||||
|
|
||||||
def test_update_remote_ocr(self) -> None:
|
|
||||||
base = DocumentMetadataOverrides()
|
|
||||||
base.update(DocumentMetadataOverrides(remote_ocr=True))
|
|
||||||
self.assertTrue(base.remote_ocr)
|
|
||||||
|
|
||||||
def test_update_remote_ocr_is_not_unset(self) -> None:
|
|
||||||
"""
|
|
||||||
A later workflow that says nothing must not undo an earlier one that
|
|
||||||
asked for remote OCR.
|
|
||||||
"""
|
|
||||||
base = DocumentMetadataOverrides(remote_ocr=True)
|
|
||||||
base.update(DocumentMetadataOverrides())
|
|
||||||
self.assertTrue(base.remote_ocr)
|
|
||||||
|
|
||||||
def test_update_actor_and_version_label(self) -> None:
|
def test_update_actor_and_version_label(self) -> None:
|
||||||
base = DocumentMetadataOverrides(
|
base = DocumentMetadataOverrides(
|
||||||
actor_id=1,
|
actor_id=1,
|
||||||
|
|||||||
@@ -287,45 +287,6 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
|
|||||||
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
|
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase):
|
|
||||||
"""
|
|
||||||
Consumption workflows do not run on reprocess, so the remote parser is
|
|
||||||
used only in 'always' mode or when the caller explicitly asks for it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
super().setUp()
|
|
||||||
|
|
||||||
patcher = mock.patch("documents.tasks.get_parser_registry")
|
|
||||||
self.mock_registry = patcher.start()
|
|
||||||
self.mock_registry.return_value.get_parser_for_file.return_value = None
|
|
||||||
self.addCleanup(patcher.stop)
|
|
||||||
|
|
||||||
self.doc = Document.objects.create(
|
|
||||||
title="test",
|
|
||||||
content="my document",
|
|
||||||
checksum="wow",
|
|
||||||
mime_type="application/pdf",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _allow_remote(self, **kwargs) -> bool:
|
|
||||||
tasks.update_document_content_maybe_archive_file(self.doc.pk, **kwargs)
|
|
||||||
_, call_kwargs = self.mock_registry.return_value.get_parser_for_file.call_args
|
|
||||||
return call_kwargs["allow_remote"]
|
|
||||||
|
|
||||||
@override_settings(REMOTE_OCR_MODE="always")
|
|
||||||
def test_always_mode_allows_remote(self) -> None:
|
|
||||||
self.assertTrue(self._allow_remote())
|
|
||||||
|
|
||||||
@override_settings(REMOTE_OCR_MODE="workflow_only")
|
|
||||||
def test_workflow_only_mode_denies_remote_by_default(self) -> None:
|
|
||||||
self.assertFalse(self._allow_remote())
|
|
||||||
|
|
||||||
@override_settings(REMOTE_OCR_MODE="workflow_only")
|
|
||||||
def test_workflow_only_mode_allows_remote_when_requested(self) -> None:
|
|
||||||
self.assertTrue(self._allow_remote(remote_ocr=True))
|
|
||||||
|
|
||||||
|
|
||||||
class TestAIIndex(DirectoriesMixin, TestCase):
|
class TestAIIndex(DirectoriesMixin, TestCase):
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ from typing import TYPE_CHECKING
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
|
from django.db.models import OuterRef
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
|
from django.db.models import Subquery
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
|
||||||
@@ -22,6 +25,24 @@ def versions_newest_first(documents: QuerySet[Document]) -> QuerySet[Document]:
|
|||||||
return documents.order_by(F("version_index").desc(nulls_last=True), "-id")
|
return documents.order_by(F("version_index").desc(nulls_last=True), "-id")
|
||||||
|
|
||||||
|
|
||||||
|
def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Document]:
|
||||||
|
"""
|
||||||
|
Annotates documents with the content of their newest version, falling back
|
||||||
|
to their own, so get_effective_content() can answer from the row rather
|
||||||
|
than querying for the versions of each document
|
||||||
|
"""
|
||||||
|
return documents.annotate(
|
||||||
|
effective_content=Coalesce(
|
||||||
|
Subquery(
|
||||||
|
versions_newest_first(
|
||||||
|
Document.objects.filter(root_document=OuterRef("pk")),
|
||||||
|
).values("content")[:1],
|
||||||
|
),
|
||||||
|
F("content"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
||||||
"""
|
"""
|
||||||
Same sorting as versions_newest_first()
|
Same sorting as versions_newest_first()
|
||||||
|
|||||||
@@ -239,10 +239,8 @@ from paperless import version
|
|||||||
from paperless.celery import app as celery_app
|
from paperless.celery import app as celery_app
|
||||||
from paperless.config import AIConfig
|
from paperless.config import AIConfig
|
||||||
from paperless.config import GeneralConfig
|
from paperless.config import GeneralConfig
|
||||||
from paperless.config import RemoteOCRConfig
|
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless.parsers.registry import get_parser_registry
|
from paperless.parsers.registry import get_parser_registry
|
||||||
from paperless.parsers.remote import RemoteEngineConfig
|
|
||||||
from paperless.serialisers import GroupSerializer
|
from paperless.serialisers import GroupSerializer
|
||||||
from paperless.serialisers import UserSerializer
|
from paperless.serialisers import UserSerializer
|
||||||
from paperless.views import StandardPagination
|
from paperless.views import StandardPagination
|
||||||
@@ -4102,11 +4100,6 @@ class UiSettingsView(GenericAPIView[Any]):
|
|||||||
|
|
||||||
ui_settings["auditlog_enabled"] = settings.AUDIT_LOG_ENABLED
|
ui_settings["auditlog_enabled"] = settings.AUDIT_LOG_ENABLED
|
||||||
|
|
||||||
ui_settings["remote_ocr"] = {
|
|
||||||
"configured": RemoteEngineConfig.from_app_config().engine_is_valid(),
|
|
||||||
"mode": RemoteOCRConfig().remote_ocr_mode,
|
|
||||||
}
|
|
||||||
|
|
||||||
if settings.GMAIL_OAUTH_ENABLED or settings.OUTLOOK_OAUTH_ENABLED:
|
if settings.GMAIL_OAUTH_ENABLED or settings.OUTLOOK_OAUTH_ENABLED:
|
||||||
manager = PaperlessMailOAuth2Manager()
|
manager = PaperlessMailOAuth2Manager()
|
||||||
if settings.GMAIL_OAUTH_ENABLED:
|
if settings.GMAIL_OAUTH_ENABLED:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-08-19 14:23+0000\n"
|
"POT-Creation-Date: 2026-08-19 18:18+0000\n"
|
||||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -2170,151 +2170,151 @@ msgstr ""
|
|||||||
msgid "paperless application settings"
|
msgid "paperless application settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:556
|
#: paperless/settings/__init__.py:562
|
||||||
msgid "English (US)"
|
msgid "English (US)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:557
|
#: paperless/settings/__init__.py:563
|
||||||
msgid "Arabic"
|
msgid "Arabic"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:558
|
#: paperless/settings/__init__.py:564
|
||||||
msgid "Afrikaans"
|
msgid "Afrikaans"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:559
|
#: paperless/settings/__init__.py:565
|
||||||
msgid "Belarusian"
|
msgid "Belarusian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:560
|
#: paperless/settings/__init__.py:566
|
||||||
msgid "Bulgarian"
|
msgid "Bulgarian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:561
|
#: paperless/settings/__init__.py:567
|
||||||
msgid "Catalan"
|
msgid "Catalan"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:562
|
#: paperless/settings/__init__.py:568
|
||||||
msgid "Czech"
|
msgid "Czech"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:563
|
#: paperless/settings/__init__.py:569
|
||||||
msgid "Danish"
|
msgid "Danish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:564
|
#: paperless/settings/__init__.py:570
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:565
|
#: paperless/settings/__init__.py:571
|
||||||
msgid "Greek"
|
msgid "Greek"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:566
|
#: paperless/settings/__init__.py:572
|
||||||
msgid "English (GB)"
|
msgid "English (GB)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:567
|
#: paperless/settings/__init__.py:573
|
||||||
msgid "Spanish"
|
msgid "Spanish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:568
|
#: paperless/settings/__init__.py:574
|
||||||
msgid "Persian"
|
msgid "Persian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:569
|
#: paperless/settings/__init__.py:575
|
||||||
msgid "Finnish"
|
msgid "Finnish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:570
|
#: paperless/settings/__init__.py:576
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:571
|
#: paperless/settings/__init__.py:577
|
||||||
msgid "Hungarian"
|
msgid "Hungarian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:572
|
#: paperless/settings/__init__.py:578
|
||||||
msgid "Indonesian"
|
msgid "Indonesian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:573
|
#: paperless/settings/__init__.py:579
|
||||||
msgid "Italian"
|
msgid "Italian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:574
|
#: paperless/settings/__init__.py:580
|
||||||
msgid "Japanese"
|
msgid "Japanese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:575
|
#: paperless/settings/__init__.py:581
|
||||||
msgid "Korean"
|
msgid "Korean"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:576
|
#: paperless/settings/__init__.py:582
|
||||||
msgid "Luxembourgish"
|
msgid "Luxembourgish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:577
|
#: paperless/settings/__init__.py:583
|
||||||
msgid "Norwegian"
|
msgid "Norwegian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:578
|
#: paperless/settings/__init__.py:584
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:579
|
#: paperless/settings/__init__.py:585
|
||||||
msgid "Polish"
|
msgid "Polish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:580
|
#: paperless/settings/__init__.py:586
|
||||||
msgid "Portuguese (Brazil)"
|
msgid "Portuguese (Brazil)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:581
|
#: paperless/settings/__init__.py:587
|
||||||
msgid "Portuguese"
|
msgid "Portuguese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:582
|
#: paperless/settings/__init__.py:588
|
||||||
msgid "Romanian"
|
msgid "Romanian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:583
|
#: paperless/settings/__init__.py:589
|
||||||
msgid "Russian"
|
msgid "Russian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:584
|
#: paperless/settings/__init__.py:590
|
||||||
msgid "Slovak"
|
msgid "Slovak"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:585
|
#: paperless/settings/__init__.py:591
|
||||||
msgid "Slovenian"
|
msgid "Slovenian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:586
|
#: paperless/settings/__init__.py:592
|
||||||
msgid "Serbian"
|
msgid "Serbian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:587
|
#: paperless/settings/__init__.py:593
|
||||||
msgid "Swedish"
|
msgid "Swedish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:588
|
#: paperless/settings/__init__.py:594
|
||||||
msgid "Turkish"
|
msgid "Turkish"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:589
|
#: paperless/settings/__init__.py:595
|
||||||
msgid "Ukrainian"
|
msgid "Ukrainian"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:590
|
#: paperless/settings/__init__.py:596
|
||||||
msgid "Vietnamese"
|
msgid "Vietnamese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:591
|
#: paperless/settings/__init__.py:597
|
||||||
msgid "Chinese Simplified"
|
msgid "Chinese Simplified"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/settings/__init__.py:592
|
#: paperless/settings/__init__.py:598
|
||||||
msgid "Chinese Traditional"
|
msgid "Chinese Traditional"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -337,6 +337,20 @@ def check_deprecated_v2_ocr_env_vars(
|
|||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
@register()
|
||||||
|
def check_remote_parser_configured(app_configs: Any, **kwargs: Any) -> list[Error]:
|
||||||
|
if settings.REMOTE_OCR_ENGINE == "azureai" and not (
|
||||||
|
settings.REMOTE_OCR_ENDPOINT and settings.REMOTE_OCR_API_KEY
|
||||||
|
):
|
||||||
|
return [
|
||||||
|
Error(
|
||||||
|
"Azure AI remote parser requires endpoint and API key to be configured.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def get_tesseract_langs():
|
def get_tesseract_langs():
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[shutil.which("tesseract"), "--list-langs"],
|
[shutil.which("tesseract"), "--list-langs"],
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from paperless.models import CleanChoices
|
|||||||
from paperless.models import ColorConvertChoices
|
from paperless.models import ColorConvertChoices
|
||||||
from paperless.models import ModeChoices
|
from paperless.models import ModeChoices
|
||||||
from paperless.models import OutputTypeChoices
|
from paperless.models import OutputTypeChoices
|
||||||
from paperless.models import RemoteOCRMode
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
@@ -186,45 +185,6 @@ class GeneralConfig(BaseConfig):
|
|||||||
self.app_logo = app_config.app_logo.url if app_config.app_logo else None
|
self.app_logo = app_config.app_logo.url if app_config.app_logo else None
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class RemoteOCRConfig(BaseConfig):
|
|
||||||
"""
|
|
||||||
Settings for the remote (cloud) OCR parser
|
|
||||||
"""
|
|
||||||
|
|
||||||
remote_ocr_engine: str | None = dataclasses.field(init=False)
|
|
||||||
remote_ocr_api_key: str | None = dataclasses.field(init=False)
|
|
||||||
remote_ocr_endpoint: str | None = dataclasses.field(init=False)
|
|
||||||
remote_ocr_mode: RemoteOCRMode = dataclasses.field(init=False)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
app_config = self._get_config_instance()
|
|
||||||
|
|
||||||
self.remote_ocr_engine = (
|
|
||||||
app_config.remote_ocr_engine or settings.REMOTE_OCR_ENGINE
|
|
||||||
)
|
|
||||||
self.remote_ocr_api_key = (
|
|
||||||
app_config.remote_ocr_api_key or settings.REMOTE_OCR_API_KEY
|
|
||||||
)
|
|
||||||
self.remote_ocr_endpoint = (
|
|
||||||
app_config.remote_ocr_endpoint or settings.REMOTE_OCR_ENDPOINT
|
|
||||||
)
|
|
||||||
self.remote_ocr_mode = app_config.remote_ocr_mode or RemoteOCRMode(
|
|
||||||
settings.REMOTE_OCR_MODE,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def remote_ocr_by_default(self) -> bool:
|
|
||||||
"""
|
|
||||||
Whether every supported document goes to the remote engine.
|
|
||||||
|
|
||||||
When False the remote engine is used only for documents that
|
|
||||||
explicitly asked for it, i.e. a workflow matched during consumption or
|
|
||||||
the user ticked the box when reprocessing.
|
|
||||||
"""
|
|
||||||
return self.remote_ocr_mode == RemoteOCRMode.ALWAYS
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
class AIConfig(BaseConfig):
|
class AIConfig(BaseConfig):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
# Generated by Django 5.2.16 on 2026-08-10 14:37
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("paperless", "0013_applicationconfiguration_llm_request_timeout"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="applicationconfiguration",
|
|
||||||
name="remote_ocr_api_key",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
max_length=1024,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Sets the remote OCR API key",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="applicationconfiguration",
|
|
||||||
name="remote_ocr_endpoint",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
max_length=256,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Sets the remote OCR endpoint",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="applicationconfiguration",
|
|
||||||
name="remote_ocr_engine",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
choices=[("azureai", "Azure AI Document Intelligence")],
|
|
||||||
max_length=32,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Sets the remote OCR engine",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Generated by Django 5.2.16 on 2026-08-10 15:43
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("paperless", "0014_applicationconfiguration_remote_ocr_api_key_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="applicationconfiguration",
|
|
||||||
name="remote_ocr_mode",
|
|
||||||
field=models.CharField(
|
|
||||||
blank=True,
|
|
||||||
choices=[
|
|
||||||
("always", "All supported documents"),
|
|
||||||
("workflow_only", "Only when a workflow enables it"),
|
|
||||||
],
|
|
||||||
max_length=32,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Sets which documents are sent to the remote OCR engine",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -74,23 +74,6 @@ class ColorConvertChoices(models.TextChoices):
|
|||||||
CMYK = ("CMYK", _("CMYK"))
|
CMYK = ("CMYK", _("CMYK"))
|
||||||
|
|
||||||
|
|
||||||
class RemoteOCREngine(models.TextChoices):
|
|
||||||
"""
|
|
||||||
Matches to PAPERLESS_REMOTE_OCR_ENGINE
|
|
||||||
"""
|
|
||||||
|
|
||||||
AZURE_AI = ("azureai", _("Azure AI Document Intelligence"))
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteOCRMode(models.TextChoices):
|
|
||||||
"""
|
|
||||||
Matches to PAPERLESS_REMOTE_OCR_MODE
|
|
||||||
"""
|
|
||||||
|
|
||||||
ALWAYS = ("always", _("All supported documents"))
|
|
||||||
WORKFLOW_ONLY = ("workflow_only", _("Only when a workflow enables it"))
|
|
||||||
|
|
||||||
|
|
||||||
class LLMEmbeddingBackend(models.TextChoices):
|
class LLMEmbeddingBackend(models.TextChoices):
|
||||||
OPENAI_LIKE = ("openai-like", _("OpenAI-compatible"))
|
OPENAI_LIKE = ("openai-like", _("OpenAI-compatible"))
|
||||||
HUGGINGFACE = ("huggingface", _("Huggingface"))
|
HUGGINGFACE = ("huggingface", _("Huggingface"))
|
||||||
@@ -303,44 +286,6 @@ class ApplicationConfiguration(AbstractSingletonModel):
|
|||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
"""
|
|
||||||
Settings for the remote OCR parser
|
|
||||||
"""
|
|
||||||
|
|
||||||
# PAPERLESS_REMOTE_OCR_ENGINE
|
|
||||||
remote_ocr_engine = models.CharField(
|
|
||||||
verbose_name=_("Sets the remote OCR engine"),
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
max_length=32,
|
|
||||||
choices=RemoteOCREngine.choices,
|
|
||||||
)
|
|
||||||
|
|
||||||
# PAPERLESS_REMOTE_OCR_API_KEY
|
|
||||||
remote_ocr_api_key = models.CharField(
|
|
||||||
verbose_name=_("Sets the remote OCR API key"),
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
max_length=1024,
|
|
||||||
)
|
|
||||||
|
|
||||||
# PAPERLESS_REMOTE_OCR_ENDPOINT
|
|
||||||
remote_ocr_endpoint = models.CharField(
|
|
||||||
verbose_name=_("Sets the remote OCR endpoint"),
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
max_length=256,
|
|
||||||
)
|
|
||||||
|
|
||||||
# PAPERLESS_REMOTE_OCR_MODE
|
|
||||||
remote_ocr_mode = models.CharField(
|
|
||||||
verbose_name=_("Sets which documents are sent to the remote OCR engine"),
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
max_length=32,
|
|
||||||
choices=RemoteOCRMode.choices,
|
|
||||||
)
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
AI related settings
|
AI related settings
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -134,11 +134,6 @@ class ParserProtocol(Protocol):
|
|||||||
Author or organisation name.
|
Author or organisation name.
|
||||||
url : str
|
url : str
|
||||||
URL for documentation, source code, or issue tracker.
|
URL for documentation, source code, or issue tracker.
|
||||||
|
|
||||||
Parsers that send document content to a remote service should additionally
|
|
||||||
set ``uses_remote_service = True`` so the registry can exclude them when
|
|
||||||
remote processing has not been requested for a document. The attribute is
|
|
||||||
optional so a parser that omits it is treated as fully local.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -150,10 +145,6 @@ class ParserProtocol(Protocol):
|
|||||||
author: str
|
author: str
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
# NOTE: uses_remote_service is not declared here, the registry reads it
|
|
||||||
# with getattr(cls, ..., False) for backwards-compatibility with existing
|
|
||||||
# parsers
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Class methods
|
# Class methods
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -334,8 +334,6 @@ class ParserRegistry:
|
|||||||
mime_type: str,
|
mime_type: str,
|
||||||
filename: str,
|
filename: str,
|
||||||
path: Path | None = None,
|
path: Path | None = None,
|
||||||
*,
|
|
||||||
allow_remote: bool = True,
|
|
||||||
) -> type[ParserProtocol] | None:
|
) -> type[ParserProtocol] | None:
|
||||||
"""Return the best parser class for the given file, or None.
|
"""Return the best parser class for the given file, or None.
|
||||||
|
|
||||||
@@ -361,11 +359,6 @@ class ParserRegistry:
|
|||||||
path:
|
path:
|
||||||
Optional filesystem path to the file. Forwarded to each
|
Optional filesystem path to the file. Forwarded to each
|
||||||
parser's score method.
|
parser's score method.
|
||||||
allow_remote:
|
|
||||||
When False, parsers that declare ``uses_remote_service = True``
|
|
||||||
are excluded from consideration, so a document is never sent to
|
|
||||||
a remote service. Parsers that do not declare the attribute
|
|
||||||
are treated as local and are always considered.
|
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
@@ -381,13 +374,6 @@ class ParserRegistry:
|
|||||||
if mime_type not in parser_class.supported_mime_types():
|
if mime_type not in parser_class.supported_mime_types():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not allow_remote and getattr(
|
|
||||||
parser_class,
|
|
||||||
"uses_remote_service",
|
|
||||||
False,
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
|
|
||||||
score = parser_class.score(mime_type, filename, path)
|
score = parser_class.score(mime_type, filename, path)
|
||||||
if score is None:
|
if score is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -61,18 +61,6 @@ class RemoteEngineConfig:
|
|||||||
self.api_key = api_key
|
self.api_key = api_key
|
||||||
self.endpoint = endpoint
|
self.endpoint = endpoint
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_app_config(cls) -> Self:
|
|
||||||
"""Build the config from the app config, falling back to the env."""
|
|
||||||
from paperless.config import RemoteOCRConfig
|
|
||||||
|
|
||||||
app_config = RemoteOCRConfig()
|
|
||||||
return cls(
|
|
||||||
engine=app_config.remote_ocr_engine,
|
|
||||||
api_key=app_config.remote_ocr_api_key,
|
|
||||||
endpoint=app_config.remote_ocr_endpoint,
|
|
||||||
)
|
|
||||||
|
|
||||||
def engine_is_valid(self) -> bool:
|
def engine_is_valid(self) -> bool:
|
||||||
"""Return True when the engine is known and fully configured."""
|
"""Return True when the engine is known and fully configured."""
|
||||||
return (
|
return (
|
||||||
@@ -102,9 +90,6 @@ class RemoteDocumentParser:
|
|||||||
Maintainer name.
|
Maintainer name.
|
||||||
url : str
|
url : str
|
||||||
Issue tracker / source URL.
|
Issue tracker / source URL.
|
||||||
uses_remote_service : bool
|
|
||||||
Content is sent to a remote service, True so that the registry
|
|
||||||
can skip this parser if remote processing was not requested.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str = "Paperless-ngx Remote OCR Parser"
|
name: str = "Paperless-ngx Remote OCR Parser"
|
||||||
@@ -112,8 +97,6 @@ class RemoteDocumentParser:
|
|||||||
author: str = "Paperless-ngx Contributors"
|
author: str = "Paperless-ngx Contributors"
|
||||||
url: str = "https://github.com/paperless-ngx/paperless-ngx"
|
url: str = "https://github.com/paperless-ngx/paperless-ngx"
|
||||||
|
|
||||||
uses_remote_service: bool = True
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Class methods
|
# Class methods
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -162,7 +145,11 @@ class RemoteDocumentParser:
|
|||||||
20 when the remote engine is configured and the MIME type is
|
20 when the remote engine is configured and the MIME type is
|
||||||
supported, otherwise None.
|
supported, otherwise None.
|
||||||
"""
|
"""
|
||||||
config = RemoteEngineConfig.from_app_config()
|
config = RemoteEngineConfig(
|
||||||
|
engine=settings.REMOTE_OCR_ENGINE,
|
||||||
|
api_key=settings.REMOTE_OCR_API_KEY,
|
||||||
|
endpoint=settings.REMOTE_OCR_ENDPOINT,
|
||||||
|
)
|
||||||
if not config.engine_is_valid():
|
if not config.engine_is_valid():
|
||||||
return None
|
return None
|
||||||
if mime_type not in _SUPPORTED_MIME_TYPES:
|
if mime_type not in _SUPPORTED_MIME_TYPES:
|
||||||
@@ -257,7 +244,11 @@ class RemoteDocumentParser:
|
|||||||
Whether an archive copy is wanted. For PDFs, False skips the
|
Whether an archive copy is wanted. For PDFs, False skips the
|
||||||
remote engine and uses locally-extracted text instead.
|
remote engine and uses locally-extracted text instead.
|
||||||
"""
|
"""
|
||||||
config = RemoteEngineConfig.from_app_config()
|
config = RemoteEngineConfig(
|
||||||
|
engine=settings.REMOTE_OCR_ENGINE,
|
||||||
|
api_key=settings.REMOTE_OCR_API_KEY,
|
||||||
|
endpoint=settings.REMOTE_OCR_ENDPOINT,
|
||||||
|
)
|
||||||
|
|
||||||
if not config.engine_is_valid():
|
if not config.engine_is_valid():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -219,13 +219,6 @@ class ApplicationConfigurationSerializer(
|
|||||||
allow_null=True,
|
allow_null=True,
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
)
|
)
|
||||||
remote_ocr_api_key = ObfuscatedPasswordField(
|
|
||||||
required=False,
|
|
||||||
allow_null=True,
|
|
||||||
max_length=1024,
|
|
||||||
)
|
|
||||||
|
|
||||||
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key")
|
|
||||||
|
|
||||||
def run_validation(self, data):
|
def run_validation(self, data):
|
||||||
# Empty strings treated as None to avoid unexpected behavior
|
# Empty strings treated as None to avoid unexpected behavior
|
||||||
@@ -237,13 +230,11 @@ class ApplicationConfigurationSerializer(
|
|||||||
data["language"] = None
|
data["language"] = None
|
||||||
if "llm_output_language" in data and data["llm_output_language"] == "":
|
if "llm_output_language" in data and data["llm_output_language"] == "":
|
||||||
data["llm_output_language"] = None
|
data["llm_output_language"] = None
|
||||||
for field in self.OBFUSCATED_FIELDS:
|
if "llm_api_key" in data and data["llm_api_key"] is not None:
|
||||||
if field in data and data[field] is not None:
|
if data["llm_api_key"] == "":
|
||||||
if data[field] == "":
|
data["llm_api_key"] = None
|
||||||
data[field] = None
|
elif len(data["llm_api_key"].replace("*", "")) == 0:
|
||||||
# Not a real value, don't overwrite the stored one
|
del data["llm_api_key"]
|
||||||
elif len(data[field].replace("*", "")) == 0:
|
|
||||||
del data[field]
|
|
||||||
return super().run_validation(data)
|
return super().run_validation(data)
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
|
|||||||
@@ -1203,11 +1203,6 @@ WEBHOOKS_ALLOW_INTERNAL_REQUESTS = get_bool_from_env(
|
|||||||
REMOTE_OCR_ENGINE = os.getenv("PAPERLESS_REMOTE_OCR_ENGINE")
|
REMOTE_OCR_ENGINE = os.getenv("PAPERLESS_REMOTE_OCR_ENGINE")
|
||||||
REMOTE_OCR_API_KEY = os.getenv("PAPERLESS_REMOTE_OCR_API_KEY")
|
REMOTE_OCR_API_KEY = os.getenv("PAPERLESS_REMOTE_OCR_API_KEY")
|
||||||
REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT")
|
REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT")
|
||||||
REMOTE_OCR_MODE = get_choice_from_env(
|
|
||||||
"PAPERLESS_REMOTE_OCR_MODE",
|
|
||||||
{"always", "workflow_only"},
|
|
||||||
default="always",
|
|
||||||
)
|
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# AI Settings #
|
# AI Settings #
|
||||||
|
|||||||
@@ -114,26 +114,7 @@ def remote_parser() -> Generator[RemoteDocumentParser, None, None]:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def empty_remote_ocr_app_config(mocker: MockerFixture) -> MagicMock:
|
def azure_settings(settings: SettingsWrapper) -> SettingsWrapper:
|
||||||
# empty app config without accessing db
|
|
||||||
app_config = mocker.MagicMock(
|
|
||||||
remote_ocr_engine=None,
|
|
||||||
remote_ocr_api_key=None,
|
|
||||||
remote_ocr_endpoint=None,
|
|
||||||
remote_ocr_mode=None,
|
|
||||||
)
|
|
||||||
mocker.patch(
|
|
||||||
"paperless.config.BaseConfig._get_config_instance",
|
|
||||||
return_value=app_config,
|
|
||||||
)
|
|
||||||
return app_config
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def azure_settings(
|
|
||||||
settings: SettingsWrapper,
|
|
||||||
empty_remote_ocr_app_config: MagicMock,
|
|
||||||
) -> SettingsWrapper:
|
|
||||||
"""Configure Django settings for a valid Azure AI OCR engine.
|
"""Configure Django settings for a valid Azure AI OCR engine.
|
||||||
|
|
||||||
Sets ``REMOTE_OCR_ENGINE``, ``REMOTE_OCR_API_KEY``, and
|
Sets ``REMOTE_OCR_ENGINE``, ``REMOTE_OCR_API_KEY``, and
|
||||||
@@ -152,10 +133,7 @@ def azure_settings(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def no_engine_settings(
|
def no_engine_settings(settings: SettingsWrapper) -> SettingsWrapper:
|
||||||
settings: SettingsWrapper,
|
|
||||||
empty_remote_ocr_app_config: MagicMock,
|
|
||||||
) -> SettingsWrapper:
|
|
||||||
"""Configure Django settings with no remote engine configured.
|
"""Configure Django settings with no remote engine configured.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from unittest.mock import Mock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from documents.parsers import ParseError
|
from documents.parsers import ParseError
|
||||||
from paperless.models import ApplicationConfiguration
|
|
||||||
from paperless.parsers import ParserContext
|
from paperless.parsers import ParserContext
|
||||||
from paperless.parsers import ParserProtocol
|
from paperless.parsers import ParserProtocol
|
||||||
from paperless.parsers.remote import RemoteDocumentParser
|
from paperless.parsers.remote import RemoteDocumentParser
|
||||||
@@ -199,21 +198,21 @@ class TestRemoteParserScore:
|
|||||||
|
|
||||||
def test_score_returns_none_when_api_key_missing(
|
def test_score_returns_none_when_api_key_missing(
|
||||||
self,
|
self,
|
||||||
no_engine_settings: SettingsWrapper,
|
settings: SettingsWrapper,
|
||||||
) -> None:
|
) -> None:
|
||||||
no_engine_settings.REMOTE_OCR_ENGINE = "azureai"
|
settings.REMOTE_OCR_ENGINE = "azureai"
|
||||||
no_engine_settings.REMOTE_OCR_ENDPOINT = (
|
settings.REMOTE_OCR_API_KEY = None
|
||||||
"https://test.cognitiveservices.azure.com"
|
settings.REMOTE_OCR_ENDPOINT = "https://test.cognitiveservices.azure.com"
|
||||||
)
|
|
||||||
result = RemoteDocumentParser.score("application/pdf", "doc.pdf")
|
result = RemoteDocumentParser.score("application/pdf", "doc.pdf")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
def test_score_returns_none_when_endpoint_missing(
|
def test_score_returns_none_when_endpoint_missing(
|
||||||
self,
|
self,
|
||||||
no_engine_settings: SettingsWrapper,
|
settings: SettingsWrapper,
|
||||||
) -> None:
|
) -> None:
|
||||||
no_engine_settings.REMOTE_OCR_ENGINE = "azureai"
|
settings.REMOTE_OCR_ENGINE = "azureai"
|
||||||
no_engine_settings.REMOTE_OCR_API_KEY = "key"
|
settings.REMOTE_OCR_API_KEY = "key"
|
||||||
|
settings.REMOTE_OCR_ENDPOINT = None
|
||||||
result = RemoteDocumentParser.score("application/pdf", "doc.pdf")
|
result = RemoteDocumentParser.score("application/pdf", "doc.pdf")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@@ -228,24 +227,6 @@ class TestRemoteParserScore:
|
|||||||
score = RemoteDocumentParser.score("application/pdf", "doc.pdf")
|
score = RemoteDocumentParser.score("application/pdf", "doc.pdf")
|
||||||
assert score is not None and score > 10
|
assert score is not None and score > 10
|
||||||
|
|
||||||
@pytest.mark.django_db
|
|
||||||
def test_score_uses_app_config_when_env_unset(
|
|
||||||
self,
|
|
||||||
settings: SettingsWrapper,
|
|
||||||
) -> None:
|
|
||||||
"""The app config alone is enough to activate the parser."""
|
|
||||||
settings.REMOTE_OCR_ENGINE = None
|
|
||||||
settings.REMOTE_OCR_API_KEY = None
|
|
||||||
settings.REMOTE_OCR_ENDPOINT = None
|
|
||||||
config = ApplicationConfiguration.objects.first()
|
|
||||||
assert config is not None
|
|
||||||
config.remote_ocr_engine = "azureai"
|
|
||||||
config.remote_ocr_api_key = "app-config-key"
|
|
||||||
config.remote_ocr_endpoint = "https://config.cognitiveservices.azure.com"
|
|
||||||
config.save()
|
|
||||||
|
|
||||||
assert RemoteDocumentParser.score("application/pdf", "doc.pdf") == 20
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Properties
|
# Properties
|
||||||
|
|||||||
@@ -1277,8 +1277,6 @@ class TestParserFileTypes:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
# Remote ocr config from ApplicationConfiguration needs DB access
|
|
||||||
@pytest.mark.django_db
|
|
||||||
class TestRasterisedDocumentParserRegistry:
|
class TestRasterisedDocumentParserRegistry:
|
||||||
def test_registered_in_defaults(self) -> None:
|
def test_registered_in_defaults(self) -> None:
|
||||||
from paperless.parsers.registry import ParserRegistry
|
from paperless.parsers.registry import ParserRegistry
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from paperless.checks import audit_log_check
|
|||||||
from paperless.checks import binaries_check
|
from paperless.checks import binaries_check
|
||||||
from paperless.checks import check_default_language_available
|
from paperless.checks import check_default_language_available
|
||||||
from paperless.checks import check_deprecated_db_settings
|
from paperless.checks import check_deprecated_db_settings
|
||||||
|
from paperless.checks import check_remote_parser_configured
|
||||||
from paperless.checks import check_v3_minimum_upgrade_version
|
from paperless.checks import check_v3_minimum_upgrade_version
|
||||||
from paperless.checks import debug_mode_check
|
from paperless.checks import debug_mode_check
|
||||||
from paperless.checks import paths_check
|
from paperless.checks import paths_check
|
||||||
@@ -630,6 +631,31 @@ class TestV3MinimumUpgradeVersionCheck:
|
|||||||
assert check_v3_minimum_upgrade_version(None) == []
|
assert check_v3_minimum_upgrade_version(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteParserChecks:
|
||||||
|
def test_no_engine(self, settings: SettingsWrapper) -> None:
|
||||||
|
settings.REMOTE_OCR_ENGINE = None
|
||||||
|
msgs = check_remote_parser_configured(None)
|
||||||
|
|
||||||
|
assert len(msgs) == 0
|
||||||
|
|
||||||
|
def test_azure_no_endpoint(self, settings: SettingsWrapper) -> None:
|
||||||
|
|
||||||
|
settings.REMOTE_OCR_ENGINE = "azureai"
|
||||||
|
settings.REMOTE_OCR_API_KEY = "somekey"
|
||||||
|
settings.REMOTE_OCR_ENDPOINT = None
|
||||||
|
|
||||||
|
msgs = check_remote_parser_configured(None)
|
||||||
|
|
||||||
|
assert len(msgs) == 1
|
||||||
|
|
||||||
|
msg = msgs[0]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"Azure AI remote parser requires endpoint and API key to be configured."
|
||||||
|
in msg.msg
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestTesseractChecks:
|
class TestTesseractChecks:
|
||||||
def test_default_language(self) -> None:
|
def test_default_language(self) -> None:
|
||||||
check_default_language_available(None)
|
check_default_language_available(None)
|
||||||
|
|||||||
@@ -468,124 +468,6 @@ class TestParserRegistryGetParserForFile:
|
|||||||
assert result is AcceptingBuiltin
|
assert result is AcceptingBuiltin
|
||||||
|
|
||||||
|
|
||||||
class TestParserRegistryRemoteParsers:
|
|
||||||
"""Verify the allow_remote filter in ParserRegistry.get_parser_for_file()."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _remote_parser_cls() -> type:
|
|
||||||
class RemoteParser:
|
|
||||||
name = "remote"
|
|
||||||
version = "1.0"
|
|
||||||
author = "A"
|
|
||||||
url = "https://example.com/remote"
|
|
||||||
uses_remote_service = True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def supported_mime_types(cls):
|
|
||||||
return {"text/plain": ".txt"}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def score(cls, mime_type, filename, path=None):
|
|
||||||
return 20
|
|
||||||
|
|
||||||
return RemoteParser
|
|
||||||
|
|
||||||
def test_remote_parser_wins_when_remote_allowed(
|
|
||||||
self,
|
|
||||||
dummy_parser_cls: type,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A remote parser scoring 20 and a local parser scoring 10.
|
|
||||||
WHEN: get_parser_for_file() is called with allow_remote=True.
|
|
||||||
THEN: The remote parser is returned.
|
|
||||||
"""
|
|
||||||
remote_parser_cls = self._remote_parser_cls()
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(dummy_parser_cls)
|
|
||||||
registry.register_builtin(remote_parser_cls)
|
|
||||||
|
|
||||||
result = registry.get_parser_for_file(
|
|
||||||
"text/plain",
|
|
||||||
"readme.txt",
|
|
||||||
allow_remote=True,
|
|
||||||
)
|
|
||||||
assert result is remote_parser_cls
|
|
||||||
|
|
||||||
def test_remote_parser_skipped_when_remote_not_allowed(
|
|
||||||
self,
|
|
||||||
dummy_parser_cls: type,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A remote parser scoring 20 and a local parser scoring 10.
|
|
||||||
WHEN: get_parser_for_file() is called with allow_remote=False.
|
|
||||||
THEN: The local parser is returned despite its lower score.
|
|
||||||
"""
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(dummy_parser_cls)
|
|
||||||
registry.register_builtin(self._remote_parser_cls())
|
|
||||||
|
|
||||||
result = registry.get_parser_for_file(
|
|
||||||
"text/plain",
|
|
||||||
"readme.txt",
|
|
||||||
allow_remote=False,
|
|
||||||
)
|
|
||||||
assert result is dummy_parser_cls
|
|
||||||
|
|
||||||
def test_no_parser_when_only_remote_available_and_not_allowed(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A registry whose only candidate declares uses_remote_service.
|
|
||||||
WHEN: get_parser_for_file() is called with allow_remote=False.
|
|
||||||
THEN: None is returned — the remote parser is never used as a
|
|
||||||
fallback when remote processing was not requested.
|
|
||||||
"""
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(self._remote_parser_cls())
|
|
||||||
|
|
||||||
result = registry.get_parser_for_file(
|
|
||||||
"text/plain",
|
|
||||||
"readme.txt",
|
|
||||||
allow_remote=False,
|
|
||||||
)
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_parser_without_attribute_treated_as_local(
|
|
||||||
self,
|
|
||||||
dummy_parser_cls: type,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A third-party parser predating uses_remote_service, so it does
|
|
||||||
not declare the attribute at all.
|
|
||||||
WHEN: get_parser_for_file() is called with allow_remote=False.
|
|
||||||
THEN: It is still considered, i.e. treated as fully local, rather
|
|
||||||
than raising AttributeError.
|
|
||||||
"""
|
|
||||||
assert not hasattr(dummy_parser_cls, "uses_remote_service")
|
|
||||||
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(dummy_parser_cls)
|
|
||||||
|
|
||||||
result = registry.get_parser_for_file(
|
|
||||||
"text/plain",
|
|
||||||
"readme.txt",
|
|
||||||
allow_remote=False,
|
|
||||||
)
|
|
||||||
assert result is dummy_parser_cls
|
|
||||||
|
|
||||||
def test_remote_allowed_by_default(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN: A registry containing only a remote parser.
|
|
||||||
WHEN: get_parser_for_file() is called without allow_remote.
|
|
||||||
THEN: The remote parser is returned — callers that do not opt in to
|
|
||||||
the filter keep the previous behaviour.
|
|
||||||
"""
|
|
||||||
remote_parser_cls = self._remote_parser_cls()
|
|
||||||
registry = ParserRegistry()
|
|
||||||
registry.register_builtin(remote_parser_cls)
|
|
||||||
|
|
||||||
result = registry.get_parser_for_file("text/plain", "readme.txt")
|
|
||||||
assert result is remote_parser_cls
|
|
||||||
|
|
||||||
|
|
||||||
class TestDiscover:
|
class TestDiscover:
|
||||||
"""Verify entrypoint discovery in ParserRegistry.discover()."""
|
"""Verify entrypoint discovery in ParserRegistry.discover()."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
"""Tests for RemoteOCRConfig precedence between app config and Django settings."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.test import override_settings
|
|
||||||
|
|
||||||
from paperless.config import RemoteOCRConfig
|
|
||||||
from paperless.models import RemoteOCRMode
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def null_app_config(mocker) -> MagicMock:
|
|
||||||
"""Mock ApplicationConfiguration with all fields None → falls back to Django settings."""
|
|
||||||
return mocker.MagicMock(
|
|
||||||
remote_ocr_engine=None,
|
|
||||||
remote_ocr_api_key=None,
|
|
||||||
remote_ocr_endpoint=None,
|
|
||||||
remote_ocr_mode=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def make_remote_ocr_config(mocker):
|
|
||||||
def _make(app_config, **django_settings_overrides):
|
|
||||||
mocker.patch(
|
|
||||||
"paperless.config.BaseConfig._get_config_instance",
|
|
||||||
return_value=app_config,
|
|
||||||
)
|
|
||||||
with override_settings(**django_settings_overrides):
|
|
||||||
return RemoteOCRConfig()
|
|
||||||
|
|
||||||
return _make
|
|
||||||
|
|
||||||
|
|
||||||
class TestRemoteOCRConfig:
|
|
||||||
def test_falls_back_to_settings(
|
|
||||||
self,
|
|
||||||
make_remote_ocr_config,
|
|
||||||
null_app_config,
|
|
||||||
) -> None:
|
|
||||||
cfg = make_remote_ocr_config(
|
|
||||||
null_app_config,
|
|
||||||
REMOTE_OCR_ENGINE="azureai",
|
|
||||||
REMOTE_OCR_API_KEY="env-key",
|
|
||||||
REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com",
|
|
||||||
REMOTE_OCR_MODE=RemoteOCRMode.WORKFLOW_ONLY,
|
|
||||||
)
|
|
||||||
assert cfg.remote_ocr_engine == "azureai"
|
|
||||||
assert cfg.remote_ocr_api_key == "env-key"
|
|
||||||
assert cfg.remote_ocr_endpoint == "https://env.cognitiveservices.azure.com"
|
|
||||||
assert cfg.remote_ocr_mode == RemoteOCRMode.WORKFLOW_ONLY
|
|
||||||
|
|
||||||
def test_app_config_takes_precedence(
|
|
||||||
self,
|
|
||||||
make_remote_ocr_config,
|
|
||||||
mocker,
|
|
||||||
) -> None:
|
|
||||||
app_config = mocker.MagicMock(
|
|
||||||
remote_ocr_engine="azureai",
|
|
||||||
remote_ocr_api_key="db-key",
|
|
||||||
remote_ocr_endpoint="https://db.cognitiveservices.azure.com",
|
|
||||||
remote_ocr_mode=RemoteOCRMode.WORKFLOW_ONLY,
|
|
||||||
)
|
|
||||||
cfg = make_remote_ocr_config(
|
|
||||||
app_config,
|
|
||||||
REMOTE_OCR_ENGINE=None,
|
|
||||||
REMOTE_OCR_API_KEY="env-key",
|
|
||||||
REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com",
|
|
||||||
REMOTE_OCR_MODE=RemoteOCRMode.ALWAYS,
|
|
||||||
)
|
|
||||||
assert cfg.remote_ocr_engine == "azureai"
|
|
||||||
assert cfg.remote_ocr_api_key == "db-key"
|
|
||||||
assert cfg.remote_ocr_endpoint == "https://db.cognitiveservices.azure.com"
|
|
||||||
assert cfg.remote_ocr_mode == RemoteOCRMode.WORKFLOW_ONLY
|
|
||||||
|
|
||||||
def test_unset_everywhere(
|
|
||||||
self,
|
|
||||||
make_remote_ocr_config,
|
|
||||||
null_app_config,
|
|
||||||
) -> None:
|
|
||||||
cfg = make_remote_ocr_config(
|
|
||||||
null_app_config,
|
|
||||||
REMOTE_OCR_ENGINE=None,
|
|
||||||
REMOTE_OCR_API_KEY=None,
|
|
||||||
REMOTE_OCR_ENDPOINT=None,
|
|
||||||
)
|
|
||||||
assert cfg.remote_ocr_engine is None
|
|
||||||
assert cfg.remote_ocr_api_key is None
|
|
||||||
assert cfg.remote_ocr_endpoint is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestRemoteOCRByDefault:
|
|
||||||
def test_always_mode(self, make_remote_ocr_config, null_app_config) -> None:
|
|
||||||
cfg = make_remote_ocr_config(
|
|
||||||
null_app_config,
|
|
||||||
REMOTE_OCR_MODE=RemoteOCRMode.ALWAYS,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert cfg.remote_ocr_by_default is True
|
|
||||||
|
|
||||||
def test_workflow_only_mode(self, make_remote_ocr_config, null_app_config) -> None:
|
|
||||||
cfg = make_remote_ocr_config(
|
|
||||||
null_app_config,
|
|
||||||
REMOTE_OCR_MODE=RemoteOCRMode.WORKFLOW_ONLY,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert cfg.remote_ocr_by_default is False
|
|
||||||
Reference in New Issue
Block a user