mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-27 13:13:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2250ef96d | ||
|
|
50043ddb66 | ||
|
|
15a5618a6e | ||
|
|
34a01a5bd6 | ||
|
|
cafed919a1 | ||
|
|
549afde1fd | ||
|
|
a8576d827e | ||
|
|
7330c4d9cb | ||
|
|
2e7cd14964 | ||
|
|
5090ffe39e | ||
|
|
35d40b51cb | ||
|
|
78025df405 | ||
|
|
e24db7023a | ||
|
|
f5ddc14588 | ||
|
|
492424f7f2 | ||
|
|
cd525819d7 | ||
|
|
2609327e9c | ||
|
|
b90ccf910f | ||
|
|
c93c996edf | ||
|
|
7f1609332a | ||
|
|
a1f20c9fe7 | ||
|
|
4fd1c60731 | ||
|
|
ba83e5b39a | ||
|
|
1497bd33a1 |
+2
-1
@@ -302,7 +302,8 @@ The following methods are supported:
|
|||||||
- `delete`
|
- `delete`
|
||||||
- No `parameters` required
|
- No `parameters` required
|
||||||
- `reprocess`
|
- `reprocess`
|
||||||
- No `parameters` required
|
- Optional `parameters`: `{ "remote_ocr": true }` to send the documents to the
|
||||||
|
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,6 +2066,18 @@ 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,6 +456,20 @@ 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
|
||||||
|
|||||||
+51
-1
@@ -654,6 +654,48 @@ happened while it was still encrypted, that original version will likewise be mi
|
|||||||
**Current limitation**: Passwords are stored as a simple list without descriptions. To handle
|
**Current limitation**: Passwords are stored as a simple list without descriptions. To handle
|
||||||
multiple PDF types with different passwords, create separate workflows for each use case.
|
multiple PDF types with different passwords, create separate workflows for each use case.
|
||||||
|
|
||||||
|
##### Remote OCR {#workflow-action-remote-ocr}
|
||||||
|
|
||||||
|
"Remote OCR" actions send the document to the configured remote OCR engine instead of processing it
|
||||||
|
locally. To use remote OCR selectively, set the [remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE)
|
||||||
|
to `workflow_only` then add this action to a workflow that matches only the documents you
|
||||||
|
want sent to the remote engine. See [Remote OCR](#remote-ocr) for the engine setup. The action only works with
|
||||||
|
a **Consumption Started** trigger.
|
||||||
|
|
||||||
|
The action takes no options, its presence is what enables remote OCR for a matching document.
|
||||||
|
|
||||||
|
If the remote engine is not configured, or does not support the document's file type, the document is
|
||||||
|
processed locally instead and a warning is written to the log.
|
||||||
|
|
||||||
|
##### Apply AI Suggestions {#workflow-action-apply-ai-suggestions}
|
||||||
|
|
||||||
|
"Apply AI Suggestions" actions ask the configured AI service for title and metadata suggestions,
|
||||||
|
the same as the AI suggestions shown on the document detail page, except applied automatically and in bulk.
|
||||||
|
It requires [AI features](configuration.md#ai) to be enabled. You can specify:
|
||||||
|
|
||||||
|
- Which suggestions to apply: title, tags, correspondent, document type, storage path and / or created
|
||||||
|
date. Suggestions for fields you did not select are discarded.
|
||||||
|
- Whether to create missing items. By default only tags, correspondents and document types that
|
||||||
|
already exist are assigned and any other suggestion is dropped. With this enabled, suggested items
|
||||||
|
that do not exist are created. Storage paths are never created.
|
||||||
|
- Whether to overwrite existing values. By default a field is only filled in if it is currently empty.
|
||||||
|
Note that documents almost always already have a title and created date, so if you select those you
|
||||||
|
will usually want to enable this too. Tags are an exception: suggested tags are always added and
|
||||||
|
never replace the document's existing tags.
|
||||||
|
|
||||||
|
The action works with every trigger **except Consumption Started**, because suggestions are made from
|
||||||
|
the document's text, which does not exist until after the document has been processed.
|
||||||
|
|
||||||
|
Because the query to the AI service is slow, the action is queued and runs in the background rather
|
||||||
|
than as part of the workflow run itself. The document is updated once the suggestions come back.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
Every matching document results in a query to the AI service, which may incur costs and have privacy
|
||||||
|
implications. Queries can be slow, so a workflow matching a large number of documents can occupy the
|
||||||
|
task queue, and delay consumption of new documents, etc. Consider narrowing the trigger filters,
|
||||||
|
running in small batches and / or increasing workers.
|
||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||||
@@ -1090,11 +1132,19 @@ 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.
|
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details. These
|
||||||
|
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 [remote OCR workflow action](#workflow-action-remote-ocr) enables it 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:
|
||||||
|
|||||||
+484
-320
File diff suppressed because it is too large
Load Diff
@@ -14,43 +14,48 @@
|
|||||||
<a ngbNavLink>{{category}}</a>
|
<a ngbNavLink>{{category}}</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
@for (section of getCategorySections(category); track section) {
|
||||||
@for (option of getCategoryOptions(category); track option.key) {
|
@if (section) {
|
||||||
<div class="col">
|
<h5 class="mt-4 mb-3">{{section}}</h5>
|
||||||
<div class="card bg-light">
|
}
|
||||||
<div class="card-body">
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||||
<div class="card-title d-flex align-items-center">
|
@for (option of getCategoryOptions(category, section); track option.key) {
|
||||||
<h6 class="mb-0">
|
<div class="col">
|
||||||
{{option.title}}
|
<div class="card bg-light">
|
||||||
</h6>
|
<div class="card-body">
|
||||||
<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">
|
<div class="card-title d-flex align-items-center">
|
||||||
<i-bs name="info-circle"></i-bs>
|
<h6 class="mb-0">
|
||||||
</a>
|
{{option.title}}
|
||||||
@if (isSet(option.key)) {
|
</h6>
|
||||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(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">
|
||||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
<i-bs name="info-circle"></i-bs>
|
||||||
</button>
|
</a>
|
||||||
|
@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,7 +8,11 @@ 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 { OutputTypeConfig } from 'src/app/data/paperless-config'
|
import {
|
||||||
|
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'
|
||||||
@@ -158,4 +162,24 @@ 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,8 +74,20 @@ export class ConfigComponent
|
|||||||
return Object.values(ConfigCategory)
|
return Object.values(ConfigCategory)
|
||||||
}
|
}
|
||||||
|
|
||||||
getCategoryOptions(category: string): ConfigOption[] {
|
getCategorySections(category: string): string[] {
|
||||||
return PaperlessConfigOptions.filter((o) => o.category === category)
|
return [
|
||||||
|
...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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
||||||
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
||||||
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<i-bs width="1.5em" height="1.5em" name="list"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0 order-sm-0"
|
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0 order-sm-0"
|
||||||
[ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
|
[ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
|
||||||
@@ -10,16 +10,12 @@
|
|||||||
tourAnchor="tour.intro">
|
tourAnchor="tour.intro">
|
||||||
@if (!hasCustomBranding) {
|
@if (!hasCustomBranding) {
|
||||||
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
|
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
|
||||||
<svg class="brand-mark brand-mark-slim d-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
<pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
|
||||||
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
|
||||||
</svg>
|
|
||||||
} @else {
|
} @else {
|
||||||
@if (customAppLogo) {
|
@if (customAppLogo) {
|
||||||
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
||||||
} @else {
|
} @else {
|
||||||
<svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
<pngx-brand-mark class="brand-mark"></pngx-brand-mark>
|
||||||
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
|
||||||
</svg>
|
|
||||||
}
|
}
|
||||||
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
||||||
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
||||||
@@ -29,9 +25,9 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-3 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
||||||
[class.mobile-hidden]="mobileSearchHidden()">
|
[class.mobile-hidden]="mobileSearchHidden()">
|
||||||
<div class="col-12 header-search">
|
<div class="col-12 header-search mx-auto">
|
||||||
<pngx-global-search></pngx-global-search>
|
<pngx-global-search></pngx-global-search>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +86,7 @@
|
|||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link">
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span>
|
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -99,7 +95,7 @@
|
|||||||
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||||
[routerLinkActiveOptions]="{ paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }"
|
[routerLinkActiveOptions]="{ paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }"
|
||||||
(click)="closeMenu()"
|
(click)="closeMenu()"
|
||||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
|
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -118,7 +114,7 @@
|
|||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
||||||
@if (showSidebarCounts && !slimSidebarEnabled) {
|
@if (showSidebarCounts && !slimSidebarEnabled) {
|
||||||
@@ -156,7 +152,7 @@
|
|||||||
<li class="nav-item w-100 app-link">
|
<li class="nav-item w-100 app-link">
|
||||||
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
|
||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
||||||
<span class="close flex-column justify-content-center"
|
<span class="close flex-column justify-content-center"
|
||||||
@@ -170,7 +166,7 @@
|
|||||||
<li class="nav-item w-100 app-link">
|
<li class="nav-item w-100 app-link">
|
||||||
<button type="button" class="nav-link nav-link-action app-link w-100 text-start"
|
<button type="button" class="nav-link nav-link-action app-link w-100 text-start"
|
||||||
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
||||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
|
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -189,7 +185,7 @@
|
|||||||
<div class="d-flex align-items-center attributes-row">
|
<div class="d-flex align-items-center attributes-row">
|
||||||
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active"
|
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active"
|
||||||
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
||||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span>
|
<i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -241,7 +237,7 @@
|
|||||||
}
|
}
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span>
|
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -250,7 +246,7 @@
|
|||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
tourAnchor="tour.workflows">
|
tourAnchor="tour.workflows">
|
||||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
|
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -258,14 +254,14 @@
|
|||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
tourAnchor="tour.mail">
|
tourAnchor="tour.mail">
|
||||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span>
|
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span>
|
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -281,21 +277,21 @@
|
|||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||||
tourAnchor="tour.settings">
|
tourAnchor="tour.settings">
|
||||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span>
|
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||||
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span>
|
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span>
|
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@@ -304,7 +300,7 @@
|
|||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
|
||||||
tourAnchor="tour.file-tasks">
|
tourAnchor="tour.file-tasks">
|
||||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
|
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
|
||||||
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
|
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
|
||||||
@@ -317,26 +313,26 @@
|
|||||||
@if (permissionsService.isAdmin()) {
|
@if (permissionsService.isAdmin()) {
|
||||||
<li class="nav-item app-link">
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span>
|
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||||
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
|
||||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
|
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||||
<div class="me-3">
|
<div class="me-3">
|
||||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
{{ versionString }}
|
{{ versionString }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -67,6 +67,23 @@
|
|||||||
main.mobile-search-hidden {
|
main.mobile-search-hidden {
|
||||||
padding-top: 56px;
|
padding-top: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-toggler {
|
||||||
|
padding-left: calc(12px - (1.5em * 2.5 / 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Below sm the search gets its own full-width row, so line it up with main's content edge
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.search-container {
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@@ -97,7 +114,7 @@ main {
|
|||||||
|
|
||||||
li.nav-item span.badge {
|
li.nav-item span.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: 2px;
|
margin-right: -4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,18 +158,11 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim {
|
|
||||||
li.nav-item span.badge {
|
|
||||||
display: inline-block;
|
|
||||||
margin-right: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-slim-toggler {
|
.sidebar-slim-toggler {
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: calc(var(--pngx-sidebar-width) - 12px);
|
left: calc(var(--pngx-sidebar-width) - 12px);
|
||||||
top: 72px;
|
bottom: 16px;
|
||||||
z-index: 996;
|
z-index: 996;
|
||||||
--bs-btn-padding-x: 0.35rem;
|
--bs-btn-padding-x: 0.35rem;
|
||||||
--bs-btn-padding-y: 0.125rem;
|
--bs-btn-padding-y: 0.125rem;
|
||||||
@@ -227,6 +237,10 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar .nav-anchor, .sidebar .nav-label {
|
||||||
|
padding: .25rem .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
.attributes-row {
|
.attributes-row {
|
||||||
border-radius: .55rem;
|
border-radius: .55rem;
|
||||||
margin: .1rem 0;
|
margin: .1rem 0;
|
||||||
@@ -403,7 +417,7 @@ main {
|
|||||||
|
|
||||||
:host ::ng-deep .navbar-official-logo {
|
:host ::ng-deep .navbar-official-logo {
|
||||||
.leaf {
|
.leaf {
|
||||||
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 70%, var(--bs-primary)) !important;
|
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 85%, var(--bs-primary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
@@ -444,7 +458,7 @@ main {
|
|||||||
|
|
||||||
.header-search {
|
.header-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 44rem;
|
max-width: 55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 575.98px) {
|
@media screen and (max-width: 575.98px) {
|
||||||
@@ -458,6 +472,7 @@ main {
|
|||||||
|
|
||||||
.navbar-toggler {
|
.navbar-toggler {
|
||||||
grid-area: toggler;
|
grid-area: toggler;
|
||||||
|
color: var(--pngx-primary-text-contrast);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { TasksService } from 'src/app/services/tasks.service'
|
|||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ChatComponent } from '../chat/chat/chat.component'
|
import { ChatComponent } from '../chat/chat/chat.component'
|
||||||
|
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
@@ -61,6 +62,7 @@ const SCROLL_THRESHOLD = 16
|
|||||||
imports: [
|
imports: [
|
||||||
GlobalSearchComponent,
|
GlobalSearchComponent,
|
||||||
LogoComponent,
|
LogoComponent,
|
||||||
|
BrandMarkComponent,
|
||||||
DocumentTitlePipe,
|
DocumentTitlePipe,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
ToastsDropdownComponent,
|
ToastsDropdownComponent,
|
||||||
@@ -278,6 +280,10 @@ export class AppFrameComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get slimSidebarPopoversEnabled(): boolean {
|
||||||
|
return this.slimSidebarEnabled && !this.isMobileViewport()
|
||||||
|
}
|
||||||
|
|
||||||
get attributesSectionsCollapsed(): boolean {
|
get attributesSectionsCollapsed(): boolean {
|
||||||
this.settingsService.trackChanges()
|
this.settingsService.trackChanges()
|
||||||
return this.settingsService
|
return this.settingsService
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<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
@@ -0,0 +1,72 @@
|
|||||||
|
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
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
@@ -455,6 +455,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@case (WorkflowActionType.RemoteOcr) {
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<p class="text-muted small" i18n>The document will be sent to the configured remote OCR service. May incur costs.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case (WorkflowActionType.ApplyAiSuggestions) {
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<p class="text-muted small" i18n>The document will be sent to the configured AI service for suggestions. Consider costs and privacy.</p>
|
||||||
|
<pngx-input-select
|
||||||
|
i18n-title
|
||||||
|
title="Apply suggestions for"
|
||||||
|
[items]="aiSuggestionFieldOptions"
|
||||||
|
[multiple]="true"
|
||||||
|
formControlName="ai_suggestion_fields"
|
||||||
|
[error]="error?.actions?.[i]?.ai_suggestion_fields"
|
||||||
|
hint="Suggestions for fields that are not selected are discarded."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-switch
|
||||||
|
[horizontal]="true"
|
||||||
|
i18n-title
|
||||||
|
title="Create missing items"
|
||||||
|
formControlName="ai_create_missing"
|
||||||
|
hint="Create suggested tags, correspondents and document types that do not exist yet."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-switch
|
||||||
|
[horizontal]="true"
|
||||||
|
i18n-title
|
||||||
|
title="Overwrite existing values"
|
||||||
|
formControlName="ai_overwrite_existing"
|
||||||
|
hint="Apply suggestions even if the document already has a value. Tags are always added, never replaced."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
+228
-3
@@ -22,6 +22,7 @@ import {
|
|||||||
} from 'src/app/data/matching-model'
|
} from 'src/app/data/matching-model'
|
||||||
import { Workflow } from 'src/app/data/workflow'
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
import {
|
import {
|
||||||
|
AISuggestionField,
|
||||||
WorkflowAction,
|
WorkflowAction,
|
||||||
WorkflowActionType,
|
WorkflowActionType,
|
||||||
} from 'src/app/data/workflow-action'
|
} from 'src/app/data/workflow-action'
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
DocumentSource,
|
DocumentSource,
|
||||||
WorkflowTriggerType,
|
WorkflowTriggerType,
|
||||||
} from 'src/app/data/workflow-trigger'
|
} from 'src/app/data/workflow-trigger'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
@@ -48,6 +50,7 @@ import { TagsComponent } from '../../input/tags/tags.component'
|
|||||||
import { TextComponent } from '../../input/text/text.component'
|
import { TextComponent } from '../../input/text/text.component'
|
||||||
import { EditDialogMode } from '../edit-dialog.component'
|
import { EditDialogMode } from '../edit-dialog.component'
|
||||||
import {
|
import {
|
||||||
|
AI_SUGGESTION_FIELD_OPTIONS,
|
||||||
DOCUMENT_SOURCE_OPTIONS,
|
DOCUMENT_SOURCE_OPTIONS,
|
||||||
SCHEDULE_DATE_FIELD_OPTIONS,
|
SCHEDULE_DATE_FIELD_OPTIONS,
|
||||||
TriggerFilterType,
|
TriggerFilterType,
|
||||||
@@ -224,7 +227,12 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
).toEqual('Document Added')
|
).toEqual('Document Added')
|
||||||
expect(component.getTriggerTypeOptionName(null)).toEqual('')
|
expect(component.getTriggerTypeOptionName(null)).toEqual('')
|
||||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||||
expect(component.actionTypeOptions).toEqual(WORKFLOW_ACTION_OPTIONS)
|
// Remote OCR is absent until the workflow has a consumption trigger
|
||||||
|
expect(component.actionTypeOptions).toEqual(
|
||||||
|
WORKFLOW_ACTION_OPTIONS.filter(
|
||||||
|
(a) => a.id !== WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
)
|
||||||
expect(
|
expect(
|
||||||
component.getActionTypeOptionName(WorkflowActionType.Assignment)
|
component.getActionTypeOptionName(WorkflowActionType.Assignment)
|
||||||
).toEqual('Assignment')
|
).toEqual('Assignment')
|
||||||
@@ -233,14 +241,231 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
SCHEDULE_DATE_FIELD_OPTIONS
|
SCHEDULE_DATE_FIELD_OPTIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
// Email disabled
|
// Email, remote OCR and AI all disabled
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.actionTypeOptions).toEqual(
|
expect(component.actionTypeOptions).toEqual(
|
||||||
WORKFLOW_ACTION_OPTIONS.filter((a) => a.id !== WorkflowActionType.Email)
|
WORKFLOW_ACTION_OPTIONS.filter(
|
||||||
|
(a) =>
|
||||||
|
a.id !== WorkflowActionType.Email &&
|
||||||
|
a.id !== WorkflowActionType.RemoteOcr &&
|
||||||
|
a.id !== WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should offer remote OCR only for consumption workflows', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
|
||||||
|
// A consumption trigger makes the action reachable
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
|
||||||
|
// Any other trigger type runs after the document has been parsed
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 2',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
// Nothing for the action to apply to yet
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
|
||||||
|
// addTrigger creates the form field with emitEvent false, so the options
|
||||||
|
// have to be computed on read rather than cached from valueChanges
|
||||||
|
component.addTrigger()
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
|
||||||
|
// Switching that trigger to a type that runs after parsing removes it
|
||||||
|
component.triggerFields
|
||||||
|
.at(0)
|
||||||
|
.get('type')
|
||||||
|
.setValue(WorkflowTriggerType.DocumentAdded)
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep remote OCR listed when an action already uses it', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
|
||||||
|
// Otherwise changing the trigger would silently blank the selection
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [{ type: WorkflowActionType.RemoteOcr }],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not offer remote OCR when no engine is configured', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'get')
|
||||||
|
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||||
|
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
|
||||||
|
// Consumption runs before the document has been parsed, so there would be
|
||||||
|
// no content to make suggestions from
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
|
||||||
|
// A second, usable trigger is enough
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 2',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [
|
||||||
|
{ type: WorkflowTriggerType.Consumption },
|
||||||
|
{ type: WorkflowTriggerType.DocumentAdded },
|
||||||
|
],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||||
|
WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
|
||||||
|
// Otherwise changing the trigger would silently blank the selection
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||||
|
actions: [{ type: WorkflowActionType.ApplyAiSuggestions }],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||||
|
WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not offer apply AI suggestions when AI is disabled', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'get')
|
||||||
|
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
|
||||||
|
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create form fields for apply AI suggestions options', () => {
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: WorkflowActionType.ApplyAiSuggestions,
|
||||||
|
ai_suggestion_fields: [
|
||||||
|
AISuggestionField.Title,
|
||||||
|
AISuggestionField.Tags,
|
||||||
|
],
|
||||||
|
ai_create_missing: true,
|
||||||
|
ai_overwrite_existing: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
const action = component.actionFields.at(0)
|
||||||
|
expect(action.get('ai_suggestion_fields').value).toEqual([
|
||||||
|
AISuggestionField.Title,
|
||||||
|
AISuggestionField.Tags,
|
||||||
|
])
|
||||||
|
expect(action.get('ai_create_missing').value).toBeTruthy()
|
||||||
|
expect(action.get('ai_overwrite_existing').value).toBeTruthy()
|
||||||
|
expect(component.aiSuggestionFieldOptions).toEqual(
|
||||||
|
AI_SUGGESTION_FIELD_OPTIONS
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should default apply AI suggestions options on a new action', () => {
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.addAction()
|
||||||
|
|
||||||
|
const action = component.actionFields.at(component.actionFields.length - 1)
|
||||||
|
expect(action.get('ai_suggestion_fields').value).toEqual([])
|
||||||
|
expect(action.get('ai_create_missing').value).toBeFalsy()
|
||||||
|
expect(action.get('ai_overwrite_existing').value).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support add and remove triggers and actions', () => {
|
it('should support add and remove triggers and actions', () => {
|
||||||
component.object = workflow
|
component.object = workflow
|
||||||
component.addTrigger()
|
component.addTrigger()
|
||||||
|
|||||||
+102
-10
@@ -30,6 +30,7 @@ import { StoragePath } from 'src/app/data/storage-path'
|
|||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { Workflow } from 'src/app/data/workflow'
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
import {
|
import {
|
||||||
|
AISuggestionField,
|
||||||
WorkflowAction,
|
WorkflowAction,
|
||||||
WorkflowActionType,
|
WorkflowActionType,
|
||||||
} from 'src/app/data/workflow-action'
|
} from 'src/app/data/workflow-action'
|
||||||
@@ -148,6 +149,41 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
id: WorkflowActionType.MoveToTrash,
|
id: WorkflowActionType.MoveToTrash,
|
||||||
name: $localize`Move to trash`,
|
name: $localize`Move to trash`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: WorkflowActionType.RemoteOcr,
|
||||||
|
name: $localize`Remote OCR`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: WorkflowActionType.ApplyAiSuggestions,
|
||||||
|
name: $localize`Apply AI suggestions`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const AI_SUGGESTION_FIELD_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: AISuggestionField.Title,
|
||||||
|
name: $localize`Title`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.Tags,
|
||||||
|
name: $localize`Tags`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.Correspondent,
|
||||||
|
name: $localize`Correspondent`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.DocumentType,
|
||||||
|
name: $localize`Document type`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.StoragePath,
|
||||||
|
name: $localize`Storage path`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.Created,
|
||||||
|
name: $localize`Created date`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export enum TriggerFilterType {
|
export enum TriggerFilterType {
|
||||||
@@ -504,8 +540,6 @@ export class WorkflowEditDialogComponent
|
|||||||
|
|
||||||
expandedItem: number = null
|
expandedItem: number = null
|
||||||
|
|
||||||
readonly allowedActionTypes = signal([])
|
|
||||||
|
|
||||||
private readonly triggerFilterOptionsMap = new WeakMap<
|
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||||
FormArray,
|
FormArray,
|
||||||
TriggerFilterOption[]
|
TriggerFilterOption[]
|
||||||
@@ -548,13 +582,58 @@ export class WorkflowEditDialogComponent
|
|||||||
this.checkRemovalActionFields.bind(this)
|
this.checkRemovalActionFields.bind(this)
|
||||||
)
|
)
|
||||||
this.checkRemovalActionFields(this.objectForm.value)
|
this.checkRemovalActionFields(this.objectForm.value)
|
||||||
this.allowedActionTypes.set(
|
}
|
||||||
this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)
|
|
||||||
? WORKFLOW_ACTION_OPTIONS
|
private allowedActionTypes: typeof WORKFLOW_ACTION_OPTIONS = null
|
||||||
: WORKFLOW_ACTION_OPTIONS.filter(
|
|
||||||
(a) => a.id !== WorkflowActionType.Email
|
private getAllowedActionTypes() {
|
||||||
)
|
let allowed = WORKFLOW_ACTION_OPTIONS
|
||||||
)
|
|
||||||
|
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
|
||||||
|
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote OCR is decided before the document is parsed, so it is only
|
||||||
|
// offered for workflows that run at consumption.
|
||||||
|
const formWorkflow: Workflow = this.objectForm?.value
|
||||||
|
const remoteOcrUsable =
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
||||||
|
(formWorkflow?.triggers?.some(
|
||||||
|
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
||||||
|
) ||
|
||||||
|
formWorkflow?.actions?.some(
|
||||||
|
(action) => action.type === WorkflowActionType.RemoteOcr
|
||||||
|
))
|
||||||
|
if (!remoteOcrUsable) {
|
||||||
|
allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only available after consumption. Unlike remote OCR this is hidden only
|
||||||
|
// once every trigger is consumption, so it stays offered on a workflow
|
||||||
|
// that has no triggers yet.
|
||||||
|
const aiSuggestionsUsable =
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) &&
|
||||||
|
(!formWorkflow?.triggers?.length ||
|
||||||
|
formWorkflow.triggers.some(
|
||||||
|
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
||||||
|
) ||
|
||||||
|
formWorkflow.actions?.some(
|
||||||
|
(action) => action.type === WorkflowActionType.ApplyAiSuggestions
|
||||||
|
))
|
||||||
|
if (!aiSuggestionsUsable) {
|
||||||
|
allowed = allowed.filter(
|
||||||
|
(a) => a.id !== WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.allowedActionTypes?.length === allowed.length &&
|
||||||
|
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
|
||||||
|
) {
|
||||||
|
return this.allowedActionTypes
|
||||||
|
}
|
||||||
|
this.allowedActionTypes = allowed
|
||||||
|
return allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkRemovalActionFields(formWorkflow: Workflow) {
|
private checkRemovalActionFields(formWorkflow: Workflow) {
|
||||||
@@ -1198,6 +1277,11 @@ export class WorkflowEditDialogComponent
|
|||||||
passwords: new FormControl(
|
passwords: new FormControl(
|
||||||
this.formatPasswords(action.passwords ?? [])
|
this.formatPasswords(action.passwords ?? [])
|
||||||
),
|
),
|
||||||
|
ai_suggestion_fields: new FormControl(
|
||||||
|
action.ai_suggestion_fields ?? []
|
||||||
|
),
|
||||||
|
ai_create_missing: new FormControl(!!action.ai_create_missing),
|
||||||
|
ai_overwrite_existing: new FormControl(!!action.ai_overwrite_existing),
|
||||||
}),
|
}),
|
||||||
{ emitEvent }
|
{ emitEvent }
|
||||||
)
|
)
|
||||||
@@ -1279,13 +1363,18 @@ export class WorkflowEditDialogComponent
|
|||||||
|
|
||||||
get actionTypeOptions() {
|
get actionTypeOptions() {
|
||||||
this.settingsService.trackChanges()
|
this.settingsService.trackChanges()
|
||||||
return this.allowedActionTypes()
|
// Computed on read rather than cached
|
||||||
|
return this.getAllowedActionTypes()
|
||||||
}
|
}
|
||||||
|
|
||||||
getActionTypeOptionName(type: WorkflowActionType): string {
|
getActionTypeOptionName(type: WorkflowActionType): string {
|
||||||
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
|
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get aiSuggestionFieldOptions() {
|
||||||
|
return AI_SUGGESTION_FIELD_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
addAction() {
|
addAction() {
|
||||||
if (!this.object) {
|
if (!this.object) {
|
||||||
this.object = Object.assign({}, this.objectForm.value)
|
this.object = Object.assign({}, this.objectForm.value)
|
||||||
@@ -1339,6 +1428,9 @@ export class WorkflowEditDialogComponent
|
|||||||
include_document: false,
|
include_document: false,
|
||||||
},
|
},
|
||||||
passwords: [],
|
passwords: [],
|
||||||
|
ai_suggestion_fields: [],
|
||||||
|
ai_create_missing: false,
|
||||||
|
ai_overwrite_existing: false,
|
||||||
}
|
}
|
||||||
this.object.actions.push(action)
|
this.object.actions.push(action)
|
||||||
this.createActionField(action)
|
this.createActionField(action)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg class="w-100 h-100 d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 545 B |
@@ -0,0 +1,14 @@
|
|||||||
|
import { Component, input } from '@angular/core'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-brand-mark',
|
||||||
|
templateUrl: './brand-mark.component.html',
|
||||||
|
host: {
|
||||||
|
'[style.width]': 'width()',
|
||||||
|
'[style.height]': 'height()',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class BrandMarkComponent {
|
||||||
|
readonly width = input<string>(null)
|
||||||
|
readonly height = input<string>(null)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="row pt-3 pb-3 pb-md-2 align-items-center">
|
<div class="row pt-3 pb-2 align-items-center">
|
||||||
<div class="col-md text-truncate">
|
<div class="col-md text-truncate">
|
||||||
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4">
|
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
|
||||||
<span class="text-truncate">{{title()}}</span>
|
<span class="text-truncate">{{title()}}</span>
|
||||||
@if (id()) {
|
@if (id()) {
|
||||||
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
|
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
|
||||||
@@ -12,10 +12,10 @@
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@if (subTitle()) {
|
@if (subTitle()) {
|
||||||
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
||||||
}
|
}
|
||||||
@if (info()) {
|
@if (info()) {
|
||||||
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
<button class="btn btn-sm btn-link text-muted p-0 ms-2 p-md-2 ms-md-0" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||||
<i-bs name="question-circle"></i-bs>
|
<i-bs name="question-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #infoPopover>
|
<ng-template #infoPopover>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
h3 {
|
h3 {
|
||||||
min-height: calc(1.325rem + 0.9vw);
|
min-height: calc(1.325rem + 0.9vw);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
@@ -7,6 +8,16 @@ h3 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop the subtitle onto its own and shrink it
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
h3 > .page-subtitle {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
font-size: .7rem;
|
||||||
|
line-height: 1.3 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: .35rem;
|
margin-bottom: .35rem;
|
||||||
|
|||||||
+16
-8
@@ -1,10 +1,18 @@
|
|||||||
<ngb-alert class="pe-3" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
|
<ngb-alert class="welcome-widget bg-light shadow-sm pe-3 position-relative overflow-hidden" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
|
||||||
<h4 class="alert-heading"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
|
<pngx-brand-mark class="welcome-watermark d-none d-md-block position-absolute top-50 translate-middle-y text-primary pe-none" width="26rem" height="26rem"></pngx-brand-mark>
|
||||||
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
|
<div class="d-flex align-items-start gap-3 gap-md-4 position-relative">
|
||||||
<p i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>
|
<pngx-brand-mark class="d-none d-sm-block flex-shrink-0 text-primary" width="3rem" height="3rem"></pngx-brand-mark>
|
||||||
<hr>
|
<div class="flex-grow-1">
|
||||||
<div class="d-flex align-items-end">
|
<h4 class="alert-heading text-primary fw-semibold"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
|
||||||
<p class="lead fs-6 m-0"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
|
<div class="welcome-copy text-dark">
|
||||||
<button class="btn btn-primary ms-auto flex-shrink-0" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> →</button>
|
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
|
||||||
|
<p class="mb-0" i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>
|
||||||
|
</div>
|
||||||
|
<hr class="opacity-100">
|
||||||
|
<div class="d-flex flex-column flex-sm-row align-items-sm-center gap-3">
|
||||||
|
<p class="m-0 text-secondary"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
|
||||||
|
<button class="btn btn-primary ms-sm-auto flex-shrink-0 align-self-start align-self-sm-auto" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ngb-alert>
|
</ngb-alert>
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
.welcome-widget {
|
||||||
|
border-color: color-mix(in srgb, var(--bs-primary) 50%, transparent);
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border-top-color: color-mix(in srgb, var(--bs-primary) 25%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-copy {
|
||||||
|
max-width: 68ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-watermark {
|
||||||
|
right: -6rem;
|
||||||
|
opacity: .07;
|
||||||
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,12 +1,13 @@
|
|||||||
import { Component, EventEmitter, Output, inject } from '@angular/core'
|
import { Component, EventEmitter, Output, inject } from '@angular/core'
|
||||||
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
|
import { BrandMarkComponent } from '../../../common/logo/brand-mark/brand-mark.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-welcome-widget',
|
selector: 'pngx-welcome-widget',
|
||||||
templateUrl: './welcome-widget.component.html',
|
templateUrl: './welcome-widget.component.html',
|
||||||
styleUrls: ['./welcome-widget.component.scss'],
|
styleUrls: ['./welcome-widget.component.scss'],
|
||||||
imports: [NgbAlertModule],
|
imports: [NgbAlertModule, BrandMarkComponent],
|
||||||
})
|
})
|
||||||
export class WelcomeWidgetComponent {
|
export class WelcomeWidgetComponent {
|
||||||
readonly tourService = inject(TourService)
|
readonly tourService = inject(TourService)
|
||||||
|
|||||||
@@ -963,12 +963,24 @@ 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] })
|
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
|
||||||
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,6 +97,7 @@ 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'
|
||||||
@@ -1398,7 +1399,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
reprocess() {
|
reprocess() {
|
||||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
let modal = this.modalService.open(ReprocessConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Reprocess confirm`
|
modal.componentInstance.title = $localize`Reprocess confirm`
|
||||||
@@ -1409,7 +1410,10 @@ 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({ documents: [this.document().id] })
|
.reprocessDocuments(
|
||||||
|
{ documents: [this.document().id] },
|
||||||
|
modal.componentInstance.remoteOcr
|
||||||
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
|
|||||||
@@ -1122,6 +1122,7 @@ 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,6 +52,7 @@ 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'
|
||||||
@@ -916,7 +917,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
reprocessSelected() {
|
reprocessSelected() {
|
||||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
let modal = this.modalService.open(ReprocessConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.title = $localize`Reprocess confirm`
|
modal.componentInstance.title = $localize`Reprocess confirm`
|
||||||
@@ -930,7 +931,10 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.reprocessDocuments(this.getSelectionQuery())
|
this.documentService.reprocessDocuments(
|
||||||
|
this.getSelectionQuery(),
|
||||||
|
modal.componentInstance.remoteOcr
|
||||||
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ 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',
|
||||||
@@ -65,6 +69,15 @@ 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
|
||||||
@@ -72,6 +85,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +195,43 @@ 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`,
|
||||||
@@ -398,6 +449,10 @@ 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,5 +1,6 @@
|
|||||||
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 {
|
||||||
@@ -94,6 +95,8 @@ 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[] = [
|
||||||
@@ -347,4 +350,14 @@ 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,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ export enum WorkflowActionType {
|
|||||||
Webhook = 4,
|
Webhook = 4,
|
||||||
PasswordRemoval = 5,
|
PasswordRemoval = 5,
|
||||||
MoveToTrash = 6,
|
MoveToTrash = 6,
|
||||||
|
RemoteOcr = 7,
|
||||||
|
ApplyAiSuggestions = 8,
|
||||||
|
}
|
||||||
|
|
||||||
|
// see src/documents/models.py AISuggestionField
|
||||||
|
export enum AISuggestionField {
|
||||||
|
Title = 'title',
|
||||||
|
Tags = 'tags',
|
||||||
|
Correspondent = 'correspondent',
|
||||||
|
DocumentType = 'document_type',
|
||||||
|
StoragePath = 'storage_path',
|
||||||
|
Created = 'created',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowActionEmail extends ObjectWithId {
|
export interface WorkflowActionEmail extends ObjectWithId {
|
||||||
@@ -101,4 +113,10 @@ export interface WorkflowAction extends ObjectWithId {
|
|||||||
webhook?: WorkflowActionWebhook
|
webhook?: WorkflowActionWebhook
|
||||||
|
|
||||||
passwords?: string[]
|
passwords?: string[]
|
||||||
|
|
||||||
|
ai_suggestion_fields?: AISuggestionField[]
|
||||||
|
|
||||||
|
ai_create_missing?: boolean
|
||||||
|
|
||||||
|
ai_overwrite_existing?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,21 @@ 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,9 +349,13 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
reprocessDocuments(selection: DocumentSelectionQuery) {
|
reprocessDocuments(
|
||||||
|
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,6 +13,7 @@ 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'
|
||||||
@@ -434,4 +435,26 @@ 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,6 +19,7 @@ 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,
|
||||||
@@ -687,6 +688,17 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ import {
|
|||||||
journalBookmarkFill,
|
journalBookmarkFill,
|
||||||
journals,
|
journals,
|
||||||
link,
|
link,
|
||||||
|
list,
|
||||||
listNested,
|
listNested,
|
||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
@@ -365,6 +366,7 @@ const icons = {
|
|||||||
journalBookmarkFill,
|
journalBookmarkFill,
|
||||||
journals,
|
journals,
|
||||||
link,
|
link,
|
||||||
|
list,
|
||||||
listNested,
|
listNested,
|
||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
|
|||||||
@@ -536,6 +536,7 @@ ul.pagination {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-underline {
|
.nav-underline {
|
||||||
|
gap: calc(0.5 * var(--bs-nav-underline-gap)) var(--bs-nav-underline-gap);
|
||||||
.nav-link {
|
.nav-link {
|
||||||
&.active, &:hover, &:focus {
|
&.active, &:hover, &:focus {
|
||||||
color: var(--bs-primary);
|
color: var(--bs-primary);
|
||||||
|
|||||||
+41
-66
@@ -305,74 +305,46 @@ def modify_custom_fields(
|
|||||||
else [(field, None) for field in add_custom_fields]
|
else [(field, None) for field in add_custom_fields]
|
||||||
)
|
)
|
||||||
|
|
||||||
custom_fields_by_id: dict[int, CustomField] = {
|
custom_fields = CustomField.objects.filter(
|
||||||
cf.id: cf
|
id__in=[int(field) for field, _ in add_custom_fields],
|
||||||
for cf in CustomField.objects.filter(
|
).distinct()
|
||||||
id__in=[int(field) for field, _ in add_custom_fields],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
# Deferred, not `.only()`: signal receivers touch other Document fields,
|
|
||||||
# and `.only("pk")` would just turn that into a per-document reload.
|
|
||||||
# `content` is the one field both large and unused here. Skipped
|
|
||||||
# entirely for a remove-only call -- the removal pass below resolves
|
|
||||||
# its own documents.
|
|
||||||
docs_by_id: dict[int, Document] = (
|
|
||||||
{
|
|
||||||
doc.id: doc
|
|
||||||
for doc in Document.objects.filter(id__in=affected_docs).defer("content")
|
|
||||||
}
|
|
||||||
if add_custom_fields
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
for field_id, value in add_custom_fields:
|
for field_id, value in add_custom_fields:
|
||||||
custom_field = custom_fields_by_id[field_id]
|
|
||||||
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
|
||||||
custom_field.data_type
|
|
||||||
]
|
|
||||||
for doc_id in affected_docs:
|
for doc_id in affected_docs:
|
||||||
defaults = {value_field: value}
|
defaults = {}
|
||||||
if (
|
custom_field = custom_fields.get(id=field_id)
|
||||||
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
if custom_field:
|
||||||
and value
|
value_field = CustomFieldInstance.TYPE_TO_DATA_STORE_NAME_MAP[
|
||||||
and doc_id in value
|
custom_field.data_type
|
||||||
):
|
]
|
||||||
# Prevent self-linking
|
defaults[value_field] = value
|
||||||
continue
|
if (
|
||||||
# Not update_or_create(): it fetches an existing row via plain
|
custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK
|
||||||
# `.get()` before calling .save(), so a signal receiver touching
|
and value
|
||||||
# `.field`/`.document` (e.g. auditlog) on that save re-fetches
|
and doc_id in value
|
||||||
# per instance regardless of what's passed in as lookup kwargs.
|
):
|
||||||
# Assigning the cached objects ourselves before .save() avoids
|
# Prevent self-linking
|
||||||
# that for both the create and update case.
|
continue
|
||||||
try:
|
CustomFieldInstance.objects.update_or_create(
|
||||||
instance = CustomFieldInstance.objects.get(
|
document_id=doc_id,
|
||||||
document=docs_by_id[doc_id],
|
field_id=field_id,
|
||||||
field=custom_field,
|
defaults=defaults,
|
||||||
)
|
)
|
||||||
except CustomFieldInstance.DoesNotExist:
|
|
||||||
instance = CustomFieldInstance(
|
|
||||||
document=docs_by_id[doc_id],
|
|
||||||
field=custom_field,
|
|
||||||
)
|
|
||||||
instance.document = docs_by_id[doc_id]
|
|
||||||
instance.field = custom_field
|
|
||||||
for attr, val in defaults.items():
|
|
||||||
setattr(instance, attr, val)
|
|
||||||
instance.save()
|
|
||||||
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
if custom_field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
|
||||||
reflect_doclinks(docs_by_id[doc_id], custom_field, value)
|
doc = Document.objects.get(id=doc_id)
|
||||||
|
reflect_doclinks(doc, custom_field, value)
|
||||||
|
|
||||||
# For doc link fields being removed, remove symmetrical links.
|
# For doc link fields that are being removed, remove symmetrical links
|
||||||
# select_related here avoids resolving every affected document up front.
|
|
||||||
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
for doclink_being_removed_instance in CustomFieldInstance.objects.filter(
|
||||||
document_id__in=affected_docs,
|
document_id__in=affected_docs,
|
||||||
field__id__in=remove_custom_fields,
|
field__id__in=remove_custom_fields,
|
||||||
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
field__data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
||||||
value_document_ids__isnull=False,
|
value_document_ids__isnull=False,
|
||||||
).select_related("field", "document"):
|
):
|
||||||
for target_doc_id in doclink_being_removed_instance.value:
|
for target_doc_id in doclink_being_removed_instance.value:
|
||||||
remove_doclink(
|
remove_doclink(
|
||||||
document=doclink_being_removed_instance.document,
|
document=Document.objects.get(
|
||||||
|
id=doclink_being_removed_instance.document.id,
|
||||||
|
),
|
||||||
field=doclink_being_removed_instance.field,
|
field=doclink_being_removed_instance.field,
|
||||||
target_doc_id=target_doc_id,
|
target_doc_id=target_doc_id,
|
||||||
)
|
)
|
||||||
@@ -427,10 +399,16 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
|||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def reprocess(doc_ids: list[int]) -> Literal["OK"]:
|
def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> 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},
|
kwargs={"document_id": document_id, "remote_ocr": remote_ocr},
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
|
headers={"trigger_source": PaperlessTask.TriggerSource.MANUAL},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1199,13 +1177,10 @@ def remove_doclink(
|
|||||||
"""
|
"""
|
||||||
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
|
Removes a 'symmetrical' link to `document` from the target document's existing custom field instance
|
||||||
"""
|
"""
|
||||||
# select_related: a signal receiver (auditlog) touches .document/.field
|
target_doc_field_instance = CustomFieldInstance.objects.filter(
|
||||||
# on save() below -- without this, that's a per-call reload query.
|
document_id=target_doc_id,
|
||||||
target_doc_field_instance = (
|
field=field,
|
||||||
CustomFieldInstance.objects.filter(document_id=target_doc_id, field=field)
|
).first()
|
||||||
.select_related("document", "field")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if (
|
if (
|
||||||
target_doc_field_instance is not None
|
target_doc_field_instance is not None
|
||||||
and document.id in target_doc_field_instance.value
|
and document.id in target_doc_field_instance.value
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import pickle
|
import pickle
|
||||||
|
import time
|
||||||
from binascii import hexlify
|
from binascii import hexlify
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@@ -16,6 +19,7 @@ from django.core.cache import caches
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.core.cache.backends.base import BaseCache
|
from django.core.cache.backends.base import BaseCache
|
||||||
|
|
||||||
from documents.classifier import DocumentClassifier
|
from documents.classifier import DocumentClassifier
|
||||||
@@ -52,6 +56,9 @@ CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
|
|||||||
# [...]} per taxonomy field (#13676)
|
# [...]} per taxonomy field (#13676)
|
||||||
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
|
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
|
||||||
|
|
||||||
|
# How often a request waiting on llm generation re-checks the cache
|
||||||
|
LLM_SUGGESTION_POLL_INTERVAL: Final[float] = 0.5
|
||||||
|
|
||||||
CACHE_1_MINUTE: Final[int] = 60
|
CACHE_1_MINUTE: Final[int] = 60
|
||||||
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
||||||
CACHE_50_MINUTES: Final[int] = 50 * CACHE_1_MINUTE
|
CACHE_50_MINUTES: Final[int] = 50 * CACHE_1_MINUTE
|
||||||
@@ -223,6 +230,68 @@ def get_llm_suggestion_cache(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_llm_suggestions(
|
||||||
|
document: Document,
|
||||||
|
user: User | None,
|
||||||
|
output_language: str | None,
|
||||||
|
*,
|
||||||
|
backend: str,
|
||||||
|
lock_timeout: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Return cached LLM suggestions, generating them once across workers."""
|
||||||
|
# Lazy import to avoid pulling in the whole AI stuff
|
||||||
|
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||||
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
|
|
||||||
|
lock_key = (
|
||||||
|
f"{get_suggestion_cache_key(document.pk)}_llm_lock_"
|
||||||
|
f"{sha256(backend.encode()).hexdigest()}"
|
||||||
|
)
|
||||||
|
waited = False
|
||||||
|
|
||||||
|
while True:
|
||||||
|
cached = get_llm_suggestion_cache(document.pk, backend=backend)
|
||||||
|
if cached is not None:
|
||||||
|
refresh_suggestions_cache(document.pk)
|
||||||
|
return cached.suggestions
|
||||||
|
|
||||||
|
lock_token = uuid4().hex
|
||||||
|
if cache.add(lock_key, lock_token, lock_timeout):
|
||||||
|
if waited:
|
||||||
|
# The generation we were waiting on has ended without caching
|
||||||
|
# anything so it either failed or outlived its lock. Give up
|
||||||
|
# rather than re-running it
|
||||||
|
cache.delete(lock_key)
|
||||||
|
raise LLMTimeoutError
|
||||||
|
|
||||||
|
try:
|
||||||
|
# The cache may have been populated while acquiring the lock.
|
||||||
|
cached = get_llm_suggestion_cache(document.pk, backend=backend)
|
||||||
|
if cached is not None:
|
||||||
|
refresh_suggestions_cache(document.pk)
|
||||||
|
return cached.suggestions
|
||||||
|
|
||||||
|
suggestions = get_ai_document_classification(
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
output_language,
|
||||||
|
)
|
||||||
|
set_llm_suggestions_cache(
|
||||||
|
document.pk,
|
||||||
|
suggestions,
|
||||||
|
backend=backend,
|
||||||
|
)
|
||||||
|
return suggestions
|
||||||
|
finally:
|
||||||
|
# Don't remove lock if this one expired while generation was still running
|
||||||
|
if cache.get(lock_key) == lock_token:
|
||||||
|
cache.delete(lock_key)
|
||||||
|
|
||||||
|
waited = True
|
||||||
|
# Another worker is generating suggestions, poll to avoid another LLM request
|
||||||
|
time.sleep(LLM_SUGGESTION_POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
def set_llm_suggestions_cache(
|
def set_llm_suggestions_cache(
|
||||||
document_id: int,
|
document_id: int,
|
||||||
suggestions: dict,
|
suggestions: dict,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ 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
|
||||||
@@ -451,12 +452,19 @@ 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:
|
||||||
@@ -465,6 +473,16 @@ class ConsumerPlugin(
|
|||||||
f"Unsupported mime type {mime_type}",
|
f"Unsupported mime type {mime_type}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.metadata.remote_ocr and not getattr(
|
||||||
|
parser_class,
|
||||||
|
"uses_remote_service",
|
||||||
|
False,
|
||||||
|
):
|
||||||
|
self.log.warning(
|
||||||
|
"Remote OCR was requested for this document but no remote "
|
||||||
|
"parser is available for it, processing locally instead.",
|
||||||
|
)
|
||||||
|
|
||||||
# Notify all listeners that we're going to do some work.
|
# Notify all listeners that we're going to do some work.
|
||||||
|
|
||||||
document_consumption_started.send(
|
document_consumption_started.send(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ 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":
|
||||||
"""
|
"""
|
||||||
@@ -57,6 +58,8 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-08-10 17:27
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "0023_savedview_icon"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="type",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
choices=[
|
||||||
|
(1, "Assignment"),
|
||||||
|
(2, "Removal"),
|
||||||
|
(3, "Email"),
|
||||||
|
(4, "Webhook"),
|
||||||
|
(5, "Password removal"),
|
||||||
|
(6, "Move to trash"),
|
||||||
|
(7, "Remote OCR"),
|
||||||
|
],
|
||||||
|
default=1,
|
||||||
|
verbose_name="Workflow Action Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-08-10 18:26
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "0024_alter_workflowaction_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="ai_create_missing",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Create suggested tags, correspondents, document types and storage paths that do not already exist instead of skipping them.",
|
||||||
|
verbose_name="create missing objects",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="ai_overwrite_existing",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Apply suggestions even if the document already has a value for that field. Tags are always added to, never replaced.",
|
||||||
|
verbose_name="overwrite existing values",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="ai_suggestion_fields",
|
||||||
|
field=models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Which of the AI-suggested fields to apply to the document.",
|
||||||
|
null=True,
|
||||||
|
verbose_name="AI suggestion fields",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="type",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
choices=[
|
||||||
|
(1, "Assignment"),
|
||||||
|
(2, "Removal"),
|
||||||
|
(3, "Email"),
|
||||||
|
(4, "Webhook"),
|
||||||
|
(5, "Password removal"),
|
||||||
|
(6, "Move to trash"),
|
||||||
|
(7, "Remote OCR"),
|
||||||
|
(8, "Apply AI suggestions"),
|
||||||
|
],
|
||||||
|
default=1,
|
||||||
|
verbose_name="Workflow Action Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="paperlesstask",
|
||||||
|
name="task_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("consume_file", "Consume File"),
|
||||||
|
("train_classifier", "Train Classifier"),
|
||||||
|
("sanity_check", "Sanity Check"),
|
||||||
|
("index_optimize", "Index Optimize"),
|
||||||
|
("mail_fetch", "Mail Fetch"),
|
||||||
|
("llm_index", "LLM Index"),
|
||||||
|
("empty_trash", "Empty Trash"),
|
||||||
|
("check_workflows", "Check Workflows"),
|
||||||
|
("bulk_update", "Bulk Update"),
|
||||||
|
("reprocess_document", "Reprocess Document"),
|
||||||
|
("build_share_link", "Build Share Link"),
|
||||||
|
("bulk_delete", "Bulk Delete"),
|
||||||
|
("apply_ai_suggestions", "Apply AI Suggestions"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
help_text="The kind of work being performed",
|
||||||
|
max_length=50,
|
||||||
|
verbose_name="Task Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -766,6 +766,7 @@ class PaperlessTask(ModelWithOwner):
|
|||||||
REPROCESS_DOCUMENT = "reprocess_document", _("Reprocess Document")
|
REPROCESS_DOCUMENT = "reprocess_document", _("Reprocess Document")
|
||||||
BUILD_SHARE_LINK = "build_share_link", _("Build Share Link")
|
BUILD_SHARE_LINK = "build_share_link", _("Build Share Link")
|
||||||
BULK_DELETE = "bulk_delete", _("Bulk Delete")
|
BULK_DELETE = "bulk_delete", _("Bulk Delete")
|
||||||
|
APPLY_AI_SUGGESTIONS = "apply_ai_suggestions", _("Apply AI Suggestions")
|
||||||
|
|
||||||
COMPLETE_STATUSES = (
|
COMPLETE_STATUSES = (
|
||||||
Status.SUCCESS,
|
Status.SUCCESS,
|
||||||
@@ -1670,6 +1671,22 @@ class WorkflowAction(models.Model):
|
|||||||
6,
|
6,
|
||||||
_("Move to trash"),
|
_("Move to trash"),
|
||||||
)
|
)
|
||||||
|
REMOTE_OCR = (
|
||||||
|
7,
|
||||||
|
_("Remote OCR"),
|
||||||
|
)
|
||||||
|
APPLY_AI_SUGGESTIONS = (
|
||||||
|
8,
|
||||||
|
_("Apply AI suggestions"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class AISuggestionField(models.TextChoices):
|
||||||
|
TITLE = ("title", _("Title"))
|
||||||
|
TAGS = ("tags", _("Tags"))
|
||||||
|
CORRESPONDENT = ("correspondent", _("Correspondent"))
|
||||||
|
DOCUMENT_TYPE = ("document_type", _("Document type"))
|
||||||
|
STORAGE_PATH = ("storage_path", _("Storage path"))
|
||||||
|
CREATED = ("created", _("Created date"))
|
||||||
|
|
||||||
type = models.PositiveSmallIntegerField(
|
type = models.PositiveSmallIntegerField(
|
||||||
_("Workflow Action Type"),
|
_("Workflow Action Type"),
|
||||||
@@ -1908,6 +1925,33 @@ class WorkflowAction(models.Model):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ai_suggestion_fields = models.JSONField(
|
||||||
|
_("AI suggestion fields"),
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text=_(
|
||||||
|
"Which of the AI-suggested fields to apply to the document.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_create_missing = models.BooleanField(
|
||||||
|
_("create missing objects"),
|
||||||
|
default=False,
|
||||||
|
help_text=_(
|
||||||
|
"Create suggested tags, correspondents, document types and storage "
|
||||||
|
"paths that do not already exist instead of skipping them.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_overwrite_existing = models.BooleanField(
|
||||||
|
_("overwrite existing values"),
|
||||||
|
default=False,
|
||||||
|
help_text=_(
|
||||||
|
"Apply suggestions even if the document already has a value for that "
|
||||||
|
"field. Tags are always added to, never replaced.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("workflow action")
|
verbose_name = _("workflow action")
|
||||||
verbose_name_plural = _("workflow actions")
|
verbose_name_plural = _("workflow actions")
|
||||||
|
|||||||
@@ -1795,7 +1795,7 @@ class DeleteDocumentsSerializer(DocumentSelectionSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
|
||||||
pass
|
remote_ocr = serializers.BooleanField(required=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
class BulkEditSerializer(
|
class BulkEditSerializer(
|
||||||
@@ -2137,6 +2137,13 @@ 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")
|
||||||
@@ -2201,6 +2208,8 @@ 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
|
||||||
|
|
||||||
@@ -3226,6 +3235,9 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
|
|||||||
"email",
|
"email",
|
||||||
"webhook",
|
"webhook",
|
||||||
"passwords",
|
"passwords",
|
||||||
|
"ai_suggestion_fields",
|
||||||
|
"ai_create_missing",
|
||||||
|
"ai_overwrite_existing",
|
||||||
]
|
]
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
@@ -3283,6 +3295,23 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
|
|||||||
"Passwords are required for password removal actions",
|
"Passwords are required for password removal actions",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"type" in attrs
|
||||||
|
and attrs["type"] == WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
|
||||||
|
):
|
||||||
|
fields = attrs.get("ai_suggestion_fields")
|
||||||
|
valid_fields = set(WorkflowAction.AISuggestionField.values)
|
||||||
|
if (
|
||||||
|
fields is None
|
||||||
|
or not isinstance(fields, list)
|
||||||
|
or len(fields) == 0
|
||||||
|
or any(field not in valid_fields for field in fields)
|
||||||
|
):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"At least one valid field is required for apply AI "
|
||||||
|
f"suggestions actions, options are: {sorted(valid_fields)}",
|
||||||
|
)
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
@@ -3303,6 +3332,68 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
|||||||
"actions",
|
"actions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
attrs = super().validate(attrs)
|
||||||
|
|
||||||
|
if "actions" in attrs:
|
||||||
|
has_remote_ocr_action = any(
|
||||||
|
action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR
|
||||||
|
for action in attrs["actions"]
|
||||||
|
)
|
||||||
|
has_ai_suggestions_action = any(
|
||||||
|
action.get("type")
|
||||||
|
== WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
|
||||||
|
for action in attrs["actions"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
has_remote_ocr_action = self.instance is not None and (
|
||||||
|
self.instance.actions.filter(
|
||||||
|
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
has_ai_suggestions_action = self.instance is not None and (
|
||||||
|
self.instance.actions.filter(
|
||||||
|
type=WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
if "triggers" in attrs:
|
||||||
|
has_consumption_trigger = any(
|
||||||
|
trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||||
|
for trigger in attrs["triggers"]
|
||||||
|
)
|
||||||
|
has_non_consumption_trigger = any(
|
||||||
|
trigger.get("type") != WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||||
|
for trigger in attrs["triggers"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
has_consumption_trigger = self.instance is not None and (
|
||||||
|
self.instance.triggers.filter(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
has_non_consumption_trigger = self.instance is not None and (
|
||||||
|
self.instance.triggers.exclude(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remote OCR can only work with consumption triggers
|
||||||
|
if has_remote_ocr_action and not has_consumption_trigger:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Remote OCR actions require a consumption started trigger",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Suggestions are made from the document content, which does not exist
|
||||||
|
# until after consumption has finished
|
||||||
|
if has_ai_suggestions_action and not has_non_consumption_trigger:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Apply AI suggestions actions require a trigger other than "
|
||||||
|
"consumption started",
|
||||||
|
)
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
def update_triggers_and_actions(
|
def update_triggers_and_actions(
|
||||||
self,
|
self,
|
||||||
instance: Workflow,
|
instance: Workflow,
|
||||||
|
|||||||
@@ -973,6 +973,39 @@ def run_workflows(
|
|||||||
)
|
)
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
|
elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
|
||||||
has_move_to_trash_action = True
|
has_move_to_trash_action = True
|
||||||
|
elif action.type == WorkflowAction.WorkflowActionType.REMOTE_OCR:
|
||||||
|
if use_overrides and overrides:
|
||||||
|
overrides.remote_ocr = True
|
||||||
|
else:
|
||||||
|
# If a workflow has a consumption trigger *and* another type,
|
||||||
|
# the document has already been parsed by the time the other one fires
|
||||||
|
logger.debug(
|
||||||
|
"Remote OCR action only applies to consumption "
|
||||||
|
"triggers, ignoring",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
action.type
|
||||||
|
== WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
|
||||||
|
):
|
||||||
|
if use_overrides:
|
||||||
|
# The document has not been parsed yet, so there is no
|
||||||
|
# content for the LLM to make suggestions from
|
||||||
|
logger.debug(
|
||||||
|
"Apply AI suggestions action does not apply to "
|
||||||
|
"consumption triggers, ignoring",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Queued rather than run sync
|
||||||
|
from documents.tasks import apply_ai_suggestions
|
||||||
|
|
||||||
|
# kwargs so the PaperlessTask record can note the
|
||||||
|
# document, see _extract_input_data
|
||||||
|
apply_ai_suggestions.delay(
|
||||||
|
action_id=action.pk,
|
||||||
|
document_id=document.pk,
|
||||||
|
)
|
||||||
|
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
# limit title to 128 characters
|
# limit title to 128 characters
|
||||||
@@ -1028,6 +1061,7 @@ TRACKED_TASKS: dict[str, PaperlessTask.TaskType] = {
|
|||||||
"documents.tasks.update_document_content_maybe_archive_file": PaperlessTask.TaskType.REPROCESS_DOCUMENT,
|
"documents.tasks.update_document_content_maybe_archive_file": PaperlessTask.TaskType.REPROCESS_DOCUMENT,
|
||||||
"documents.tasks.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK,
|
"documents.tasks.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK,
|
||||||
"documents.bulk_edit.delete": PaperlessTask.TaskType.BULK_DELETE,
|
"documents.bulk_edit.delete": PaperlessTask.TaskType.BULK_DELETE,
|
||||||
|
"documents.tasks.apply_ai_suggestions": PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS,
|
||||||
}
|
}
|
||||||
|
|
||||||
_CELERY_STATE_TO_STATUS: dict[str, PaperlessTask.Status] = {
|
_CELERY_STATE_TO_STATUS: dict[str, PaperlessTask.Status] = {
|
||||||
@@ -1081,6 +1115,12 @@ def _extract_input_data(
|
|||||||
return {"account_ids": account_ids}
|
return {"account_ids": account_ids}
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
if task_type == PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS:
|
||||||
|
document_id = task_kwargs.get("document_id")
|
||||||
|
if document_id is not None:
|
||||||
|
return {"document_id": document_id}
|
||||||
|
return {}
|
||||||
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+50
-1
@@ -67,9 +67,11 @@ from documents.utils import identity
|
|||||||
from documents.versioning import annotate_effective_content
|
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
|
||||||
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
from paperless_ai.indexing import llm_index_add_or_update_document
|
from paperless_ai.indexing import llm_index_add_or_update_document
|
||||||
from paperless_ai.indexing import llm_index_remove_document
|
from paperless_ai.indexing import llm_index_remove_document
|
||||||
from paperless_ai.indexing import update_llm_index
|
from paperless_ai.indexing import update_llm_index
|
||||||
@@ -338,10 +340,17 @@ def bulk_update_documents(document_ids) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def update_document_content_maybe_archive_file(document_id) -> None:
|
def update_document_content_maybe_archive_file(
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -351,6 +360,7 @@ def update_document_content_maybe_archive_file(document_id) -> None:
|
|||||||
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:
|
||||||
@@ -705,6 +715,45 @@ def llmindex_index(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(LLMTimeoutError,),
|
||||||
|
max_retries=3,
|
||||||
|
retry_backoff=60,
|
||||||
|
retry_backoff_max=600,
|
||||||
|
retry_jitter=True,
|
||||||
|
)
|
||||||
|
def apply_ai_suggestions(self, action_id: int, document_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Deferred "apply AI suggestions" workflow action.
|
||||||
|
"""
|
||||||
|
from documents.models import WorkflowAction
|
||||||
|
from documents.workflows.ai import apply_ai_suggestions_to_document
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = WorkflowAction.objects.get(pk=action_id)
|
||||||
|
document = Document.objects.select_related("owner").get(pk=document_id)
|
||||||
|
except (WorkflowAction.DoesNotExist, Document.DoesNotExist):
|
||||||
|
logger.warning(
|
||||||
|
"Workflow action %s or document %s no longer exists, "
|
||||||
|
"not applying AI suggestions",
|
||||||
|
action_id,
|
||||||
|
document_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not apply_ai_suggestions_to_document(action, document):
|
||||||
|
return
|
||||||
|
|
||||||
|
# No document_updated signal to avoid loop
|
||||||
|
clear_document_caches(document.pk)
|
||||||
|
index_document.delay(document.pk)
|
||||||
|
|
||||||
|
ai_config = AIConfig()
|
||||||
|
if ai_config.llm_index_enabled:
|
||||||
|
update_document_in_llm_index.apply_async(kwargs={"document": document})
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def update_document_in_llm_index(document) -> None:
|
def update_document_in_llm_index(document) -> None:
|
||||||
llm_index_add_or_update_document(document)
|
llm_index_add_or_update_document(document)
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ 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,
|
||||||
@@ -870,6 +874,49 @@ 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,7 +532,29 @@ 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(len(kwargs), 0)
|
self.assertEqual(kwargs, {"remote_ocr": False})
|
||||||
|
|
||||||
|
@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:
|
||||||
@@ -1553,6 +1575,29 @@ 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")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from django.contrib.auth.models import User
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import FieldError
|
from django.core.exceptions import FieldError
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from django.test import TestCase as DjangoTestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -21,6 +22,7 @@ from documents.filters import TitleContentFilter
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import read_streaming_response
|
from documents.tests.utils import read_streaming_response
|
||||||
|
from documents.views import DocumentSelectionMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -923,3 +925,36 @@ class TestVersionAwareFilters(TestCase):
|
|||||||
|
|
||||||
self.assertIs(result, queryset)
|
self.assertIs(result, queryset)
|
||||||
queryset.filter.assert_not_called()
|
queryset.filter.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulkSelectionExcludesVersions(DjangoTestCase):
|
||||||
|
def test_select_all_matching_does_not_select_version_documents(self) -> None:
|
||||||
|
"""
|
||||||
|
"Select all matching" reconstructs the document list, which never
|
||||||
|
contains version documents as rows of their own.
|
||||||
|
"""
|
||||||
|
user = User.objects.create_superuser(username="bulk_versions")
|
||||||
|
root = Document.objects.create(
|
||||||
|
title="shared-title root",
|
||||||
|
checksum="bulk-root",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
content="root",
|
||||||
|
)
|
||||||
|
Document.objects.create(
|
||||||
|
title="shared-title version",
|
||||||
|
checksum="bulk-version",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
content="version",
|
||||||
|
)
|
||||||
|
|
||||||
|
selected = DocumentSelectionMixin()._resolve_document_ids(
|
||||||
|
user=user,
|
||||||
|
validated_data={
|
||||||
|
"all": True,
|
||||||
|
"filters": {"title__icontains": "shared-title"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(selected, [root.id])
|
||||||
|
|||||||
@@ -2486,7 +2486,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
response = self.client.get("/api/documents/34676/suggestions/")
|
response = self.client.get("/api/documents/34676/suggestions/")
|
||||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
@mock.patch("documents.views.get_ai_document_classification")
|
@mock.patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(AI_ENABLED=True)
|
@override_settings(AI_ENABLED=True)
|
||||||
def test_suggestions_still_uses_classifier_when_ai_enabled(
|
def test_suggestions_still_uses_classifier_when_ai_enabled(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
|
|||||||
},
|
},
|
||||||
"email_enabled": False,
|
"email_enabled": False,
|
||||||
"ai_enabled": False,
|
"ai_enabled": False,
|
||||||
|
"remote_ocr": {
|
||||||
|
"configured": False,
|
||||||
|
"mode": "always",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -154,6 +158,50 @@ 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",
|
||||||
|
|||||||
@@ -506,6 +506,280 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(Workflow.objects.count(), 1)
|
self.assertEqual(Workflow.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_api_create_remote_ocr_action_requires_consumption_trigger(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create a workflow with a remote OCR action
|
||||||
|
- No consumption started trigger, so the action could never run
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- Correct HTTP 400 response
|
||||||
|
- No objects are created
|
||||||
|
"""
|
||||||
|
existing_count = Workflow.objects.count()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Remote OCR too late",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(Workflow.objects.count(), existing_count)
|
||||||
|
|
||||||
|
def test_api_create_remote_ocr_action_with_consumption_trigger(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create a workflow with a remote OCR action
|
||||||
|
- A consumption started trigger alongside another trigger type
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- The workflow is created, the action applies to consumption only
|
||||||
|
"""
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Remote OCR on consume",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
"filter_filename": "*.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def test_api_partial_update_adds_remote_ocr_action(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An existing workflow with a consumption started trigger
|
||||||
|
WHEN:
|
||||||
|
- A partial update adds a remote OCR action without resubmitting triggers
|
||||||
|
THEN:
|
||||||
|
- The existing trigger is considered and the update succeeds
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{self.workflow.id}/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
self.workflow.actions.get().type,
|
||||||
|
WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_partial_update_cannot_remove_remote_ocr_trigger(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An existing workflow with a remote OCR action
|
||||||
|
- An existing consumption started trigger
|
||||||
|
WHEN:
|
||||||
|
- A partial update replaces the trigger without resubmitting actions
|
||||||
|
THEN:
|
||||||
|
- The existing action is considered and the update is rejected
|
||||||
|
"""
|
||||||
|
self.action.type = WorkflowAction.WorkflowActionType.REMOTE_OCR
|
||||||
|
self.action.save()
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{self.workflow.id}/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(self.workflow.triggers.get(), self.trigger)
|
||||||
|
|
||||||
|
def _post_ai_suggestions_workflow(self, *, trigger_types, action: dict):
|
||||||
|
def trigger(trigger_type):
|
||||||
|
# consumption triggers require a filter of their own
|
||||||
|
if trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION:
|
||||||
|
return {"type": trigger_type, "filter_filename": "*.pdf"}
|
||||||
|
return {"type": trigger_type}
|
||||||
|
|
||||||
|
return self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Apply AI suggestions",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [trigger(t) for t in trigger_types],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
|
||||||
|
**action,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_create_apply_ai_suggestions_action(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create a workflow with an apply AI suggestions
|
||||||
|
action and a valid set of fields
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- The workflow is created with the chosen options
|
||||||
|
"""
|
||||||
|
response = self._post_ai_suggestions_workflow(
|
||||||
|
trigger_types=[WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED],
|
||||||
|
action={
|
||||||
|
"ai_suggestion_fields": ["title", "tags", "correspondent"],
|
||||||
|
"ai_create_missing": True,
|
||||||
|
"ai_overwrite_existing": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
action = Workflow.objects.get(name="Apply AI suggestions").actions.first()
|
||||||
|
self.assertEqual(
|
||||||
|
action.ai_suggestion_fields,
|
||||||
|
["title", "tags", "correspondent"],
|
||||||
|
)
|
||||||
|
self.assertTrue(action.ai_create_missing)
|
||||||
|
self.assertTrue(action.ai_overwrite_existing)
|
||||||
|
|
||||||
|
def test_api_create_apply_ai_suggestions_action_requires_fields(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create an apply AI suggestions action with no
|
||||||
|
fields selected, which could never do anything
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- Correct HTTP 400 response
|
||||||
|
- No objects are created
|
||||||
|
"""
|
||||||
|
existing_count = Workflow.objects.count()
|
||||||
|
|
||||||
|
response = self._post_ai_suggestions_workflow(
|
||||||
|
trigger_types=[WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED],
|
||||||
|
action={"ai_suggestion_fields": []},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(Workflow.objects.count(), existing_count)
|
||||||
|
|
||||||
|
def test_api_create_apply_ai_suggestions_action_rejects_unknown_field(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create an apply AI suggestions action naming a
|
||||||
|
field that does not exist
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- Correct HTTP 400 response
|
||||||
|
"""
|
||||||
|
response = self._post_ai_suggestions_workflow(
|
||||||
|
trigger_types=[WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED],
|
||||||
|
action={"ai_suggestion_fields": ["title", "not_a_field"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_api_create_apply_ai_suggestions_action_rejects_consumption_only(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create an apply AI suggestions action whose only
|
||||||
|
trigger is consumption started, so there is no document content
|
||||||
|
to make suggestions from yet
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- Correct HTTP 400 response
|
||||||
|
- No objects are created
|
||||||
|
"""
|
||||||
|
existing_count = Workflow.objects.count()
|
||||||
|
|
||||||
|
response = self._post_ai_suggestions_workflow(
|
||||||
|
trigger_types=[WorkflowTrigger.WorkflowTriggerType.CONSUMPTION],
|
||||||
|
action={"ai_suggestion_fields": ["title"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(Workflow.objects.count(), existing_count)
|
||||||
|
|
||||||
|
def test_api_create_apply_ai_suggestions_action_allows_extra_consumption_trigger(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create an apply AI suggestions action with a
|
||||||
|
consumption trigger alongside a usable one
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- The workflow is created, the action applies to the other trigger
|
||||||
|
"""
|
||||||
|
response = self._post_ai_suggestions_workflow(
|
||||||
|
trigger_types=[
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
],
|
||||||
|
action={"ai_suggestion_fields": ["title"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def test_api_create_workflow_trigger_action_empty_fields(self) -> None:
|
def test_api_create_workflow_trigger_action_empty_fields(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ from unittest import mock
|
|||||||
import pikepdf
|
import pikepdf
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import connection
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from guardian.shortcuts import get_groups_with_perms
|
from guardian.shortcuts import get_groups_with_perms
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
@@ -346,202 +344,6 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
|
|||||||
assert _cf_3 is not None
|
assert _cf_3 is not None
|
||||||
self.assertNotIn(self.doc3.id, _cf_3.value)
|
self.assertNotIn(self.doc3.id, _cf_3.value)
|
||||||
|
|
||||||
def test_modify_custom_fields_batches_field_lookup(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Several documents are being bulk-edited to add several custom
|
|
||||||
fields at once
|
|
||||||
WHEN:
|
|
||||||
- modify_custom_fields runs
|
|
||||||
THEN:
|
|
||||||
- Each CustomField is resolved with one batched query total, not
|
|
||||||
once per (field, document) pair
|
|
||||||
"""
|
|
||||||
docs = [
|
|
||||||
Document.objects.create(checksum=f"batch-{i}", title=f"batch-{i}")
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
fields = [
|
|
||||||
CustomField.objects.create(
|
|
||||||
name=f"Batch Field {i}",
|
|
||||||
data_type=CustomField.FieldDataType.STRING,
|
|
||||||
)
|
|
||||||
for i in range(4)
|
|
||||||
]
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
bulk_edit.modify_custom_fields(
|
|
||||||
[doc.id for doc in docs],
|
|
||||||
add_custom_fields=[field.id for field in fields],
|
|
||||||
remove_custom_fields=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
field_lookups = [
|
|
||||||
q
|
|
||||||
for q in ctx.captured_queries
|
|
||||||
if 'FROM "documents_customfield"' in q["sql"]
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
len(field_lookups),
|
|
||||||
1,
|
|
||||||
"Expected a single batched query to resolve the custom fields, "
|
|
||||||
f"got {len(field_lookups)}: {field_lookups}",
|
|
||||||
)
|
|
||||||
|
|
||||||
for doc in docs:
|
|
||||||
self.assertEqual(doc.custom_fields.count(), len(fields))
|
|
||||||
|
|
||||||
def test_modify_custom_fields_batches_document_lookup_for_documentlink(
|
|
||||||
self,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Several documents are being bulk-edited to add a DOCUMENTLINK
|
|
||||||
custom field at once
|
|
||||||
WHEN:
|
|
||||||
- modify_custom_fields runs
|
|
||||||
THEN:
|
|
||||||
- The Document rows needed to reflect the symmetrical links are
|
|
||||||
resolved with one batched query total, not once per document
|
|
||||||
"""
|
|
||||||
docs = [
|
|
||||||
Document.objects.create(checksum=f"link-{i}", title=f"link-{i}")
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
target = Document.objects.create(checksum="link-target", title="link-target")
|
|
||||||
doclink_field = CustomField.objects.create(
|
|
||||||
name="Related",
|
|
||||||
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
|
||||||
)
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
bulk_edit.modify_custom_fields(
|
|
||||||
[doc.id for doc in docs],
|
|
||||||
add_custom_fields={doclink_field.id: [target.id]},
|
|
||||||
remove_custom_fields=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
single_document_lookups = [
|
|
||||||
q
|
|
||||||
for q in ctx.captured_queries
|
|
||||||
if 'FROM "documents_document"' in q["sql"]
|
|
||||||
and '"documents_document"."id" = ' in q["sql"]
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
len(single_document_lookups),
|
|
||||||
0,
|
|
||||||
"Expected document rows to come from a batched query, not "
|
|
||||||
f"per-document lookups, got: {single_document_lookups}",
|
|
||||||
)
|
|
||||||
|
|
||||||
for doc in docs:
|
|
||||||
self.assertEqual(
|
|
||||||
doc.custom_fields.get(field=doclink_field).value,
|
|
||||||
[target.id],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_modify_custom_fields_update_caches_document_and_field(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Several documents already have an instance of a custom field
|
|
||||||
WHEN:
|
|
||||||
- modify_custom_fields runs again for the same field, updating
|
|
||||||
the existing instances rather than creating new ones
|
|
||||||
THEN:
|
|
||||||
- No per-instance `.document`/`.field` reload query is issued
|
|
||||||
(e.g. by auditlog's post_save receiver touching them)
|
|
||||||
"""
|
|
||||||
docs = [
|
|
||||||
Document.objects.create(checksum=f"update-{i}", title=f"update-{i}")
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
field = CustomField.objects.create(
|
|
||||||
name="Update Field",
|
|
||||||
data_type=CustomField.FieldDataType.STRING,
|
|
||||||
)
|
|
||||||
bulk_edit.modify_custom_fields(
|
|
||||||
[doc.id for doc in docs],
|
|
||||||
add_custom_fields=[field.id],
|
|
||||||
remove_custom_fields=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
bulk_edit.modify_custom_fields(
|
|
||||||
[doc.id for doc in docs],
|
|
||||||
add_custom_fields={field.id: "updated value"},
|
|
||||||
remove_custom_fields=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
single_row_reloads = [
|
|
||||||
q
|
|
||||||
for q in ctx.captured_queries
|
|
||||||
if ('FROM "documents_document"' in q["sql"] and '."id" = ' in q["sql"])
|
|
||||||
or ('FROM "documents_customfield"' in q["sql"] and '."id" = ' in q["sql"])
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
single_row_reloads,
|
|
||||||
[],
|
|
||||||
"Expected no per-instance document/field reload queries when "
|
|
||||||
f"updating existing custom field instances, got: {single_row_reloads}",
|
|
||||||
)
|
|
||||||
|
|
||||||
for doc in docs:
|
|
||||||
self.assertEqual(
|
|
||||||
doc.custom_fields.get(field=field).value,
|
|
||||||
"updated value",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_modify_custom_fields_removes_symmetrical_doclinks_batched(self) -> None:
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- Several source documents link to a shared target via a doc
|
|
||||||
link field
|
|
||||||
WHEN:
|
|
||||||
- The field is removed from all of them in one call
|
|
||||||
THEN:
|
|
||||||
- The symmetrical links are removed from the target
|
|
||||||
- No per-document lookup query is issued, on either side
|
|
||||||
"""
|
|
||||||
target = Document.objects.create(checksum="rm-target", title="rm-target")
|
|
||||||
docs = [
|
|
||||||
Document.objects.create(checksum=f"rm-{i}", title=f"rm-{i}")
|
|
||||||
for i in range(6)
|
|
||||||
]
|
|
||||||
field = CustomField.objects.create(
|
|
||||||
name="Related",
|
|
||||||
data_type=CustomField.FieldDataType.DOCUMENTLINK,
|
|
||||||
)
|
|
||||||
bulk_edit.modify_custom_fields(
|
|
||||||
[doc.id for doc in docs],
|
|
||||||
add_custom_fields={field.id: [target.id]},
|
|
||||||
remove_custom_fields=[],
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
target.custom_fields.get(field=field).value,
|
|
||||||
[d.id for d in docs],
|
|
||||||
)
|
|
||||||
|
|
||||||
with CaptureQueriesContext(connection) as ctx:
|
|
||||||
bulk_edit.modify_custom_fields(
|
|
||||||
[doc.id for doc in docs],
|
|
||||||
add_custom_fields=[],
|
|
||||||
remove_custom_fields=[field.id],
|
|
||||||
)
|
|
||||||
|
|
||||||
single_document_lookups = [
|
|
||||||
q
|
|
||||||
for q in ctx.captured_queries
|
|
||||||
if 'FROM "documents_document"' in q["sql"]
|
|
||||||
and '"documents_document"."id" = ' in q["sql"]
|
|
||||||
]
|
|
||||||
self.assertEqual(
|
|
||||||
single_document_lookups,
|
|
||||||
[],
|
|
||||||
"Expected batched document resolution, not per-document lookups, "
|
|
||||||
f"got: {single_document_lookups}",
|
|
||||||
)
|
|
||||||
self.assertEqual(target.custom_fields.get(field=field).value, [])
|
|
||||||
|
|
||||||
def test_modify_custom_fields_doclink_self_link(self) -> None:
|
def test_modify_custom_fields_doclink_self_link(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
@@ -1980,3 +1782,56 @@ 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"])
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import pickle
|
import pickle
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from threading import Event
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from documents.caching import StoredLRUCache
|
from documents.caching import StoredLRUCache
|
||||||
|
from documents.caching import retrieve_llm_suggestions
|
||||||
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
|
|
||||||
|
|
||||||
def test_lru_cache_entries() -> None:
|
def test_lru_cache_entries() -> None:
|
||||||
@@ -43,3 +50,119 @@ def test_stored_lru_cache_key_ttl(mocker) -> None:
|
|||||||
assert key == "test_key"
|
assert key == "test_key"
|
||||||
assert timeout == 321
|
assert timeout == 321
|
||||||
assert pickle.loads(data) == {"x": "X", "y": "Y"}
|
assert pickle.loads(data) == {"x": "X", "y": "Y"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_suggestions_are_generated_once_for_concurrent_requests(mocker) -> None:
|
||||||
|
generation_started = Event()
|
||||||
|
finish_generation = Event()
|
||||||
|
waiter_started = Event()
|
||||||
|
call_lock = Lock()
|
||||||
|
calls = 0
|
||||||
|
suggestions = {"title": "Generated once"}
|
||||||
|
document = mocker.Mock(pk=42)
|
||||||
|
user = mocker.Mock()
|
||||||
|
|
||||||
|
def generate(*args) -> dict:
|
||||||
|
nonlocal calls
|
||||||
|
with call_lock:
|
||||||
|
calls += 1
|
||||||
|
generation_started.set()
|
||||||
|
assert finish_generation.wait(timeout=2)
|
||||||
|
return suggestions
|
||||||
|
|
||||||
|
def wait_for_generation(_interval: float) -> None:
|
||||||
|
waiter_started.set()
|
||||||
|
assert finish_generation.wait(timeout=2)
|
||||||
|
|
||||||
|
mock_get_classification = mocker.patch(
|
||||||
|
"paperless_ai.ai_classifier.get_ai_document_classification",
|
||||||
|
side_effect=generate,
|
||||||
|
)
|
||||||
|
mocker.patch("documents.caching.time.sleep", side_effect=wait_for_generation)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
first = executor.submit(
|
||||||
|
retrieve_llm_suggestions,
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
None,
|
||||||
|
backend="ollama:model",
|
||||||
|
lock_timeout=10,
|
||||||
|
)
|
||||||
|
assert generation_started.wait(timeout=2)
|
||||||
|
second = executor.submit(
|
||||||
|
retrieve_llm_suggestions,
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
None,
|
||||||
|
backend="ollama:model",
|
||||||
|
lock_timeout=10,
|
||||||
|
)
|
||||||
|
assert waiter_started.wait(timeout=2)
|
||||||
|
finish_generation.set()
|
||||||
|
|
||||||
|
assert first.result(timeout=2) == suggestions
|
||||||
|
assert second.result(timeout=2) == suggestions
|
||||||
|
|
||||||
|
assert calls == 1
|
||||||
|
mock_get_classification.assert_called_once_with(document, user, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_suggestions_waiter_does_not_rerun_a_failed_generation(mocker) -> None:
|
||||||
|
"""
|
||||||
|
A request queued behind a generation that fails should give up, not take
|
||||||
|
its turn at re-running a query that just failed.
|
||||||
|
"""
|
||||||
|
generation_started = Event()
|
||||||
|
fail_generation = Event()
|
||||||
|
waiter_started = Event()
|
||||||
|
call_lock = Lock()
|
||||||
|
calls = 0
|
||||||
|
document = mocker.Mock(pk=43)
|
||||||
|
user = mocker.Mock()
|
||||||
|
|
||||||
|
def generate(*args) -> dict:
|
||||||
|
nonlocal calls
|
||||||
|
with call_lock:
|
||||||
|
calls += 1
|
||||||
|
generation_started.set()
|
||||||
|
assert fail_generation.wait(timeout=2)
|
||||||
|
raise ValueError("Unknown model")
|
||||||
|
|
||||||
|
def wait_for_generation(_interval: float) -> None:
|
||||||
|
waiter_started.set()
|
||||||
|
assert fail_generation.wait(timeout=2)
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"paperless_ai.ai_classifier.get_ai_document_classification",
|
||||||
|
side_effect=generate,
|
||||||
|
)
|
||||||
|
mocker.patch("documents.caching.time.sleep", side_effect=wait_for_generation)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
first = executor.submit(
|
||||||
|
retrieve_llm_suggestions,
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
None,
|
||||||
|
backend="ollama:model",
|
||||||
|
lock_timeout=10,
|
||||||
|
)
|
||||||
|
assert generation_started.wait(timeout=2)
|
||||||
|
second = executor.submit(
|
||||||
|
retrieve_llm_suggestions,
|
||||||
|
document,
|
||||||
|
user,
|
||||||
|
None,
|
||||||
|
backend="ollama:model",
|
||||||
|
lock_timeout=10,
|
||||||
|
)
|
||||||
|
assert waiter_started.wait(timeout=2)
|
||||||
|
fail_generation.set()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Unknown model"):
|
||||||
|
first.result(timeout=2)
|
||||||
|
with pytest.raises(LLMTimeoutError):
|
||||||
|
second.result(timeout=2)
|
||||||
|
|
||||||
|
assert calls == 1
|
||||||
|
|||||||
@@ -1559,6 +1559,72 @@ 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()
|
||||||
@@ -1566,6 +1632,20 @@ 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,
|
||||||
|
|||||||
@@ -385,6 +385,25 @@ class TestTaskFailureHandler:
|
|||||||
task_failure_handler(task_id=None, exception=ValueError("x"), traceback=None)
|
task_failure_handler(task_id=None, exception=ValueError("x"), traceback=None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestApplyAiSuggestionsTracking:
|
||||||
|
def test_records_the_document_it_is_for(self) -> None:
|
||||||
|
"""
|
||||||
|
The action queues one task per document, so the tracked record notes
|
||||||
|
which document it is for -- otherwise a bulk run is an indistinguishable
|
||||||
|
wall of identical entries in the tasks list.
|
||||||
|
"""
|
||||||
|
task_id = send_publish(
|
||||||
|
"documents.tasks.apply_ai_suggestions",
|
||||||
|
(),
|
||||||
|
{"action_id": 1, "document_id": 42},
|
||||||
|
)
|
||||||
|
|
||||||
|
task = PaperlessTask.objects.get(task_id=task_id)
|
||||||
|
assert task.task_type == PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS
|
||||||
|
assert task.input_data == {"document_id": 42}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestTaskRevokedHandler:
|
class TestTaskRevokedHandler:
|
||||||
def test_marks_task_revoked(self, mocker: pytest_mock.MockerFixture) -> None:
|
def test_marks_task_revoked(self, mocker: pytest_mock.MockerFixture) -> None:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from documents.models import Correspondent
|
|||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.models import WorkflowAction
|
||||||
from documents.sanity_checker import SanityCheckFailedException
|
from documents.sanity_checker import SanityCheckFailedException
|
||||||
from documents.sanity_checker import SanityCheckMessages
|
from documents.sanity_checker import SanityCheckMessages
|
||||||
from documents.tests.test_classifier import dummy_preprocess
|
from documents.tests.test_classifier import dummy_preprocess
|
||||||
@@ -287,6 +288,45 @@ 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,
|
||||||
@@ -408,3 +448,110 @@ class TestAIIndex(DirectoriesMixin, TestCase):
|
|||||||
rebuild=False,
|
rebuild=False,
|
||||||
document_ids=doc_ids,
|
document_ids=doc_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyAISuggestionsTask(DirectoriesMixin, TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
self.doc = Document.objects.create(
|
||||||
|
title="doc",
|
||||||
|
content="content",
|
||||||
|
checksum="apply-ai-suggestions",
|
||||||
|
)
|
||||||
|
self.action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
|
||||||
|
ai_suggestion_fields=[WorkflowAction.AISuggestionField.TITLE],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reindexes_without_sending_document_updated(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An apply AI suggestions action that changes the document
|
||||||
|
WHEN:
|
||||||
|
- The task runs
|
||||||
|
THEN:
|
||||||
|
- The search index and caches are refreshed directly, deliberately
|
||||||
|
not via the document_updated signal: that re-runs updated
|
||||||
|
workflows, which for this action means queueing another LLM
|
||||||
|
query for a document it just changed, forever
|
||||||
|
"""
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.workflows.ai.apply_ai_suggestions_to_document",
|
||||||
|
return_value=["title"],
|
||||||
|
),
|
||||||
|
mock.patch("documents.tasks.index_document") as index_document,
|
||||||
|
mock.patch("documents.tasks.clear_document_caches") as clear_caches,
|
||||||
|
mock.patch("documents.tasks.document_updated") as document_updated,
|
||||||
|
):
|
||||||
|
tasks.apply_ai_suggestions(self.action.pk, self.doc.pk)
|
||||||
|
|
||||||
|
index_document.delay.assert_called_once_with(self.doc.pk)
|
||||||
|
clear_caches.assert_called_once_with(self.doc.pk)
|
||||||
|
document_updated.send.assert_not_called()
|
||||||
|
|
||||||
|
def test_no_changes_skips_reindex(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An apply AI suggestions action that changes nothing
|
||||||
|
WHEN:
|
||||||
|
- The task runs
|
||||||
|
THEN:
|
||||||
|
- No reindexing work is queued
|
||||||
|
"""
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.workflows.ai.apply_ai_suggestions_to_document",
|
||||||
|
return_value=[],
|
||||||
|
),
|
||||||
|
mock.patch("documents.tasks.index_document") as index_document,
|
||||||
|
):
|
||||||
|
tasks.apply_ai_suggestions(self.action.pk, self.doc.pk)
|
||||||
|
|
||||||
|
index_document.delay.assert_not_called()
|
||||||
|
|
||||||
|
@override_settings(AI_ENABLED=True, LLM_EMBEDDING_BACKEND="huggingface")
|
||||||
|
def test_updates_llm_index_when_enabled(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An apply AI suggestions action that changes the document
|
||||||
|
- The LLM index is enabled
|
||||||
|
WHEN:
|
||||||
|
- The task runs
|
||||||
|
THEN:
|
||||||
|
- The document is updated in the LLM index too
|
||||||
|
"""
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.workflows.ai.apply_ai_suggestions_to_document",
|
||||||
|
return_value=["title"],
|
||||||
|
),
|
||||||
|
mock.patch("documents.tasks.index_document"),
|
||||||
|
mock.patch(
|
||||||
|
"documents.tasks.update_document_in_llm_index",
|
||||||
|
) as update_in_llm_index,
|
||||||
|
):
|
||||||
|
tasks.apply_ai_suggestions(self.action.pk, self.doc.pk)
|
||||||
|
|
||||||
|
update_in_llm_index.apply_async.assert_called_once()
|
||||||
|
|
||||||
|
def test_deleted_document_is_a_noop(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document that was deleted between the workflow running and the
|
||||||
|
queued task starting
|
||||||
|
WHEN:
|
||||||
|
- The task runs
|
||||||
|
THEN:
|
||||||
|
- It logs and exits rather than raising
|
||||||
|
"""
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.workflows.ai.apply_ai_suggestions_to_document",
|
||||||
|
) as apply_suggestions,
|
||||||
|
self.assertLogs("paperless.tasks", level="WARNING") as cm,
|
||||||
|
):
|
||||||
|
tasks.apply_ai_suggestions(self.action.pk, self.doc.pk + 1000)
|
||||||
|
|
||||||
|
apply_suggestions.assert_not_called()
|
||||||
|
self.assertIn("no longer exists", "".join(cm.output))
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(response.json()["tags"], [])
|
self.assertEqual(response.json()["tags"], [])
|
||||||
self.assertEqual(response.json()["suggested_tags"], [])
|
self.assertEqual(response.json()["suggested_tags"], [])
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
@@ -491,7 +491,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
@@ -529,7 +529,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
"KI Title",
|
"KI Title",
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
@@ -568,7 +568,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
"Titre IA",
|
"Titre IA",
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
@@ -604,7 +604,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="openai-like",
|
LLM_BACKEND="openai-like",
|
||||||
@@ -633,7 +633,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="openai-like",
|
LLM_BACKEND="openai-like",
|
||||||
@@ -660,7 +660,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
get_llm_suggestion_cache(self.document.pk, backend="openai-like"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
@@ -698,7 +698,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
||||||
self.assertEqual(response.json()["suggested_tags"], ["Follow-up"])
|
self.assertEqual(response.json()["suggested_tags"], ["Follow-up"])
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
@@ -737,7 +737,7 @@ class TestAISuggestions(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
self.assertEqual(response.json()["tags"], [self.tag1.pk])
|
||||||
self.assertEqual(response.json()["suggested_tags"], [])
|
self.assertEqual(response.json()["suggested_tags"], [])
|
||||||
|
|
||||||
@patch("documents.views.get_ai_document_classification")
|
@patch("paperless_ai.ai_classifier.get_ai_document_classification")
|
||||||
@override_settings(
|
@override_settings(
|
||||||
AI_ENABLED=True,
|
AI_ENABLED=True,
|
||||||
LLM_BACKEND="mock_backend",
|
LLM_BACKEND="mock_backend",
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ from documents.file_handling import create_source_path_directory
|
|||||||
from documents.file_handling import generate_filename
|
from documents.file_handling import generate_filename
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
|
from documents.workflows.ai import apply_ai_suggestions_to_document
|
||||||
from documents.workflows.webhooks import send_webhook
|
from documents.workflows.webhooks import send_webhook
|
||||||
|
from paperless_ai.base_model import ClassificationSuggestions
|
||||||
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
@@ -5409,3 +5412,560 @@ class TestDateWorkflowLocalization(
|
|||||||
document = Document.objects.first()
|
document = Document.objects.first()
|
||||||
assert document is not None
|
assert document is not None
|
||||||
assert document.title == expected_title
|
assert document.title == expected_title
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteOCRWorkflowAction(DirectoriesMixin, SampleDirMixin, APITestCase):
|
||||||
|
def _make_workflow(self, trigger_type) -> None:
|
||||||
|
trigger = WorkflowTrigger.objects.create(type=trigger_type)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
|
)
|
||||||
|
w = Workflow.objects.create(name="Remote OCR", order=0)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
def test_consumption_trigger_requests_remote_ocr(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A consumption workflow with a remote OCR action
|
||||||
|
WHEN:
|
||||||
|
- A matching document is consumed
|
||||||
|
THEN:
|
||||||
|
- The overrides ask for remote OCR, which is what the consumer
|
||||||
|
reads when choosing a parser
|
||||||
|
"""
|
||||||
|
self._make_workflow(WorkflowTrigger.WorkflowTriggerType.CONSUMPTION)
|
||||||
|
|
||||||
|
test_file = shutil.copy(
|
||||||
|
self.SAMPLE_DIR / "simple.pdf",
|
||||||
|
self.dirs.scratch_dir / "simple.pdf",
|
||||||
|
)
|
||||||
|
overrides = DocumentMetadataOverrides()
|
||||||
|
|
||||||
|
run_workflows(
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
ConsumableDocument(
|
||||||
|
source=DocumentSource.ConsumeFolder,
|
||||||
|
original_file=test_file,
|
||||||
|
),
|
||||||
|
overrides=overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(overrides.remote_ocr)
|
||||||
|
|
||||||
|
def test_other_trigger_types_are_ignored(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A workflow with a remote OCR action that also has a
|
||||||
|
non-consumption trigger, which is a valid combination
|
||||||
|
WHEN:
|
||||||
|
- The non-consumption trigger fires
|
||||||
|
THEN:
|
||||||
|
- The action is skipped, since the document has already been
|
||||||
|
parsed by this point
|
||||||
|
"""
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
)
|
||||||
|
updated_trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
|
)
|
||||||
|
w = Workflow.objects.create(name="Remote OCR", order=0)
|
||||||
|
w.triggers.add(trigger, updated_trigger)
|
||||||
|
w.actions.add(action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
original_filename="sample.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertLogs("paperless.handlers", level="DEBUG") as cm:
|
||||||
|
run_workflows(
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
doc,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("only applies to consumption triggers", "".join(cm.output))
|
||||||
|
|
||||||
|
|
||||||
|
SUGGESTIONS: ClassificationSuggestions = {
|
||||||
|
"title": "Suggested Title",
|
||||||
|
"tags": {
|
||||||
|
"existing_ids": [],
|
||||||
|
"new_names": ["Existing Tag", "Suggested Tag"],
|
||||||
|
},
|
||||||
|
"correspondents": {
|
||||||
|
"existing_ids": [],
|
||||||
|
"new_names": ["Existing Correspondent", "Suggested Correspondent"],
|
||||||
|
},
|
||||||
|
"document_types": {
|
||||||
|
"existing_ids": [],
|
||||||
|
"new_names": ["Suggested Document Type"],
|
||||||
|
},
|
||||||
|
"storage_paths": {
|
||||||
|
"existing_ids": [],
|
||||||
|
"new_names": ["Suggested Storage Path"],
|
||||||
|
},
|
||||||
|
"dates": ["2024-03-05"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ALL_SUGGESTION_FIELDS = [
|
||||||
|
WorkflowAction.AISuggestionField.TITLE,
|
||||||
|
WorkflowAction.AISuggestionField.TAGS,
|
||||||
|
WorkflowAction.AISuggestionField.CORRESPONDENT,
|
||||||
|
WorkflowAction.AISuggestionField.DOCUMENT_TYPE,
|
||||||
|
WorkflowAction.AISuggestionField.STORAGE_PATH,
|
||||||
|
WorkflowAction.AISuggestionField.CREATED,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(AI_ENABLED=True)
|
||||||
|
class TestApplyAISuggestionsWorkflowAction(
|
||||||
|
DirectoriesMixin,
|
||||||
|
SampleDirMixin,
|
||||||
|
APITestCase,
|
||||||
|
):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
self.user = User.objects.create(username="ai-user")
|
||||||
|
self.doc = Document.objects.create(
|
||||||
|
title="original.pdf",
|
||||||
|
content="the document content",
|
||||||
|
checksum="ai-suggestions-checksum",
|
||||||
|
mime_type="application/pdf",
|
||||||
|
created=datetime.date(2020, 1, 1),
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_action(self, **kwargs) -> WorkflowAction:
|
||||||
|
return WorkflowAction.objects.create(
|
||||||
|
type=WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
|
||||||
|
ai_suggestion_fields=kwargs.pop(
|
||||||
|
"ai_suggestion_fields",
|
||||||
|
ALL_SUGGESTION_FIELDS,
|
||||||
|
),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_workflow(self, action: WorkflowAction, trigger_type) -> Workflow:
|
||||||
|
trigger = WorkflowTrigger.objects.create(type=trigger_type)
|
||||||
|
w = Workflow.objects.create(name="Apply AI suggestions", order=0)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(action)
|
||||||
|
w.save()
|
||||||
|
return w
|
||||||
|
|
||||||
|
def apply(
|
||||||
|
self,
|
||||||
|
action: WorkflowAction,
|
||||||
|
suggestions: ClassificationSuggestions = SUGGESTIONS,
|
||||||
|
) -> list[str]:
|
||||||
|
with mock.patch(
|
||||||
|
"documents.workflows.ai.get_ai_document_classification",
|
||||||
|
return_value=suggestions,
|
||||||
|
):
|
||||||
|
changed = apply_ai_suggestions_to_document(action, self.doc)
|
||||||
|
self.doc.refresh_from_db()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def test_document_added_trigger_queues_task(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document added workflow with an apply AI suggestions action
|
||||||
|
WHEN:
|
||||||
|
- A matching document is added
|
||||||
|
THEN:
|
||||||
|
- The work is queued rather than run inline, so a slow LLM query
|
||||||
|
cannot stall the rest of the workflow run
|
||||||
|
"""
|
||||||
|
action = self.make_action()
|
||||||
|
self.make_workflow(action, WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED)
|
||||||
|
|
||||||
|
with mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay:
|
||||||
|
run_workflows(
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
self.doc,
|
||||||
|
)
|
||||||
|
|
||||||
|
delay.assert_called_once_with(action_id=action.pk, document_id=self.doc.pk)
|
||||||
|
|
||||||
|
def test_consumption_trigger_is_ignored(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A workflow with an apply AI suggestions action and a consumption
|
||||||
|
trigger alongside a valid one
|
||||||
|
WHEN:
|
||||||
|
- The consumption trigger fires
|
||||||
|
THEN:
|
||||||
|
- The action is skipped, since the document has not been parsed
|
||||||
|
yet and so has no content to make suggestions from
|
||||||
|
"""
|
||||||
|
action = self.make_action()
|
||||||
|
w = self.make_workflow(
|
||||||
|
action,
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
)
|
||||||
|
w.triggers.add(
|
||||||
|
WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
test_file = shutil.copy(
|
||||||
|
self.SAMPLE_DIR / "simple.pdf",
|
||||||
|
self.dirs.scratch_dir / "simple.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay,
|
||||||
|
self.assertLogs("paperless.handlers", level="DEBUG") as cm,
|
||||||
|
):
|
||||||
|
run_workflows(
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
ConsumableDocument(
|
||||||
|
source=DocumentSource.ConsumeFolder,
|
||||||
|
original_file=test_file,
|
||||||
|
),
|
||||||
|
overrides=DocumentMetadataOverrides(),
|
||||||
|
)
|
||||||
|
|
||||||
|
delay.assert_not_called()
|
||||||
|
self.assertIn("does not apply to consumption triggers", "".join(cm.output))
|
||||||
|
|
||||||
|
def test_no_selected_fields_does_nothing(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action with no suggestion fields selected
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- Nothing is changed and it is logged
|
||||||
|
"""
|
||||||
|
action = self.make_action(ai_suggestion_fields=[])
|
||||||
|
|
||||||
|
with self.assertLogs("paperless.workflows.ai", level="WARNING") as cm:
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(changed, [])
|
||||||
|
self.assertIn("no AI suggestion fields selected", "".join(cm.output))
|
||||||
|
|
||||||
|
@override_settings(AI_ENABLED=False)
|
||||||
|
def test_ai_disabled_does_nothing(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action on an install where AI has since been disabled
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- Nothing is changed and it is logged
|
||||||
|
"""
|
||||||
|
action = self.make_action()
|
||||||
|
|
||||||
|
with self.assertLogs("paperless.workflows.ai", level="ERROR") as cm:
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(changed, [])
|
||||||
|
self.assertIn("AI is not enabled", "".join(cm.output))
|
||||||
|
|
||||||
|
def test_invalid_configuration_leaves_document_untouched(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An AI backend that is misconfigured
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- The failure is logged and the document is left alone. It is not
|
||||||
|
re-raised, because retrying will not fix a bad configuration
|
||||||
|
"""
|
||||||
|
action = self.make_action()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.workflows.ai.get_ai_document_classification",
|
||||||
|
side_effect=ValueError("nope"),
|
||||||
|
),
|
||||||
|
self.assertLogs("paperless.workflows.ai", level="ERROR") as cm,
|
||||||
|
):
|
||||||
|
changed = apply_ai_suggestions_to_document(action, self.doc)
|
||||||
|
|
||||||
|
self.assertEqual(changed, [])
|
||||||
|
self.doc.refresh_from_db()
|
||||||
|
self.assertEqual(self.doc.title, "original.pdf")
|
||||||
|
self.assertIn("Invalid AI configuration", "".join(cm.output))
|
||||||
|
|
||||||
|
def test_transient_llm_failure_is_raised_for_retry(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An LLM backend that times out, or rate limits the request
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- The error propagates so the queued task can back off and retry,
|
||||||
|
rather than silently dropping this document's suggestions
|
||||||
|
"""
|
||||||
|
action = self.make_action()
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.workflows.ai.get_ai_document_classification",
|
||||||
|
side_effect=LLMTimeoutError(),
|
||||||
|
),
|
||||||
|
self.assertRaises(LLMTimeoutError),
|
||||||
|
):
|
||||||
|
apply_ai_suggestions_to_document(action, self.doc)
|
||||||
|
|
||||||
|
self.doc.refresh_from_db()
|
||||||
|
self.assertEqual(self.doc.title, "original.pdf")
|
||||||
|
|
||||||
|
def test_only_matching_objects_are_applied(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action without create missing, and only some of the suggested
|
||||||
|
objects existing
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- Only the existing objects are assigned, unmatched suggestions are
|
||||||
|
dropped rather than creating anything
|
||||||
|
"""
|
||||||
|
tag = Tag.objects.create(name="Existing Tag", owner=self.user)
|
||||||
|
correspondent = Correspondent.objects.create(
|
||||||
|
name="Existing Correspondent",
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
action = self.make_action(ai_overwrite_existing=True)
|
||||||
|
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(self.doc.correspondent, correspondent)
|
||||||
|
self.assertEqual(list(self.doc.tags.all()), [tag])
|
||||||
|
# Nothing matched for these and create missing is off
|
||||||
|
self.assertIsNone(self.doc.document_type)
|
||||||
|
self.assertIsNone(self.doc.storage_path)
|
||||||
|
self.assertNotIn("document_type", changed)
|
||||||
|
self.assertEqual(Tag.objects.count(), 1)
|
||||||
|
self.assertEqual(Correspondent.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_existing_id_suggestions_are_applied(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- AI suggestions that select existing taxonomy candidates by ID
|
||||||
|
WHEN:
|
||||||
|
- The suggestions are applied
|
||||||
|
THEN:
|
||||||
|
- Each selected object is assigned to the document
|
||||||
|
"""
|
||||||
|
tag = Tag.objects.create(name="Existing Tag", owner=self.user)
|
||||||
|
correspondent = Correspondent.objects.create(
|
||||||
|
name="Existing Correspondent",
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
document_type = DocumentType.objects.create(
|
||||||
|
name="Existing Document Type",
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
storage_path = StoragePath.objects.create(
|
||||||
|
name="Existing Storage Path",
|
||||||
|
path="{{ title }}",
|
||||||
|
owner=self.user,
|
||||||
|
)
|
||||||
|
action = self.make_action(ai_overwrite_existing=True)
|
||||||
|
suggestions: ClassificationSuggestions = {
|
||||||
|
**SUGGESTIONS,
|
||||||
|
"tags": {"existing_ids": [tag.pk], "new_names": []},
|
||||||
|
"correspondents": {
|
||||||
|
"existing_ids": [correspondent.pk],
|
||||||
|
"new_names": [],
|
||||||
|
},
|
||||||
|
"document_types": {
|
||||||
|
"existing_ids": [document_type.pk],
|
||||||
|
"new_names": [],
|
||||||
|
},
|
||||||
|
"storage_paths": {
|
||||||
|
"existing_ids": [storage_path.pk],
|
||||||
|
"new_names": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = self.apply(action, suggestions)
|
||||||
|
|
||||||
|
self.assertEqual(list(self.doc.tags.all()), [tag])
|
||||||
|
self.assertEqual(self.doc.correspondent, correspondent)
|
||||||
|
self.assertEqual(self.doc.document_type, document_type)
|
||||||
|
self.assertEqual(self.doc.storage_path, storage_path)
|
||||||
|
self.assertTrue(
|
||||||
|
{"tags", "correspondent", "document_type", "storage_path"} <= set(changed),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_missing_creates_objects_owned_by_document_owner(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action with create missing enabled
|
||||||
|
WHEN:
|
||||||
|
- The action is applied and suggestions match nothing
|
||||||
|
THEN:
|
||||||
|
- Tags, correspondents and document types are created, owned by the
|
||||||
|
document owner so they stay private to them
|
||||||
|
- Storage paths are never created, since a path template cannot be
|
||||||
|
inferred from a name
|
||||||
|
"""
|
||||||
|
action = self.make_action(
|
||||||
|
ai_create_missing=True,
|
||||||
|
ai_overwrite_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(t.name for t in self.doc.tags.all()),
|
||||||
|
["Existing Tag", "Suggested Tag"],
|
||||||
|
)
|
||||||
|
self.assertEqual(self.doc.correspondent.name, "Existing Correspondent")
|
||||||
|
self.assertEqual(self.doc.correspondent.owner, self.user)
|
||||||
|
self.assertEqual(self.doc.document_type.name, "Suggested Document Type")
|
||||||
|
self.assertEqual(self.doc.document_type.owner, self.user)
|
||||||
|
|
||||||
|
self.assertIsNone(self.doc.storage_path)
|
||||||
|
self.assertFalse(StoragePath.objects.exists())
|
||||||
|
self.assertNotIn("storage_path", changed)
|
||||||
|
|
||||||
|
def test_overwrite_disabled_keeps_existing_values(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action without overwrite existing
|
||||||
|
- A document that already has a title, created date and
|
||||||
|
correspondent
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- The existing values are kept, only the empty document type is
|
||||||
|
filled in
|
||||||
|
"""
|
||||||
|
existing = Correspondent.objects.create(name="Mine", owner=self.user)
|
||||||
|
self.doc.correspondent = existing
|
||||||
|
self.doc.save()
|
||||||
|
action = self.make_action(ai_create_missing=True)
|
||||||
|
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(self.doc.title, "original.pdf")
|
||||||
|
self.assertEqual(self.doc.created, datetime.date(2020, 1, 1))
|
||||||
|
self.assertEqual(self.doc.correspondent, existing)
|
||||||
|
self.assertEqual(self.doc.document_type.name, "Suggested Document Type")
|
||||||
|
self.assertNotIn("title", changed)
|
||||||
|
self.assertNotIn("correspondent", changed)
|
||||||
|
|
||||||
|
def test_overwrite_enabled_replaces_existing_values(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action with overwrite existing
|
||||||
|
- A document that already has a title and created date
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- The suggested values replace them
|
||||||
|
"""
|
||||||
|
action = self.make_action(
|
||||||
|
ai_create_missing=True,
|
||||||
|
ai_overwrite_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(self.doc.title, "Suggested Title")
|
||||||
|
self.assertEqual(self.doc.created, datetime.date(2024, 3, 5))
|
||||||
|
self.assertIn("title", changed)
|
||||||
|
self.assertIn("created", changed)
|
||||||
|
|
||||||
|
def test_tags_are_added_not_replaced(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A document that already has a tag unrelated to the suggestions
|
||||||
|
WHEN:
|
||||||
|
- The action is applied with overwrite existing enabled
|
||||||
|
THEN:
|
||||||
|
- The existing tag is kept, since suggested tags are always
|
||||||
|
additive regardless of the overwrite setting
|
||||||
|
"""
|
||||||
|
kept = Tag.objects.create(name="Do Not Remove", owner=self.user)
|
||||||
|
self.doc.tags.add(kept)
|
||||||
|
Tag.objects.create(name="Existing Tag", owner=self.user)
|
||||||
|
action = self.make_action(ai_overwrite_existing=True)
|
||||||
|
|
||||||
|
self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(t.name for t in self.doc.tags.all()),
|
||||||
|
["Do Not Remove", "Existing Tag"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unselected_fields_are_untouched(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An action that only selects the title
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- Only the title changes, even though the LLM suggested everything
|
||||||
|
"""
|
||||||
|
action = self.make_action(
|
||||||
|
ai_suggestion_fields=[WorkflowAction.AISuggestionField.TITLE],
|
||||||
|
ai_create_missing=True,
|
||||||
|
ai_overwrite_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(changed, ["title"])
|
||||||
|
self.assertEqual(self.doc.title, "Suggested Title")
|
||||||
|
self.assertEqual(self.doc.tags.count(), 0)
|
||||||
|
self.assertIsNone(self.doc.correspondent)
|
||||||
|
self.assertEqual(self.doc.created, datetime.date(2020, 1, 1))
|
||||||
|
|
||||||
|
def test_another_users_private_objects_are_not_matched(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A suggested tag name that exists, but is owned by someone else
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- It is not assigned, because the document owner cannot see it
|
||||||
|
"""
|
||||||
|
other = User.objects.create(username="someone-else")
|
||||||
|
Tag.objects.create(name="Existing Tag", owner=other)
|
||||||
|
action = self.make_action(
|
||||||
|
ai_suggestion_fields=[WorkflowAction.AISuggestionField.TAGS],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.apply(action)
|
||||||
|
|
||||||
|
self.assertEqual(self.doc.tags.count(), 0)
|
||||||
|
|
||||||
|
def test_unparsable_dates_are_skipped(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Suggested dates that are not all valid
|
||||||
|
WHEN:
|
||||||
|
- The action is applied
|
||||||
|
THEN:
|
||||||
|
- The first usable date is applied and the rest ignored
|
||||||
|
"""
|
||||||
|
action = self.make_action(
|
||||||
|
ai_suggestion_fields=[WorkflowAction.AISuggestionField.CREATED],
|
||||||
|
ai_overwrite_existing=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"documents.workflows.ai.get_ai_document_classification",
|
||||||
|
return_value={**SUGGESTIONS, "dates": ["not a date", "2019-07-04"]},
|
||||||
|
):
|
||||||
|
changed = apply_ai_suggestions_to_document(action, self.doc)
|
||||||
|
|
||||||
|
self.doc.refresh_from_db()
|
||||||
|
self.assertEqual(changed, ["created"])
|
||||||
|
self.assertEqual(self.doc.created, datetime.date(2019, 7, 4))
|
||||||
|
|||||||
+29
-28
@@ -115,7 +115,7 @@ from documents.caching import get_metadata_cache
|
|||||||
from documents.caching import get_suggestion_cache
|
from documents.caching import get_suggestion_cache
|
||||||
from documents.caching import refresh_metadata_cache
|
from documents.caching import refresh_metadata_cache
|
||||||
from documents.caching import refresh_suggestions_cache
|
from documents.caching import refresh_suggestions_cache
|
||||||
from documents.caching import set_llm_suggestions_cache
|
from documents.caching import retrieve_llm_suggestions
|
||||||
from documents.caching import set_metadata_cache
|
from documents.caching import set_metadata_cache
|
||||||
from documents.caching import set_suggestions_cache
|
from documents.caching import set_suggestions_cache
|
||||||
from documents.classifier import load_classifier
|
from documents.classifier import load_classifier
|
||||||
@@ -239,12 +239,14 @@ 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
|
||||||
from paperless_ai.ai_classifier import get_ai_document_classification
|
from paperless_ai.ai_classifier import get_llm_output_language
|
||||||
from paperless_ai.chat import stream_chat_with_documents
|
from paperless_ai.chat import stream_chat_with_documents
|
||||||
from paperless_ai.exceptions import LLMTimeoutError
|
from paperless_ai.exceptions import LLMTimeoutError
|
||||||
from paperless_ai.matching import extract_unmatched_names
|
from paperless_ai.matching import extract_unmatched_names
|
||||||
@@ -663,20 +665,6 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
|
|||||||
update_document_parent_tags(tag, new_parent)
|
update_document_parent_tags(tag, new_parent)
|
||||||
|
|
||||||
|
|
||||||
def _get_llm_output_language(ai_config: AIConfig, request) -> str | None:
|
|
||||||
output_language = ai_config.llm_output_language
|
|
||||||
if (
|
|
||||||
not output_language
|
|
||||||
and hasattr(request.user, "ui_settings")
|
|
||||||
and isinstance(
|
|
||||||
request.user.ui_settings.settings,
|
|
||||||
dict,
|
|
||||||
)
|
|
||||||
):
|
|
||||||
output_language = request.user.ui_settings.settings.get("language")
|
|
||||||
return output_language
|
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
|
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
|
||||||
class DocumentTypeViewSet(
|
class DocumentTypeViewSet(
|
||||||
PermissionsAwareDocumentCountMixin,
|
PermissionsAwareDocumentCountMixin,
|
||||||
@@ -1131,7 +1119,8 @@ class DocumentViewSet(
|
|||||||
"custom_fields",
|
"custom_fields",
|
||||||
queryset=CustomFieldInstance.objects.select_related("field"),
|
queryset=CustomFieldInstance.objects.select_related("field"),
|
||||||
),
|
),
|
||||||
"notes",
|
# NotesSerializer nests the author, this avoids query per note
|
||||||
|
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1539,7 +1528,10 @@ class DocumentViewSet(
|
|||||||
if not ai_config.ai_enabled:
|
if not ai_config.ai_enabled:
|
||||||
return HttpResponseBadRequest("AI is required for this feature")
|
return HttpResponseBadRequest("AI is required for this feature")
|
||||||
|
|
||||||
output_language = _get_llm_output_language(ai_config=ai_config, request=request)
|
output_language = get_llm_output_language(
|
||||||
|
ai_config=ai_config,
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
llm_cache_backend = ":".join(
|
llm_cache_backend = ":".join(
|
||||||
part
|
part
|
||||||
for part in (
|
for part in (
|
||||||
@@ -1567,10 +1559,13 @@ class DocumentViewSet(
|
|||||||
llm_suggestions = cached_llm_suggestions.suggestions
|
llm_suggestions = cached_llm_suggestions.suggestions
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
llm_suggestions = get_ai_document_classification(
|
llm_suggestions = retrieve_llm_suggestions(
|
||||||
doc,
|
document=doc,
|
||||||
request.user,
|
user=request.user,
|
||||||
output_language,
|
output_language=output_language,
|
||||||
|
backend=llm_cache_backend,
|
||||||
|
# Classification, localization + 30s
|
||||||
|
lock_timeout=(2 * ai_config.llm_request_timeout) + 30,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -1595,11 +1590,6 @@ class DocumentViewSet(
|
|||||||
{"ai": [_("AI backend request timed out.")]},
|
{"ai": [_("AI backend request timed out.")]},
|
||||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
)
|
)
|
||||||
set_llm_suggestions_cache(
|
|
||||||
doc.pk,
|
|
||||||
llm_suggestions,
|
|
||||||
backend=llm_cache_backend,
|
|
||||||
)
|
|
||||||
|
|
||||||
tags_choice: TaxonomyChoiceDict = llm_suggestions["tags"]
|
tags_choice: TaxonomyChoiceDict = llm_suggestions["tags"]
|
||||||
correspondents_choice: TaxonomyChoiceDict = llm_suggestions["correspondents"]
|
correspondents_choice: TaxonomyChoiceDict = llm_suggestions["correspondents"]
|
||||||
@@ -2328,7 +2318,10 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
id__in=permitted_document_ids(request.user),
|
id__in=permitted_document_ids(request.user),
|
||||||
)
|
)
|
||||||
|
|
||||||
output_language = _get_llm_output_language(ai_config=ai_config, request=request)
|
output_language = get_llm_output_language(
|
||||||
|
ai_config=ai_config,
|
||||||
|
user=request.user,
|
||||||
|
)
|
||||||
|
|
||||||
response = StreamingHttpResponse(
|
response = StreamingHttpResponse(
|
||||||
stream_chat_with_documents(
|
stream_chat_with_documents(
|
||||||
@@ -2792,8 +2785,11 @@ class DocumentSelectionMixin:
|
|||||||
for key, value in filters.items()
|
for key, value in filters.items()
|
||||||
if key not in _TANTIVY_SEARCH_PARAM_NAMES
|
if key not in _TANTIVY_SEARCH_PARAM_NAMES
|
||||||
}
|
}
|
||||||
|
# Operations are addressed to roots, a caller that wants
|
||||||
|
# to act on a specific version passes its id explicitly instead
|
||||||
permitted_documents = Document.objects.filter(
|
permitted_documents = Document.objects.filter(
|
||||||
id__in=permitted_document_ids(user),
|
id__in=permitted_document_ids(user),
|
||||||
|
root_document__isnull=True,
|
||||||
)
|
)
|
||||||
# orm-filtered docs
|
# orm-filtered docs
|
||||||
filtered_documents = DocumentFilterSet(
|
filtered_documents = DocumentFilterSet(
|
||||||
@@ -4100,6 +4096,11 @@ 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:
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from documents.models import Correspondent
|
||||||
|
from documents.models import Document
|
||||||
|
from documents.models import DocumentType
|
||||||
|
from documents.models import MatchingModel
|
||||||
|
from documents.models import StoragePath
|
||||||
|
from documents.models import Tag
|
||||||
|
from documents.models import WorkflowAction
|
||||||
|
from paperless.config import AIConfig
|
||||||
|
from paperless_ai.ai_classifier import get_ai_document_classification
|
||||||
|
from paperless_ai.ai_classifier import get_llm_output_language
|
||||||
|
from paperless_ai.matching import extract_unmatched_names
|
||||||
|
from paperless_ai.matching import match_correspondents_by_name
|
||||||
|
from paperless_ai.matching import match_document_types_by_name
|
||||||
|
from paperless_ai.matching import match_storage_paths_by_name
|
||||||
|
from paperless_ai.matching import match_tags_by_name
|
||||||
|
from paperless_ai.matching import resolve_correspondent_ids
|
||||||
|
from paperless_ai.matching import resolve_document_type_ids
|
||||||
|
from paperless_ai.matching import resolve_storage_path_ids
|
||||||
|
from paperless_ai.matching import resolve_tag_ids
|
||||||
|
|
||||||
|
logger = logging.getLogger("paperless.workflows.ai")
|
||||||
|
|
||||||
|
AISuggestionField = WorkflowAction.AISuggestionField
|
||||||
|
ObjT = TypeVar("ObjT", bound=MatchingModel)
|
||||||
|
|
||||||
|
# Tags use m2m relation instead
|
||||||
|
DIRECT_FIELDS: dict[str, str] = {
|
||||||
|
AISuggestionField.TITLE: "title",
|
||||||
|
AISuggestionField.CORRESPONDENT: "correspondent",
|
||||||
|
AISuggestionField.DOCUMENT_TYPE: "document_type",
|
||||||
|
AISuggestionField.STORAGE_PATH: "storage_path",
|
||||||
|
AISuggestionField.CREATED: "created",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_date(dates: list[str]) -> date | None:
|
||||||
|
"""
|
||||||
|
First usable date out of the suggestions, which are expected as
|
||||||
|
YYYY-MM-DD. Document.created is a DateField, so only one can be applied.
|
||||||
|
"""
|
||||||
|
for value in dates:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value, "%Y-%m-%d").date()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.debug("Ignoring unparsable suggested date %s", value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_object(
|
||||||
|
model: type[ObjT],
|
||||||
|
names: list[str],
|
||||||
|
matched: list[ObjT],
|
||||||
|
*,
|
||||||
|
create_missing: bool,
|
||||||
|
owner: User | None,
|
||||||
|
) -> ObjT | None:
|
||||||
|
"""
|
||||||
|
Single object from a suggestion list. The best match if there was one, else
|
||||||
|
optionally a newly-created object. StoragePaths are excluded.
|
||||||
|
"""
|
||||||
|
if matched:
|
||||||
|
return matched[0]
|
||||||
|
|
||||||
|
if not create_missing or model is StoragePath:
|
||||||
|
return None
|
||||||
|
|
||||||
|
unmatched = extract_unmatched_names(names, matched)
|
||||||
|
if not unmatched:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# (name, owner) is what MatchingModel is unique on
|
||||||
|
obj, created = model.objects.get_or_create(
|
||||||
|
name=unmatched[0][:128],
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
logger.info("Created %s '%s' from AI suggestion", model.__name__, obj.name)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tags(
|
||||||
|
names: list[str],
|
||||||
|
matched: list[Tag],
|
||||||
|
*,
|
||||||
|
create_missing: bool,
|
||||||
|
owner: User | None,
|
||||||
|
) -> list[Tag]:
|
||||||
|
"""
|
||||||
|
Matched tags, plus newly created ones if create_missing is set.
|
||||||
|
"""
|
||||||
|
tags = list(matched)
|
||||||
|
if not create_missing:
|
||||||
|
return tags
|
||||||
|
|
||||||
|
for name in extract_unmatched_names(names, matched):
|
||||||
|
tag, created = Tag.objects.get_or_create(
|
||||||
|
name=name[:128],
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
logger.info("Created tag '%s' from AI suggestion", tag.name)
|
||||||
|
tags.append(tag)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def apply_ai_suggestions_to_document(
|
||||||
|
action: WorkflowAction,
|
||||||
|
document: Document,
|
||||||
|
logging_group=None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get suggestions about `document` and write the chosen fields.
|
||||||
|
|
||||||
|
Returns the names of the fields that were actually changed.
|
||||||
|
"""
|
||||||
|
selected = set(action.ai_suggestion_fields or [])
|
||||||
|
if not selected:
|
||||||
|
logger.warning(
|
||||||
|
"Workflow action %s has no AI suggestion fields selected, skipping",
|
||||||
|
action.pk,
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
ai_config = AIConfig()
|
||||||
|
if not ai_config.ai_enabled:
|
||||||
|
logger.error(
|
||||||
|
"AI is not enabled, cannot apply AI suggestions for document %s",
|
||||||
|
document.pk,
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Workflows run without a user, so we use the document owner
|
||||||
|
owner = document.owner
|
||||||
|
|
||||||
|
try:
|
||||||
|
suggestions = get_ai_document_classification(
|
||||||
|
document,
|
||||||
|
owner,
|
||||||
|
get_llm_output_language(ai_config, owner),
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# A bad AI config will not fix itself, so swallow it rather than
|
||||||
|
# letting the caller retry. Timeouts, rate limits, network errors etc
|
||||||
|
# propagate so the queued task can back off and try again.
|
||||||
|
logger.exception(
|
||||||
|
"Invalid AI configuration, cannot get suggestions for document %s",
|
||||||
|
document.pk,
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
overwrite = action.ai_overwrite_existing
|
||||||
|
create_missing = action.ai_create_missing
|
||||||
|
updated_fields: list[str] = []
|
||||||
|
|
||||||
|
def should_set(field: str) -> bool:
|
||||||
|
# The field is selected and (overwrite or it's empty)
|
||||||
|
return field in selected and (
|
||||||
|
overwrite or getattr(document, DIRECT_FIELDS[field]) in (None, "")
|
||||||
|
)
|
||||||
|
|
||||||
|
if should_set(AISuggestionField.TITLE):
|
||||||
|
title = suggestions["title"].strip()
|
||||||
|
if title:
|
||||||
|
# title is capped at 128 characters
|
||||||
|
document.title = title[:128]
|
||||||
|
updated_fields.append("title")
|
||||||
|
|
||||||
|
if should_set(AISuggestionField.CORRESPONDENT):
|
||||||
|
choice = suggestions["correspondents"]
|
||||||
|
names = choice["new_names"]
|
||||||
|
correspondent = resolve_object(
|
||||||
|
Correspondent,
|
||||||
|
names,
|
||||||
|
resolve_correspondent_ids(choice["existing_ids"], owner)
|
||||||
|
+ match_correspondents_by_name(names, owner),
|
||||||
|
create_missing=create_missing,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if correspondent:
|
||||||
|
document.correspondent = correspondent
|
||||||
|
updated_fields.append("correspondent")
|
||||||
|
|
||||||
|
if should_set(AISuggestionField.DOCUMENT_TYPE):
|
||||||
|
choice = suggestions["document_types"]
|
||||||
|
names = choice["new_names"]
|
||||||
|
document_type = resolve_object(
|
||||||
|
DocumentType,
|
||||||
|
names,
|
||||||
|
resolve_document_type_ids(choice["existing_ids"], owner)
|
||||||
|
+ match_document_types_by_name(names, owner),
|
||||||
|
create_missing=create_missing,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if document_type:
|
||||||
|
document.document_type = document_type
|
||||||
|
updated_fields.append("document_type")
|
||||||
|
|
||||||
|
if should_set(AISuggestionField.STORAGE_PATH):
|
||||||
|
choice = suggestions["storage_paths"]
|
||||||
|
names = choice["new_names"]
|
||||||
|
storage_path = resolve_object(
|
||||||
|
StoragePath,
|
||||||
|
names,
|
||||||
|
resolve_storage_path_ids(choice["existing_ids"], owner)
|
||||||
|
+ match_storage_paths_by_name(names, owner),
|
||||||
|
create_missing=create_missing,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if storage_path:
|
||||||
|
document.storage_path = storage_path
|
||||||
|
updated_fields.append("storage_path")
|
||||||
|
|
||||||
|
if should_set(AISuggestionField.CREATED):
|
||||||
|
created = resolve_date(suggestions["dates"])
|
||||||
|
if created:
|
||||||
|
document.created = created
|
||||||
|
updated_fields.append("created")
|
||||||
|
|
||||||
|
if AISuggestionField.TAGS in selected:
|
||||||
|
choice = suggestions["tags"]
|
||||||
|
names = choice["new_names"]
|
||||||
|
tags = resolve_tags(
|
||||||
|
names,
|
||||||
|
resolve_tag_ids(choice["existing_ids"], owner)
|
||||||
|
+ match_tags_by_name(names, owner),
|
||||||
|
create_missing=create_missing,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
if tags:
|
||||||
|
# Suggested tags are always added, so overwrite_existing
|
||||||
|
# does not really apply here
|
||||||
|
document.add_nested_tags(tags)
|
||||||
|
updated_fields.append("tags")
|
||||||
|
|
||||||
|
if updated_fields:
|
||||||
|
# save fields and update modified (excluding m2m tags from update_fields)
|
||||||
|
direct_updated_fields = [
|
||||||
|
field for field in updated_fields if field in DIRECT_FIELDS.values()
|
||||||
|
]
|
||||||
|
document.save(update_fields=[*direct_updated_fields, "modified"])
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Applied AI suggestions %s to document %s",
|
||||||
|
updated_fields or "(none)",
|
||||||
|
document.pk,
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
|
||||||
|
return updated_fields
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -337,20 +337,6 @@ 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,6 +9,7 @@ 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
|
||||||
@@ -185,6 +186,45 @@ 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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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,6 +74,23 @@ 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"))
|
||||||
@@ -286,6 +303,44 @@ 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,6 +134,11 @@ 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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -145,6 +150,10 @@ 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,6 +334,8 @@ 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.
|
||||||
|
|
||||||
@@ -359,6 +361,11 @@ 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
|
||||||
-------
|
-------
|
||||||
@@ -374,6 +381,13 @@ 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,6 +61,18 @@ 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 (
|
||||||
@@ -90,6 +102,9 @@ 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"
|
||||||
@@ -97,6 +112,8 @@ 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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -145,11 +162,7 @@ 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(
|
config = RemoteEngineConfig.from_app_config()
|
||||||
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:
|
||||||
@@ -244,11 +257,7 @@ 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(
|
config = RemoteEngineConfig.from_app_config()
|
||||||
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,6 +219,13 @@ 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
|
||||||
@@ -230,11 +237,13 @@ 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
|
||||||
if "llm_api_key" in data and data["llm_api_key"] is not None:
|
for field in self.OBFUSCATED_FIELDS:
|
||||||
if data["llm_api_key"] == "":
|
if field in data and data[field] is not None:
|
||||||
data["llm_api_key"] = None
|
if data[field] == "":
|
||||||
elif len(data["llm_api_key"].replace("*", "")) == 0:
|
data[field] = None
|
||||||
del data["llm_api_key"]
|
# Not a real value, don't overwrite the stored one
|
||||||
|
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,6 +1203,11 @@ 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,7 +114,26 @@ def remote_parser() -> Generator[RemoteDocumentParser, None, None]:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def azure_settings(settings: SettingsWrapper) -> SettingsWrapper:
|
def empty_remote_ocr_app_config(mocker: MockerFixture) -> MagicMock:
|
||||||
|
# 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
|
||||||
@@ -133,7 +152,10 @@ def azure_settings(settings: SettingsWrapper) -> SettingsWrapper:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def no_engine_settings(settings: SettingsWrapper) -> SettingsWrapper:
|
def no_engine_settings(
|
||||||
|
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,6 +21,7 @@ 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
|
||||||
@@ -198,21 +199,21 @@ class TestRemoteParserScore:
|
|||||||
|
|
||||||
def test_score_returns_none_when_api_key_missing(
|
def test_score_returns_none_when_api_key_missing(
|
||||||
self,
|
self,
|
||||||
settings: SettingsWrapper,
|
no_engine_settings: SettingsWrapper,
|
||||||
) -> None:
|
) -> None:
|
||||||
settings.REMOTE_OCR_ENGINE = "azureai"
|
no_engine_settings.REMOTE_OCR_ENGINE = "azureai"
|
||||||
settings.REMOTE_OCR_API_KEY = None
|
no_engine_settings.REMOTE_OCR_ENDPOINT = (
|
||||||
settings.REMOTE_OCR_ENDPOINT = "https://test.cognitiveservices.azure.com"
|
"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,
|
||||||
settings: SettingsWrapper,
|
no_engine_settings: SettingsWrapper,
|
||||||
) -> None:
|
) -> None:
|
||||||
settings.REMOTE_OCR_ENGINE = "azureai"
|
no_engine_settings.REMOTE_OCR_ENGINE = "azureai"
|
||||||
settings.REMOTE_OCR_API_KEY = "key"
|
no_engine_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
|
||||||
|
|
||||||
@@ -227,6 +228,24 @@ 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,6 +1277,8 @@ 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,7 +15,6 @@ 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
|
||||||
@@ -631,31 +630,6 @@ 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,6 +468,124 @@ 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()."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""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
|
||||||
@@ -47,6 +47,22 @@ def get_language_name(language_code: str) -> str:
|
|||||||
return language_code
|
return language_code
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_output_language(ai_config: AIConfig, user: User | None) -> str | None:
|
||||||
|
"""
|
||||||
|
Language to localize LLM output into: the configured language, falling back
|
||||||
|
to the user's own UI language when unset.
|
||||||
|
"""
|
||||||
|
output_language = ai_config.llm_output_language
|
||||||
|
if (
|
||||||
|
not output_language
|
||||||
|
and user is not None
|
||||||
|
and hasattr(user, "ui_settings")
|
||||||
|
and isinstance(user.ui_settings.settings, dict)
|
||||||
|
):
|
||||||
|
output_language = user.ui_settings.settings.get("language")
|
||||||
|
return output_language
|
||||||
|
|
||||||
|
|
||||||
def build_prompt_without_rag(
|
def build_prompt_without_rag(
|
||||||
document: Document,
|
document: Document,
|
||||||
config: AIConfig,
|
config: AIConfig,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from documents.models import Correspondent
|
|||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.permissions import get_objects_for_user_owner_aware
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import restrict_queryset_to_visible
|
from documents.permissions import restrict_queryset_to_visible
|
||||||
|
|
||||||
MATCH_THRESHOLD = 0.8
|
MATCH_THRESHOLD = 0.8
|
||||||
@@ -63,30 +63,40 @@ def resolve_storage_path_ids(ids: list[int], user: User | None) -> list[StorageP
|
|||||||
|
|
||||||
def _match_by_name(
|
def _match_by_name(
|
||||||
names: list[str],
|
names: list[str],
|
||||||
user: User,
|
user: User | None,
|
||||||
model: type[ModelT],
|
model: type[ModelT],
|
||||||
perm: str,
|
perm: str,
|
||||||
) -> list[ModelT]:
|
) -> list[ModelT]:
|
||||||
queryset = get_objects_for_user_owner_aware(user, [perm], model)
|
# A workflow may have no user. In that case permitted_object_ids limits
|
||||||
|
# matching to unowned objects, avoiding another user's private taxonomy.
|
||||||
|
queryset = model.objects.filter(
|
||||||
|
pk__in=permitted_object_ids(user, model, perm),
|
||||||
|
)
|
||||||
return _match_names_to_queryset(names, queryset)
|
return _match_names_to_queryset(names, queryset)
|
||||||
|
|
||||||
|
|
||||||
def match_tags_by_name(names: list[str], user: User) -> list[Tag]:
|
def match_tags_by_name(names: list[str], user: User | None) -> list[Tag]:
|
||||||
return _match_by_name(names, user, Tag, "view_tag")
|
return _match_by_name(names, user, Tag, "view_tag")
|
||||||
|
|
||||||
|
|
||||||
def match_correspondents_by_name(
|
def match_correspondents_by_name(
|
||||||
names: list[str],
|
names: list[str],
|
||||||
user: User,
|
user: User | None,
|
||||||
) -> list[Correspondent]:
|
) -> list[Correspondent]:
|
||||||
return _match_by_name(names, user, Correspondent, "view_correspondent")
|
return _match_by_name(names, user, Correspondent, "view_correspondent")
|
||||||
|
|
||||||
|
|
||||||
def match_document_types_by_name(names: list[str], user: User) -> list[DocumentType]:
|
def match_document_types_by_name(
|
||||||
|
names: list[str],
|
||||||
|
user: User | None,
|
||||||
|
) -> list[DocumentType]:
|
||||||
return _match_by_name(names, user, DocumentType, "view_documenttype")
|
return _match_by_name(names, user, DocumentType, "view_documenttype")
|
||||||
|
|
||||||
|
|
||||||
def match_storage_paths_by_name(names: list[str], user: User) -> list[StoragePath]:
|
def match_storage_paths_by_name(
|
||||||
|
names: list[str],
|
||||||
|
user: User | None,
|
||||||
|
) -> list[StoragePath]:
|
||||||
return _match_by_name(names, user, StoragePath, "view_storagepath")
|
return _match_by_name(names, user, StoragePath, "view_storagepath")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_mock
|
import pytest_mock
|
||||||
@@ -45,33 +44,25 @@ class TestAIMatching(TestCase):
|
|||||||
self.storage_path1 = StoragePath.objects.create(name="Test Storage Path 1")
|
self.storage_path1 = StoragePath.objects.create(name="Test Storage Path 1")
|
||||||
self.storage_path2 = StoragePath.objects.create(name="Test Storage Path 2")
|
self.storage_path2 = StoragePath.objects.create(name="Test Storage Path 2")
|
||||||
|
|
||||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
def test_match_tags_by_name(self) -> None:
|
||||||
def test_match_tags_by_name(self, mock_get_objects) -> None:
|
|
||||||
mock_get_objects.return_value = Tag.objects.all()
|
|
||||||
names = ["Test Tag 1", "Nonexistent Tag"]
|
names = ["Test Tag 1", "Nonexistent Tag"]
|
||||||
result = match_tags_by_name(names, user=None)
|
result = match_tags_by_name(names, user=None)
|
||||||
self.assertEqual(len(result), 1)
|
self.assertEqual(len(result), 1)
|
||||||
self.assertEqual(result[0].name, "Test Tag 1")
|
self.assertEqual(result[0].name, "Test Tag 1")
|
||||||
|
|
||||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
def test_match_correspondents_by_name(self) -> None:
|
||||||
def test_match_correspondents_by_name(self, mock_get_objects) -> None:
|
|
||||||
mock_get_objects.return_value = Correspondent.objects.all()
|
|
||||||
names = ["Test Correspondent 1", "Nonexistent Correspondent"]
|
names = ["Test Correspondent 1", "Nonexistent Correspondent"]
|
||||||
result = match_correspondents_by_name(names, user=None)
|
result = match_correspondents_by_name(names, user=None)
|
||||||
self.assertEqual(len(result), 1)
|
self.assertEqual(len(result), 1)
|
||||||
self.assertEqual(result[0].name, "Test Correspondent 1")
|
self.assertEqual(result[0].name, "Test Correspondent 1")
|
||||||
|
|
||||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
def test_match_document_types_by_name(self) -> None:
|
||||||
def test_match_document_types_by_name(self, mock_get_objects) -> None:
|
|
||||||
mock_get_objects.return_value = DocumentType.objects.all()
|
|
||||||
names = ["Test Document Type 1", "Nonexistent Document Type"]
|
names = ["Test Document Type 1", "Nonexistent Document Type"]
|
||||||
result = match_document_types_by_name(names, user=None)
|
result = match_document_types_by_name(names, user=None)
|
||||||
self.assertEqual(len(result), 1)
|
self.assertEqual(len(result), 1)
|
||||||
self.assertEqual(result[0].name, "Test Document Type 1")
|
self.assertEqual(result[0].name, "Test Document Type 1")
|
||||||
|
|
||||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
def test_match_storage_paths_by_name(self) -> None:
|
||||||
def test_match_storage_paths_by_name(self, mock_get_objects) -> None:
|
|
||||||
mock_get_objects.return_value = StoragePath.objects.all()
|
|
||||||
names = ["Test Storage Path 1", "Nonexistent Storage Path"]
|
names = ["Test Storage Path 1", "Nonexistent Storage Path"]
|
||||||
result = match_storage_paths_by_name(names, user=None)
|
result = match_storage_paths_by_name(names, user=None)
|
||||||
self.assertEqual(len(result), 1)
|
self.assertEqual(len(result), 1)
|
||||||
@@ -83,16 +74,12 @@ class TestAIMatching(TestCase):
|
|||||||
unmatched_names = extract_unmatched_names(llm_names, matched_objects)
|
unmatched_names = extract_unmatched_names(llm_names, matched_objects)
|
||||||
self.assertEqual(unmatched_names, ["Nonexistent Tag"])
|
self.assertEqual(unmatched_names, ["Nonexistent Tag"])
|
||||||
|
|
||||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
def test_match_tags_by_name_with_empty_names(self) -> None:
|
||||||
def test_match_tags_by_name_with_empty_names(self, mock_get_objects) -> None:
|
|
||||||
mock_get_objects.return_value = Tag.objects.all()
|
|
||||||
names = [None, "", " "]
|
names = [None, "", " "]
|
||||||
result = match_tags_by_name(names, user=None)
|
result = match_tags_by_name(names, user=None)
|
||||||
self.assertEqual(result, [])
|
self.assertEqual(result, [])
|
||||||
|
|
||||||
@patch("paperless_ai.matching.get_objects_for_user_owner_aware")
|
def test_match_tags_with_fuzzy_matching(self) -> None:
|
||||||
def test_match_tags_with_fuzzy_matching(self, mock_get_objects) -> None:
|
|
||||||
mock_get_objects.return_value = Tag.objects.all()
|
|
||||||
names = ["Test Taag 1", "Teest Tag 2"]
|
names = ["Test Taag 1", "Teest Tag 2"]
|
||||||
result = match_tags_by_name(names, user=None)
|
result = match_tags_by_name(names, user=None)
|
||||||
self.assertEqual(len(result), 2)
|
self.assertEqual(len(result), 2)
|
||||||
|
|||||||
@@ -253,6 +253,81 @@ class TestAPIMailAccounts(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data["success"], True)
|
self.assertEqual(response.data["success"], True)
|
||||||
|
|
||||||
|
def test_mail_account_test_existing_no_global_perms(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing account without an owner
|
||||||
|
- User without any mail account permissions
|
||||||
|
WHEN:
|
||||||
|
- API call is made to test the account by id
|
||||||
|
THEN:
|
||||||
|
- API returns forbidden
|
||||||
|
"""
|
||||||
|
account = MailAccountFactory(
|
||||||
|
username="admin",
|
||||||
|
password="secret",
|
||||||
|
imap_server="server.example.com",
|
||||||
|
imap_port=443,
|
||||||
|
owner=None,
|
||||||
|
)
|
||||||
|
user = User.objects.create_user(username="no_perms")
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}test/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": account.pk,
|
||||||
|
"imap_server": "server.example.com",
|
||||||
|
"imap_port": 443,
|
||||||
|
"imap_security": MailAccount.ImapSecurity.SSL,
|
||||||
|
"username": "admin",
|
||||||
|
"password": "******",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
self.assertEqual(response.content.decode(), "Insufficient permissions")
|
||||||
|
|
||||||
|
def test_mail_account_test_existing_object_perms_only(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Existing account owned by another user
|
||||||
|
- User with an object level grant but no global change permission
|
||||||
|
WHEN:
|
||||||
|
- API call is made to test the account by id
|
||||||
|
THEN:
|
||||||
|
- API returns forbidden
|
||||||
|
"""
|
||||||
|
owner = User.objects.create_user(username="account_owner")
|
||||||
|
account = MailAccountFactory(
|
||||||
|
username="admin",
|
||||||
|
password="secret",
|
||||||
|
imap_server="server.example.com",
|
||||||
|
imap_port=443,
|
||||||
|
owner=owner,
|
||||||
|
)
|
||||||
|
user = User.objects.create_user(username="object_perms_only")
|
||||||
|
assign_perm("change_mailaccount", user, account)
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.ENDPOINT}test/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": account.pk,
|
||||||
|
"imap_server": "server.example.com",
|
||||||
|
"imap_port": 443,
|
||||||
|
"imap_security": MailAccount.ImapSecurity.SSL,
|
||||||
|
"username": "admin",
|
||||||
|
"password": "******",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
def test_mail_account_test_existing_nonexistent_id_forbidden(self) -> None:
|
def test_mail_account_test_existing_nonexistent_id_forbidden(self) -> None:
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f"{self.ENDPOINT}test/",
|
f"{self.ENDPOINT}test/",
|
||||||
|
|||||||
@@ -2195,7 +2195,9 @@ class TestMailAccountTestView(APITestCase):
|
|||||||
password="testpassword",
|
password="testpassword",
|
||||||
)
|
)
|
||||||
self.user.user_permissions.add(
|
self.user.user_permissions.add(
|
||||||
*Permission.objects.filter(codename__in=["add_mailaccount"]),
|
*Permission.objects.filter(
|
||||||
|
codename__in=["add_mailaccount", "change_mailaccount"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.user.save()
|
self.user.save()
|
||||||
self.client.force_authenticate(user=self.user)
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]):
|
|||||||
except (TypeError, ValueError, MailAccount.DoesNotExist):
|
except (TypeError, ValueError, MailAccount.DoesNotExist):
|
||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
if not has_perms_owner_aware(
|
if not request.user.has_perms(
|
||||||
|
["paperless_mail.change_mailaccount"],
|
||||||
|
) or not has_perms_owner_aware(
|
||||||
request.user,
|
request.user,
|
||||||
"change_mailaccount",
|
"change_mailaccount",
|
||||||
existing_account,
|
existing_account,
|
||||||
|
|||||||
Reference in New Issue
Block a user