Compare commits

..
Author SHA1 Message Date
shamoon 6380933b96 Note the large qty 2026-08-10 20:56:57 -07:00
shamoon 9de12ff1c5 Backoff/retry and tweak the error bubbling 2026-08-10 20:56:57 -07:00
shamoon d24fcb92b7 Show these in tasks 2026-08-10 20:56:56 -07:00
shamoon 4df2656623 Docs 2026-08-10 20:56:56 -07:00
shamoon dd5d93e84a Frontend AI suggestion workflow stuff 2026-08-10 20:56:56 -07:00
shamoon 013b8dbc32 Codex please write backend tests thx 2026-08-10 20:31:41 -07:00
shamoon c61ea2b398 Ok wire up the apply ai suggestions action to the wf 2026-08-10 20:31:41 -07:00
shamoon 9d673645b8 Ok the ai workflow module, actually does the thing 2026-08-10 20:31:41 -07:00
shamoon 4b1ca62349 Extract get_output_language for re-use 2026-08-10 20:31:41 -07:00
shamoon 93e6d25bb7 The object getters in matching need to accept none for workflows 2026-08-10 20:31:40 -07:00
shamoon 116cced6a6 Pull the ai workflow through serializer and check for required triggers 2026-08-10 20:31:40 -07:00
shamoon 1ce7d62b66 Model and migration 2026-08-10 20:31:40 -07:00
shamoon 44a822cb10 Actually, fix the action dropdown thing 2026-08-10 20:29:33 -07:00
shamoon 6df70e62f7 Fix dynamic action fields thing 2026-08-10 11:11:32 -07:00
shamoon c478d7bb5d And docs 2026-08-10 10:57:39 -07:00
shamoon ef54e0564a Frotnend workflow stuff 2026-08-10 10:57:35 -07:00
shamoon 67628401c8 Ok! Backend stuff for the remote ocr workflow 2026-08-10 10:43:26 -07:00
shamoon 5c96b38f4e Docs 2026-08-10 10:13:24 -07:00
shamoon 21ac856e3f Ok, frontend reprocess remote option 2026-08-10 10:13:23 -07:00
shamoon 37d6b02ebc Backend stuff for remote ocr reprocess, add to bulk edit pass in from ui settings 2026-08-10 10:13:23 -07:00
shamoon 7456b52e84 Update consumer.py 2026-08-10 10:13:18 -07:00
shamoon 6a392ea099 Ok, wire up the remote_ocr_mode with allow_remote for consumer 2026-08-10 10:13:18 -07:00
shamoon 2032ad1341 Docs for remote_ocr_mode 2026-08-10 10:13:18 -07:00
shamoon cc8fee91c4 More tests for remote_ocr_mode 2026-08-10 10:13:18 -07:00
shamoon a5d46a883e Update config.component.spec.ts 2026-08-10 10:13:18 -07:00
shamoon b0e1793093 Checks for remote_ocr_mode and fix import 2026-08-10 10:13:17 -07:00
shamoon 7e466d1f71 remote_ocr_mode config setting 2026-08-10 10:13:17 -07:00
shamoon 7b69a178c0 Add to parser dev docs 2026-08-10 10:13:17 -07:00
shamoon 22cd13a8a9 uses_remote_service + allow_remote to allow opt-in / out of remote OCR 2026-08-10 10:13:17 -07:00
shamoon 72a4676be0 Update test_tesseract_parser.py 2026-08-10 10:11:14 -07:00
shamoon 6673144d23 Docs 2026-08-10 10:11:13 -07:00
shamoon 994a84cf92 Frontend stuff, with sections 2026-08-10 10:11:13 -07:00
shamoon 654ce5d8f3 Backend tests 2026-08-10 10:11:13 -07:00
shamoon 5d5e9b6db4 Backend changes and migration for remote OCR Config 2026-08-10 10:11:13 -07:00
128 changed files with 3753 additions and 3647 deletions
-16
View File
@@ -299,8 +299,6 @@ optional arguments:
-sm, --split-manifest
-z, --zip
-zn, --zip-name
--zip-compression
--zip-compression-level
--data-only
--no-progress-bar
--passphrase
@@ -363,19 +361,6 @@ If `-z` or `--zip` is provided, the export will be a zip file
in the target directory, named according to the current local date or the
value set in `-zn` or `--zip-name`.
The compression method for the zip can be set with `--zip-compression`
(`stored`, `deflated` (default), `bzip2`, `lzma`, or `zstd`) and tuned with
`--zip-compression-level` (deflated: 09, bzip2: 19, zstd: -2222; ignored
for `stored` and `lzma`). Both options require `--zip`.
!!! warning
`zstd` compression requires Python 3.14 or newer on **both** the machine
creating the export and any machine importing it. An archive compressed with
`zstd` (or `lzma`/`bzip2` where those modules are unavailable) cannot be
imported on a runtime that lacks the codec; the importer will refuse it with
a clear error. The default `deflated` is universally readable.
If `--data-only` is provided, only the database will be exported. This option is intended
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
@@ -714,7 +699,6 @@ document_fuzzy_match [--ratio] [--processes N]
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
| --url | No | blank | If an instance URL is provided, the output table will show URLs to each documents instead of the document ID and name. |
!!! warning
+2 -1
View File
@@ -301,7 +301,8 @@ The following methods are supported:
- `delete`
- No `parameters` required
- `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`
- Requires `parameters`:
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
+16 -5
View File
@@ -948,11 +948,10 @@ for display in the web interface.
!!! note
The **remote OCR parser** (Azure AI) also honors this setting: when
no archive is requested (`never`, or `auto` with a born-digital PDF),
the remote engine is skipped entirely and locally-extracted text is
used instead, avoiding an unnecessary API call and a duplicate text
layer.
The **remote OCR parser** (Azure AI) always produces a searchable
PDF and stores it as the archive copy, regardless of this setting.
`ARCHIVE_FILE_GENERATION=never` has no effect when the remote
parser handles a document.
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
@@ -2048,6 +2047,18 @@ password. All of these options come from their similarly-named [Django settings]
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}
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
+14
View File
@@ -456,6 +456,20 @@ def score(
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**
```python
+4 -5
View File
@@ -187,11 +187,10 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
### Remote OCR parser
If you use the **remote OCR parser** (Azure AI), `ARCHIVE_FILE_GENERATION` is
honored the same way as for the local engine: when no archive is requested
(`never`, or `auto` with a born-digital PDF), the remote engine is skipped
entirely and locally-extracted text is used instead, avoiding an unnecessary
API call and a duplicate text layer.
If you use the **remote OCR parser** (Azure AI), note that it always produces a
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
has no effect for documents handled by the remote parser - the archive is produced
unconditionally by the remote engine.
## Search Index (Whoosh -> Tantivy)
@@ -1,428 +0,0 @@
# Split views.py and serialisers.py Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Split `src/documents/views.py` (5,395 lines) and `src/documents/serialisers.py` (3,532 lines) into domain-based module packages, with zero behavior change.
**Architecture:** Both files become packages (`documents/views/`, `documents/serialisers/`), one module per domain area. Serialisers split first (views depend on serialisers, never the reverse), then views, then the three external call sites (`paperless/urls.py`, `paperless_mail/views.py`, `paperless_mail/serialisers.py`) are pointed at the new submodules. No `__init__.py` re-exports in either package — every internal and external consumer imports the exact submodule.
**Tech Stack:** Django REST Framework (viewsets/serializers), ruff (lint/format), pytest via the project's VM test runner.
**Spec:** `docs/superpowers/specs/2026-08-13-views-serialisers-split-design.md`
## Global Constraints
- No behavior change: class/function bodies, names, and public API responses are unchanged — pure move/reorganize. (spec: Non-goals)
- Domain module names are identical across both packages (`bulk_edit.py` exists in both, etc.). (spec: Import direction)
- Import direction is one-way: `documents/views/*` may import from `documents/serialisers/*`; `documents/serialisers/*` must never import from `documents/views/*`. (spec: Import direction)
- Neither package's `__init__.py` re-exports submodule contents — every consumer, internal or external, imports the specific submodule (e.g. `from documents.views.workflows import WorkflowViewSet`). (spec: Architecture)
- `src/documents/tests/test_views.py` and `src/documents/tests/test_api_documents.py` are not modified — they must pass unchanged, proving the move didn't alter behavior. (spec: Non-goals, Testing)
- This branch targets `dev` and is separate from `feature-ai-taxonomy-hints-v2`. (spec: Non-goals)
- Backend tests run on the Linux VM via the helper script, never locally: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "<pytest targets>"`. `ruff check` / `ruff format` run locally (global binary, not `uv run ruff`).
---
## Reference: symbol-to-module maps
These tables (from the spec) are the authoritative source for which class/function goes to which new file. Copy them exactly — do not improvise groupings.
### `documents/serialisers/` map
| Module | Symbols |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `base.py` | `DynamicFieldsModelSerializer`, `DocumentUpdateFieldsModelSerializer`, `MatchingModelSerializer`, `SetPermissionsMixin`, `SerializerWithPerms`, `SetPermissionsSerializer`, `OwnedObjectSerializer`, `OwnedObjectListSerializer`, `ReadWriteSerializerMethodField`, `DocumentListSerializer`, `DocumentSelectionSerializer`, `SourceModeValidationMixin`, `BasicUserSerializer`, `NotesSerializer` |
| `metadata.py` | `CorrespondentSerializer`, `DocumentTypeSerializer`, `DeprecatedColors`, `ColorField`, `TagSerializer`, `CorrespondentField`, `TagsField`, `DocumentTypeField`, `StoragePathField`, `StoragePathSerializer`, `StoragePathTestSerializer`, `CustomFieldSerializer`, `CustomFieldInstanceSerializer`, `validate_documentlink_targets` |
| `documents.py` | `DocumentSerializer`, `SearchResultListSerializer`, `SearchResultSerializer`, `DuplicateDocumentSummarySerializer`, `_DocumentVersionInfo`, `DocumentVersionInfoSerializer`, `DocumentVersionSerializer`, `DocumentVersionLabelSerializer`, `_get_viewable_duplicates` |
| `upload.py` | `PostDocumentSerializer` |
| `saved_views.py` | `SavedViewFilterRuleSerializer`, `SavedViewSerializer` |
| `bulk_edit.py` | `RotateDocumentsSerializer`, `MergeDocumentsSerializer`, `EditPdfDocumentsSerializer`, `RemovePasswordDocumentsSerializer`, `DeleteDocumentsSerializer`, `ReprocessDocumentsSerializer`, `BulkEditSerializer`, `BulkDownloadSerializer`, `BulkEditObjectsSerializer` |
| `sharing.py` | `EmailSerializer`, `ShareLinkSerializer`, `ShareLinkBundleSerializer` |
| `tasks.py` | `TaskSerializerV10`, `TaskSerializerV9`, `TaskSummarySerializer`, `RunTaskSerializer`, `AcknowledgeTasksViewSerializer` |
| `workflows.py` | `WorkflowTriggerSerializer`, `WorkflowActionEmailSerializer`, `WorkflowActionWebhookSerializer`, `WorkflowActionSerializer`, `WorkflowSerializer` |
| `system.py` | `UiSettingsViewSerializer`, `TrashSerializer` |
Extraction order matters (later modules reference earlier ones): `base``metadata``documents``upload``saved_views``bulk_edit``sharing``tasks``workflows``system`.
### `documents/views/` map
| Module | Symbols |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `base.py` | `PassUserMixin`, `BulkPermissionMixin`, `PermissionsAwareDocumentCountMixin`, `DocumentSelectionMixin`, `DocumentOperationPermissionMixin`, `SearchParams`, `SearchResultPage`, `ResolvedRequestDocs`, `_get_tantivy_query_and_mode`, `_get_more_like_id`, `serve_file` |
| `index.py` | `IndexView`, `serve_logo` |
| `metadata.py` | `CorrespondentViewSet`, `TagViewSet`, `DocumentTypeViewSet`, `StoragePathViewSet`, `CustomFieldViewSet`, `_get_llm_output_language` |
| `documents.py` | `EmailDocumentDetailSchema`, `DocumentViewSet`, `UnifiedSearchViewSet` |
| `upload.py` | `PostDocumentView` |
| `chat.py` | `ChatStreamingSerializer`, `ChatStreamingView` |
| `search.py` | `SearchAutoCompleteView`, `GlobalSearchView`, `SelectionDataView`, `StatisticsView` |
| `bulk_edit.py` | `BulkEditView`, `RotateDocumentsView`, `MergeDocumentsView`, `DeleteDocumentsView`, `ReprocessDocumentsView`, `EditPdfDocumentsView`, `RemovePasswordDocumentsView`, `BulkEditObjectsView`, `BulkDownloadView` |
| `sharing.py` | `ShareLinkViewSet`, `ShareLinkBundleViewSet`, `SharedLinkView` |
| `saved_views.py` | `SavedViewViewSet` |
| `tasks.py` | `_TasksViewSetSchema`, `TasksViewSet` |
| `workflows.py` | `WorkflowTriggerViewSet`, `WorkflowActionViewSet`, `WorkflowViewSet` |
| `system.py` | `UiSettingsView`, `RemoteVersionView`, `SystemStatusView`, `TrashView` |
| `logs.py` | `LogViewSet` |
Extraction order: `base``index``metadata``documents``upload``chat``search``bulk_edit``sharing``saved_views``tasks``workflows``system``logs`. Note `ChatStreamingSerializer` is defined in `views.py` today, directly above `ChatStreamingView` — it moves with it into `views/chat.py`, not into the serialisers package.
### Mechanical extraction recipe (applies to every task below)
For each module being created:
1. `grep -n "^class |^def " src/documents/<serialisers|views>.py` to get current line numbers for every symbol still in the monolith (numbers shift as earlier modules are extracted, so re-run this each time, don't reuse stale numbers).
2. Create the new file. Start it by copying the **entire top-of-file import block** from the monolith verbatim, plus a relative `from .base import ...` line if the module isn't `base.py` itself.
3. Cut each listed symbol (including any decorators/comments immediately above it) from the monolith and paste it into the new file, preserving original order.
4. Remove the cut symbols from the monolith.
5. Run `ruff check --select F401,F811,F821 <new file> <monolith file>` and fix everything reported:
- `F401` (unused import) → delete the import line.
- `F821` (undefined name) → the symbol lives in a sibling module already extracted; add `from .<sibling> import <Symbol>`. If it hasn't been extracted yet, that's an ordering bug — stop and re-check the extraction order table.
- `F811` (redefinition) → duplicate import, delete one.
6. Run `ruff format <new file> <monolith file>`.
## Task 1: Scaffold `documents/serialisers/` and extract `base.py`
**Agent:** django-expert — **Model:** sonnet (mechanical extraction, but sets the foundation every later serialiser module imports from — get the base set right or every later task inherits the mistake)
**Files:**
- Create: `src/documents/serialisers/__init__.py` (empty — no re-exports, per Global Constraints)
- Create: `src/documents/serialisers/base.py`
- Modify: `src/documents/serialisers.py` (shrinks; stays in place as the monolith for the remaining tasks in this phase — it is only deleted in Task 2 once empty)
- Test: `src/documents/tests/` (full app suite), `src/paperless_mail/tests/`
**Interfaces:**
- Produces: `documents.serialisers.base` exporting `DynamicFieldsModelSerializer`, `DocumentUpdateFieldsModelSerializer`, `MatchingModelSerializer`, `SetPermissionsMixin`, `SerializerWithPerms`, `SetPermissionsSerializer`, `OwnedObjectSerializer`, `OwnedObjectListSerializer`, `ReadWriteSerializerMethodField`, `DocumentListSerializer`, `DocumentSelectionSerializer`, `SourceModeValidationMixin`, `BasicUserSerializer`, `NotesSerializer` — every later serialiser/view module that needs one of these imports `from documents.serialisers.base import <Symbol>`.
- [ ] **Step 1: Create the package directory and empty `__init__.py`**
```bash
mkdir -p src/documents/serialisers
touch src/documents/serialisers/__init__.py
```
- [ ] **Step 2: Extract `base.py` per the mechanical extraction recipe above**
Move exactly these 14 symbols (in their current relative order) out of `src/documents/serialisers.py` into `src/documents/serialisers/base.py`: `DynamicFieldsModelSerializer`, `DocumentUpdateFieldsModelSerializer`, `MatchingModelSerializer`, `SetPermissionsMixin`, `SerializerWithPerms`, `SetPermissionsSerializer`, `OwnedObjectSerializer`, `OwnedObjectListSerializer`, `ReadWriteSerializerMethodField`, `DocumentListSerializer`, `DocumentSelectionSerializer`, `SourceModeValidationMixin`, `BasicUserSerializer`, `NotesSerializer`.
Run the ruff fix-up (`ruff check --select F401,F811,F821 src/documents/serialisers/base.py src/documents/serialisers.py` then `ruff format` both files) as described in the recipe.
- [ ] **Step 3: Verify `documents.serialisers` (the monolith module, still at `src/documents/serialisers.py`) still imports cleanly and the app still boots**
Note: at this point Python resolves `documents.serialisers` to the package `src/documents/serialisers/__init__.py` (empty), **not** to `src/documents/serialisers.py` — having both a `serialisers.py` file and a `serialisers/` directory in the same parent package is invalid and Python will pick the package. So before running anything, rename the monolith out of the way so it's importable as a submodule of the new package for the rest of Phase A:
```bash
git mv src/documents/serialisers.py src/documents/serialisers/_monolith.py
```
Everywhere else in this phase, "the monolith file" now means `src/documents/serialisers/_monolith.py`. Because nothing outside this package imports the monolith directly by its old dotted path (`documents.serialisers` resolved to the file before; now it's the package), you must update every consumer of `documents.serialisers` symbols still owned by the monolith to import from `documents.serialisers._monolith` for the remainder of this phase. Concretely, in `src/documents/views.py`, change every `from documents.serialisers import <Symbol>` line for a symbol _not yet extracted_ (i.e., not one of the 14 `base.py` symbols) to `from documents.serialisers._monolith import <Symbol>`, and change the 14 now-extracted symbols' import lines to `from documents.serialisers.base import <Symbol>`. Do the same in `src/paperless_mail/serialisers.py` for `OwnedObjectSerializer` (→ `documents.serialisers.base`); its other three imports (`CorrespondentField`, `DocumentTypeField`, `TagsField`) stay pointed at `documents.serialisers._monolith` until Task 2 moves them into `metadata.py`.
This `_monolith` re-pointing is scaffolding only — Task 2 finishes emptying and deletes `_monolith.py`, and every import that currently says `._monolith` gets its final home then.
- [ ] **Step 4: Run the full test suite for this app boundary**
```bash
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests src/paperless_mail/tests -v"
```
Expected: PASS, no collection errors (a collection error here almost always means a missed import update in `views.py` or `paperless_mail/serialisers.py`).
- [ ] **Step 5: Commit**
```bash
git add src/documents/serialisers src/documents/views.py src/paperless_mail/serialisers.py
git commit -m "refactor: extract documents/serialisers/base.py from the serialisers monolith"
```
## Task 2: Extract the remaining 8 serialiser domain modules and delete the monolith
**Agent:** django-expert — **Model:** sonnet (repetitive but each of the 8 modules needs its own cross-reference check against `base.py` and previously-extracted siblings; DocumentSerializer in particular is large and central)
**Files:**
- Create: `src/documents/serialisers/metadata.py`, `src/documents/serialisers/documents.py`, `src/documents/serialisers/upload.py`, `src/documents/serialisers/saved_views.py`, `src/documents/serialisers/bulk_edit.py`, `src/documents/serialisers/sharing.py`, `src/documents/serialisers/tasks.py`, `src/documents/serialisers/workflows.py`, `src/documents/serialisers/system.py`
- Delete: `src/documents/serialisers/_monolith.py` (once empty)
- Modify: `src/documents/views.py` (finish re-pointing every `from documents.serialisers._monolith import X` line at the correct new submodule), `src/paperless_mail/serialisers.py` (re-point `CorrespondentField`, `DocumentTypeField`, `TagsField` at `documents.serialisers.metadata`)
- Test: `src/documents/tests/` (full app suite), `src/paperless_mail/tests/`
**Interfaces:**
- Consumes: `documents.serialisers.base` from Task 1 (relative import `.base` within the package).
- Produces: the full `documents/serialisers/` package as specified in the Reference map above — this is what Task 3/4 (views split) and Task 5 (external call sites) import from.
- [ ] **Step 1: Extract the 8 remaining domain modules in order**
Following the mechanical extraction recipe, and in this exact order (each may depend on symbols extracted earlier in this same order, plus anything in `base.py`):
1. `metadata.py``CorrespondentSerializer`, `DocumentTypeSerializer`, `DeprecatedColors`, `ColorField`, `TagSerializer`, `CorrespondentField`, `TagsField`, `DocumentTypeField`, `StoragePathField`, `StoragePathSerializer`, `StoragePathTestSerializer`, `CustomFieldSerializer`, `CustomFieldInstanceSerializer`, `validate_documentlink_targets`
2. `documents.py``DocumentSerializer`, `SearchResultListSerializer`, `SearchResultSerializer`, `DuplicateDocumentSummarySerializer`, `_DocumentVersionInfo`, `DocumentVersionInfoSerializer`, `DocumentVersionSerializer`, `DocumentVersionLabelSerializer`, `_get_viewable_duplicates`
3. `upload.py``PostDocumentSerializer`
4. `saved_views.py``SavedViewFilterRuleSerializer`, `SavedViewSerializer`
5. `bulk_edit.py``RotateDocumentsSerializer`, `MergeDocumentsSerializer`, `EditPdfDocumentsSerializer`, `RemovePasswordDocumentsSerializer`, `DeleteDocumentsSerializer`, `ReprocessDocumentsSerializer`, `BulkEditSerializer`, `BulkDownloadSerializer`, `BulkEditObjectsSerializer`
6. `sharing.py``EmailSerializer`, `ShareLinkSerializer`, `ShareLinkBundleSerializer`
7. `tasks.py``TaskSerializerV10`, `TaskSerializerV9`, `TaskSummarySerializer`, `RunTaskSerializer`, `AcknowledgeTasksViewSerializer`
8. `workflows.py``WorkflowTriggerSerializer`, `WorkflowActionEmailSerializer`, `WorkflowActionWebhookSerializer`, `WorkflowActionSerializer`, `WorkflowSerializer`
9. `system.py``UiSettingsViewSerializer`, `TrashSerializer`
After each individual module extraction, run the ruff fix-up from the recipe against that new file and `_monolith.py` before moving to the next module (don't batch all 8 and fix imports once at the end — F821 errors compound and get harder to attribute to the right module).
- [ ] **Step 2: Confirm the monolith is empty and delete it**
```bash
grep -n "^class |^def " src/documents/serialisers/_monolith.py
```
Expected: no output. If anything remains, it wasn't in the Reference map — stop and reconcile with the spec rather than deleting a symbol.
```bash
git rm src/documents/serialisers/_monolith.py
```
- [ ] **Step 3: Re-point every remaining `._monolith` import**
Search for any import left pointing at the now-deleted module:
```bash
grep -rn "serialisers\._monolith\|serialisers/_monolith" src/
```
Expected: no output. Fix any that remain by pointing them at the correct submodule per the Reference map (e.g. `from documents.serialisers._monolith import DocumentSerializer``from documents.serialisers.documents import DocumentSerializer`).
- [ ] **Step 4: Update `paperless_mail/serialisers.py`'s remaining imports**
```python
# was: from documents.serialisers import CorrespondentField, DocumentTypeField, OwnedObjectSerializer, TagsField
from documents.serialisers.base import OwnedObjectSerializer
from documents.serialisers.metadata import CorrespondentField, DocumentTypeField, TagsField
```
- [ ] **Step 5: Ruff and full test suite**
```bash
ruff check src/documents/serialisers src/documents/views.py src/paperless_mail/serialisers.py
ruff format src/documents/serialisers src/documents/views.py src/paperless_mail/serialisers.py
```
```bash
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests src/paperless_mail/tests -v"
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add src/documents/serialisers src/documents/views.py src/paperless_mail/serialisers.py
git commit -m "refactor: finish splitting serialisers.py into documents/serialisers/"
```
## Task 3: Scaffold `documents/views/` and extract `base.py`
**Agent:** django-expert — **Model:** sonnet (same shape as Task 1, one level up — views/base.py is imported by every other view module)
**Files:**
- Create: `src/documents/views/__init__.py` (empty), `src/documents/views/base.py`
- Modify: `src/documents/views.py``src/documents/views/_monolith.py` (renamed, same reasoning as Task 1 Step 3)
- Modify: `src/paperless/urls.py`, `src/paperless_mail/views.py` (re-point the 1 symbol each currently pulls from `documents.views` that now lives in `base.py`, if any — see step 3)
- Test: `src/documents/tests/` (full app suite, includes URL-resolution-dependent tests), `src/paperless_mail/tests/`
**Interfaces:**
- Consumes: `documents.serialisers.*` submodules from Tasks 12 (already at final locations — import these directly, e.g. `from documents.serialisers.documents import DocumentSerializer`, never through a monolith or shim).
- Produces: `documents.views.base` exporting `PassUserMixin`, `BulkPermissionMixin`, `PermissionsAwareDocumentCountMixin`, `DocumentSelectionMixin`, `DocumentOperationPermissionMixin`, `SearchParams`, `SearchResultPage`, `ResolvedRequestDocs`, `_get_tantivy_query_and_mode`, `_get_more_like_id`, `serve_file`.
- [ ] **Step 1: Create the package directory, empty `__init__.py`, and rename the monolith**
```bash
mkdir -p src/documents/views
touch src/documents/views/__init__.py
git mv src/documents/views.py src/documents/views/_monolith.py
```
- [ ] **Step 2: Extract `base.py` per the mechanical extraction recipe**
Move exactly these 11 symbols out of `_monolith.py` into `views/base.py`: `PassUserMixin`, `BulkPermissionMixin`, `PermissionsAwareDocumentCountMixin`, `DocumentSelectionMixin`, `DocumentOperationPermissionMixin`, `SearchParams`, `SearchResultPage`, `ResolvedRequestDocs`, `_get_tantivy_query_and_mode`, `_get_more_like_id`, `serve_file`.
Within `_monolith.py`, every reference to these 11 symbols needs `from .base import <Symbol>` added (they're used throughout the rest of the file by the not-yet-extracted viewsets).
- [ ] **Step 3: Re-point external consumers of the now-moved symbol**
```bash
grep -n "from documents.views import PassUserMixin" src/paperless_mail/views.py
```
Update it to `from documents.views.base import PassUserMixin`.
`paperless/urls.py` doesn't import any of the 11 `base.py` symbols directly (it only imports viewsets/views, which are all still in `_monolith.py` at this point) — confirm with:
```bash
grep -nE "from documents\.views import (PassUserMixin|BulkPermissionMixin|PermissionsAwareDocumentCountMixin|DocumentSelectionMixin|DocumentOperationPermissionMixin|serve_file)" src/paperless/urls.py
```
Expected: no output. If something does match, re-point it at `documents.views.base` the same way.
- [ ] **Step 4: Ruff and test**
```bash
ruff check src/documents/views src/paperless_mail/views.py
ruff format src/documents/views src/paperless_mail/views.py
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests src/paperless_mail/tests -v"
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/documents/views src/paperless_mail/views.py
git commit -m "refactor: extract documents/views/base.py from the views monolith"
```
## Task 4: Extract the remaining 13 view domain modules and delete the monolith
**Agent:** django-expert — **Model:** opus (highest blast radius in the plan — `DocumentViewSet` alone is ~1,300 lines and central to the whole API; this task also rewires `paperless/urls.py`'s ~34 import lines that drive URL routing for the entire backend, where a mistake breaks the app at startup, not just in one test)
**Files:**
- Create: `src/documents/views/index.py`, `src/documents/views/metadata.py`, `src/documents/views/documents.py`, `src/documents/views/upload.py`, `src/documents/views/chat.py`, `src/documents/views/search.py`, `src/documents/views/bulk_edit.py`, `src/documents/views/sharing.py`, `src/documents/views/saved_views.py`, `src/documents/views/tasks.py`, `src/documents/views/workflows.py`, `src/documents/views/system.py`, `src/documents/views/logs.py`
- Delete: `src/documents/views/_monolith.py` (once empty)
- Modify: `src/paperless/urls.py` (all ~34 `from documents.views import X` lines)
- Test: `src/documents/tests/` (full app suite — includes `test_views.py`, `test_api_documents.py`), `src/paperless_mail/tests/`
**Interfaces:**
- Consumes: `documents.serialisers.*` (Tasks 12) and `documents.views.base` (Task 3).
- Produces: the full `documents/views/` package as specified in the Reference map above.
- [ ] **Step 1: Extract the 13 remaining domain modules in order**
Following the mechanical extraction recipe, in this exact order:
1. `index.py``IndexView`, `serve_logo`
2. `metadata.py``CorrespondentViewSet`, `TagViewSet`, `DocumentTypeViewSet`, `StoragePathViewSet`, `CustomFieldViewSet`, `_get_llm_output_language`
3. `documents.py``EmailDocumentDetailSchema`, `DocumentViewSet`, `UnifiedSearchViewSet`
4. `upload.py``PostDocumentView`
5. `chat.py``ChatStreamingSerializer`, `ChatStreamingView`
6. `search.py``SearchAutoCompleteView`, `GlobalSearchView`, `SelectionDataView`, `StatisticsView`
7. `bulk_edit.py``BulkEditView`, `RotateDocumentsView`, `MergeDocumentsView`, `DeleteDocumentsView`, `ReprocessDocumentsView`, `EditPdfDocumentsView`, `RemovePasswordDocumentsView`, `BulkEditObjectsView`, `BulkDownloadView`
8. `sharing.py``ShareLinkViewSet`, `ShareLinkBundleViewSet`, `SharedLinkView`
9. `saved_views.py``SavedViewViewSet`
10. `tasks.py``_TasksViewSetSchema`, `TasksViewSet`
11. `workflows.py``WorkflowTriggerViewSet`, `WorkflowActionViewSet`, `WorkflowViewSet`
12. `system.py``UiSettingsView`, `RemoteVersionView`, `SystemStatusView`, `TrashView`
13. `logs.py``LogViewSet`
After each module, run the ruff fix-up from the recipe before continuing to the next (same rationale as Task 2 Step 1 — attribute F821s to the right module while context is fresh). `documents.py` is the biggest single extraction in this whole plan (`DocumentViewSet` is ~1,300 lines) — expect the most F821 fix-ups here, mostly resolved by adding `from documents.serialisers.documents import ...`, `from documents.serialisers.metadata import ...`, and `from .base import ...` as needed.
- [ ] **Step 2: Confirm the monolith is empty and delete it**
```bash
grep -n "^class |^def " src/documents/views/_monolith.py
```
Expected: no output.
```bash
git rm src/documents/views/_monolith.py
```
- [ ] **Step 3: Re-point every remaining `._monolith` import**
```bash
grep -rn "views\._monolith\|views/_monolith" src/
```
Expected: no output. Fix any stragglers per the Reference map.
- [ ] **Step 4: Update `paperless/urls.py`**
Replace each of the ~34 `from documents.views import X` lines with `from documents.views.<domain> import X` per the Reference map. For example:
```python
# was:
from documents.views import CorrespondentViewSet
from documents.views import WorkflowViewSet
from documents.views import serve_logo
# becomes:
from documents.views.metadata import CorrespondentViewSet
from documents.views.workflows import WorkflowViewSet
from documents.views.index import serve_logo
```
Do this for every import in that block — check off against the full symbol list in the Reference map above so none are missed.
- [ ] **Step 5: Ruff and test**
```bash
ruff check src/documents/views src/paperless/urls.py
ruff format src/documents/views src/paperless/urls.py
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests src/paperless_mail/tests -v"
```
Expected: PASS, including `test_views.py` and `test_api_documents.py` — these exercise URL routing end-to-end, so a broken `urls.py` import shows up here as a collection error.
- [ ] **Step 6: Commit**
```bash
git add src/documents/views src/paperless/urls.py
git commit -m "refactor: finish splitting views.py into documents/views/"
```
## Task 5: Repo-wide verification sweep
**Agent:** general-purpose — **Model:** sonnet (an audit/verification pass: run targeted checks, read the output, fix anything found — moderate judgment, not novel design work)
**Files:**
- Modify: any file a grep in this task turns up beyond the ones already handled in Tasks 14 (expected: none, per the spec's stated blast radius of exactly `paperless/urls.py`, `paperless_mail/views.py`, `paperless_mail/serialisers.py` — this task exists to confirm that, not to find new work)
- Test: full backend suite (all apps, not just `documents`/`paperless_mail`)
**Interfaces:**
- Consumes: the finished `documents/views/` and `documents/serialisers/` packages from Tasks 14.
- [ ] **Step 1: Grep the whole repo for any remaining bare-module reference**
```bash
grep -rn "from documents\.views import\|from documents\.serialisers import\|documents\.views\.\_monolith\|documents\.serialisers\.\_monolith\|import documents\.views$\|import documents\.serialisers$" src/
```
Expected: no output. `documents/views/__init__.py` and `documents/serialisers/__init__.py` should still be empty (`0` bytes or a single blank line) — confirm with:
```bash
wc -l src/documents/views/__init__.py src/documents/serialisers/__init__.py
```
- [ ] **Step 2: Confirm import direction was never violated**
```bash
grep -rln "from documents\.views" src/documents/serialisers/
```
Expected: no output (no file in `serialisers/` imports from `views/`).
- [ ] **Step 3: Full ruff pass**
```bash
ruff check src/documents/views src/documents/serialisers src/paperless/urls.py src/paperless_mail/views.py src/paperless_mail/serialisers.py
ruff format --check src/documents/views src/documents/serialisers src/paperless/urls.py src/paperless_mail/views.py src/paperless_mail/serialisers.py
```
Expected: clean.
- [ ] **Step 4: Full backend test suite**
```bash
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "-v"
```
(No path filter — this runs the whole backend suite, confirming nothing outside `documents`/`paperless_mail` was quietly relying on the old module shape, e.g. a management command or a script under `scripts/`.)
Expected: PASS.
- [ ] **Step 5: If Steps 14 found nothing to fix, commit is a no-op — skip it. If they found strays, fix and commit**
```bash
git add -A
git commit -m "refactor: fix stray documents.views/serialisers references found in repo sweep"
```
@@ -1,158 +0,0 @@
# Split `documents/views.py` and `documents/serialisers.py` into modules
## Problem
`src/documents/views.py` (5,395 lines) and `src/documents/serialisers.py`
(3,532 lines) have grown into monolithic files covering every REST resource
in the `documents` app: correspondents, tags, document types, storage paths,
custom fields, the core document viewset and search, chat, bulk-edit
operations, sharing, saved views, tasks, workflows, and system/UI settings.
Their size makes them hard to navigate, hard to review incrementally, and
increases the chance of unrelated changes colliding in the same file.
This document specifies splitting both files into packages, one module per
domain area, with no behavior change.
## Non-goals
- No behavior change. Class names, method bodies, and public API responses
are unchanged — this is a pure move/reorganize.
- No change to `test_views.py` or `test_api_documents.py`. They exercise the
moved classes via imports or via the live API; class names and behavior
don't change, so they need no edits. Splitting those test files is a
separate, later task if desired.
- No change to the frontend, migrations, or any other app beyond the three
files that import from `documents.views` / `documents.serialisers`
(`paperless/urls.py`, `paperless_mail/views.py`,
`paperless_mail/serialisers.py`).
- This work happens as its own branch/PR against `dev`, after the in-flight
`feature-ai-taxonomy-hints-v2` work merges — not layered on top of it.
## Architecture
`documents/views.py` becomes the package `documents/views/`, and
`documents/serialisers.py` becomes `documents/serialisers/`. Each gets one
module per domain area (table below). Neither package's `__init__.py`
re-exports its submodules' contents — it stays empty (or a short docstring
only). The three external call sites that currently do
`from documents.views import X` / `from documents.serialisers import X` are
updated to import from the specific submodule instead
(`from documents.views.workflows import WorkflowViewSet`, etc.). This avoids
adding an indirection layer that could quietly regrow into a second dumping
ground, at the cost of touching those three files.
### Import direction
`views/*` modules may import from `serialisers/*` modules; `serialisers/*`
modules never import from `views/*`. This keeps the dependency graph acyclic
by construction — there is no case in the current code where a serializer
needs a view.
Domain module names are the same across both packages (e.g. `bulk_edit.py`
exists in both), which makes the natural import `from documents.serialisers.bulk_edit import BulkEditSerializer`
inside `documents/views/bulk_edit.py` easy to find, but a view is free to
import a serializer from a different domain module when needed (e.g. a
`documents.py` view using a `metadata.py` field serializer) — that's a plain
cross-module import, not a cycle risk, since the reverse direction never
happens.
## Module breakdown — `documents/views/`
| Module | Contents |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `base.py` | Shared mixins/helpers: `PassUserMixin`, `BulkPermissionMixin`, `PermissionsAwareDocumentCountMixin`, `DocumentSelectionMixin`, `DocumentOperationPermissionMixin`, `SearchParams`/`SearchResultPage`/`ResolvedRequestDocs`, `_get_tantivy_query_and_mode`, `_get_more_like_id`, `serve_file` |
| `index.py` | `IndexView`, `serve_logo` |
| `metadata.py` | `CorrespondentViewSet`, `TagViewSet`, `DocumentTypeViewSet`, `StoragePathViewSet`, `CustomFieldViewSet`, `_get_llm_output_language` |
| `documents.py` | `EmailDocumentDetailSchema`, `DocumentViewSet`, `UnifiedSearchViewSet` |
| `upload.py` | `PostDocumentView` |
| `chat.py` | `ChatStreamingSerializer`, `ChatStreamingView` |
| `search.py` | `SearchAutoCompleteView`, `GlobalSearchView`, `SelectionDataView`, `StatisticsView` |
| `bulk_edit.py` | `BulkEditView`, `RotateDocumentsView`, `MergeDocumentsView`, `DeleteDocumentsView`, `ReprocessDocumentsView`, `EditPdfDocumentsView`, `RemovePasswordDocumentsView`, `BulkEditObjectsView`, `BulkDownloadView` |
| `sharing.py` | `ShareLinkViewSet`, `ShareLinkBundleViewSet`, `SharedLinkView` |
| `saved_views.py` | `SavedViewViewSet` |
| `tasks.py` | `_TasksViewSetSchema`, `TasksViewSet` |
| `workflows.py` | `WorkflowTriggerViewSet`, `WorkflowActionViewSet`, `WorkflowViewSet` |
| `system.py` | `UiSettingsView`, `RemoteVersionView`, `SystemStatusView`, `TrashView` |
| `logs.py` | `LogViewSet` |
`documents.py` remains the largest module at roughly 1,600 lines
(`DocumentViewSet` alone is ~1,300 lines in the current file); every other
module is well under 500 lines.
## Module breakdown — `documents/serialisers/`
| Module | Contents |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `base.py` | `DynamicFieldsModelSerializer`, `DocumentUpdateFieldsModelSerializer`, `MatchingModelSerializer`, `SetPermissionsMixin`, `SerializerWithPerms`, `SetPermissionsSerializer`, `OwnedObjectSerializer`, `OwnedObjectListSerializer`, `ReadWriteSerializerMethodField`, `DocumentListSerializer`, `DocumentSelectionSerializer`, `SourceModeValidationMixin`, `BasicUserSerializer`, `NotesSerializer` |
| `metadata.py` | `CorrespondentSerializer`, `DocumentTypeSerializer`, `DeprecatedColors`, `ColorField`, `TagSerializer`, `CorrespondentField`, `TagsField`, `DocumentTypeField`, `StoragePathField`, `StoragePathSerializer`, `StoragePathTestSerializer`, `CustomFieldSerializer`, `CustomFieldInstanceSerializer`, `validate_documentlink_targets` |
| `documents.py` | `DocumentSerializer`, `SearchResultListSerializer`, `SearchResultSerializer`, `DuplicateDocumentSummarySerializer`, `_DocumentVersionInfo`, `DocumentVersionInfoSerializer`, `DocumentVersionSerializer`, `DocumentVersionLabelSerializer`, `_get_viewable_duplicates` |
| `upload.py` | `PostDocumentSerializer` |
| `saved_views.py` | `SavedViewFilterRuleSerializer`, `SavedViewSerializer` |
| `bulk_edit.py` | `RotateDocumentsSerializer`, `MergeDocumentsSerializer`, `EditPdfDocumentsSerializer`, `RemovePasswordDocumentsSerializer`, `DeleteDocumentsSerializer`, `ReprocessDocumentsSerializer`, `BulkEditSerializer`, `BulkDownloadSerializer`, `BulkEditObjectsSerializer` |
| `sharing.py` | `EmailSerializer`, `ShareLinkSerializer`, `ShareLinkBundleSerializer` |
| `tasks.py` | `TaskSerializerV10`, `TaskSerializerV9`, `TaskSummarySerializer`, `RunTaskSerializer`, `AcknowledgeTasksViewSerializer` |
| `workflows.py` | `WorkflowTriggerSerializer`, `WorkflowActionEmailSerializer`, `WorkflowActionWebhookSerializer`, `WorkflowActionSerializer`, `WorkflowSerializer` |
| `system.py` | `UiSettingsViewSerializer`, `TrashSerializer` |
Note: `ChatStreamingSerializer` is defined in `views.py` today (not
`serialisers.py`), directly above `ChatStreamingView`. It moves with
`ChatStreamingView` into `documents/views/chat.py` rather than into the
serialisers package, preserving its current co-location.
## External call sites to update
Only three files import from these two modules today, and all move to
importing from the specific new submodule:
- `src/paperless/urls.py` — ~34 `from documents.views import X` lines, one
per viewset/view used in URL routing. Each becomes
`from documents.views.<domain> import X`.
- `src/paperless_mail/views.py``from documents.views import PassUserMixin`
becomes `from documents.views.base import PassUserMixin`.
- `src/paperless_mail/serialisers.py``CorrespondentField`,
`DocumentTypeField`, `OwnedObjectSerializer`, `TagsField` move to
`from documents.serialisers.metadata import CorrespondentField, DocumentTypeField, TagsField`
and `from documents.serialisers.base import OwnedObjectSerializer`.
## Migration order
1. Split `serialisers.py` into `documents/serialisers/` first — serializers
have no dependency on views, so this half can be verified in isolation.
Run the full backend test suite after this step.
2. Split `views.py` into `documents/views/`, importing from the new
`documents/serialisers/*` modules per the table above. Run the full
backend test suite.
3. Update the three external call sites (`paperless/urls.py`,
`paperless_mail/views.py`, `paperless_mail/serialisers.py`).
4. Run `ruff check` / `ruff format` and the full backend test suite once
more end to end.
Splitting serialisers before views (rather than in parallel) means step 2
can immediately import finished, correctly-located serializer modules
instead of guessing at not-yet-final paths.
## Risks / error handling
- **Circular imports**: prevented by construction (serialisers never import
from views — see Import direction above). If a genuine cross-domain need
is discovered during implementation that seems to require a
views→views import cycle (e.g. `UnifiedSearchViewSet` extending
`DocumentViewSet` from a different module — both already live in
`documents.py` so this doesn't arise), resolve it by moving the shared
piece to `base.py` rather than introducing a cycle.
- **Missed re-export consumers**: verified via a full-repo grep for
`from documents.views import` / `from documents.serialisers import` /
`documents.views.` / `documents.serialisers.` before considering the split
complete, in case something beyond the three known call sites appears
(e.g. in a management command or a rarely-run script).
- **Silent behavior drift during move**: since this is a pure reorganization,
the full test suite passing after each step (rather than only at the end)
is the primary safety net; no new tests are required for this refactor
itself.
## Testing
No new tests. Existing coverage (`test_views.py`, `test_api_documents.py`,
and the rest of the `documents` test suite) is run after each migration step
per the ordering above, and must pass unchanged — a failure indicates the
move altered behavior, not that new coverage is needed.
+52 -4
View File
@@ -576,9 +576,7 @@ The following workflow action types are available:
- Tags, correspondent, document type and storage path
- Document owner
- View and / or edit permissions to users or groups
- Custom fields, optionally with a value. If no value is set, the field is only added to the
document and any value it may already have is left untouched. If a value is set, it will
overwrite an existing value of that field on the document.
- Custom fields. Note that no value for the field will be set
##### Removal {#workflow-action-removal}
@@ -650,6 +648,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
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
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
@@ -1086,11 +1126,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).
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
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
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
Paperless-ngx consists of the following components:
+152 -544
View File
File diff suppressed because it is too large Load Diff
@@ -14,43 +14,48 @@
<a ngbNavLink>{{category}}</a>
<ng-template ngbNavContent>
<div class="p-3">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
@for (option of getCategoryOptions(category); track option.key) {
<div class="col">
<div class="card bg-light">
<div class="card-body">
<div class="card-title d-flex align-items-center">
<h6 class="mb-0">
{{option.title}}
</h6>
<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 name="info-circle"></i-bs>
</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>
@for (section of getCategorySections(category); track section) {
@if (section) {
<h5 class="mt-4 mb-3">{{section}}</h5>
}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
@for (option of getCategoryOptions(category, section); track option.key) {
<div class="col">
<div class="card bg-light">
<div class="card-body">
<div class="card-title d-flex align-items-center">
<h6 class="mb-0">
{{option.title}}
</h6>
<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 name="info-circle"></i-bs>
</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 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>
</ng-template>
</li>
@@ -8,7 +8,11 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
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 { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -158,4 +162,24 @@ describe('ConfigComponent', () => {
component.resetOption('barcodes_enabled')
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)
}
getCategoryOptions(category: string): ConfigOption[] {
return PaperlessConfigOptions.filter((o) => o.category === category)
getCategorySections(category: string): string[] {
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
@@ -576,7 +576,7 @@ describe('TasksComponent', () => {
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
expect(modal.componentInstance.buttonsEnabled()).toBe(true)
expect(modal.componentInstance.buttonsEnabled).toBe(true)
expect(component.selectedTasks.size).toBe(0)
})
@@ -642,7 +642,7 @@ describe('TasksComponent', () => {
expect(dismissSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
expect(modal.componentInstance.buttonsEnabled()).toBe(true)
expect(modal.componentInstance.buttonsEnabled).toBe(true)
})
it('should dismiss the currently visible scoped and filtered tasks', () => {
@@ -316,7 +316,7 @@ export class TasksComponent
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
this.tasksService.dismissTasks(tasks).subscribe({
next: () => {
@@ -324,7 +324,7 @@ export class TasksComponent
},
error: (e) => {
this.toastService.showError($localize`Error dismissing tasks`, e)
modal.componentInstance.buttonsEnabled.set(true)
modal.componentInstance.buttonsEnabled = true
},
})
this.clearSelection()
@@ -350,7 +350,7 @@ export class TasksComponent
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
this.tasksService.dismissAllTasks().subscribe({
next: () => {
@@ -358,7 +358,7 @@ export class TasksComponent
},
error: (e) => {
this.toastService.showError($localize`Error dismissing tasks`, e)
modal.componentInstance.buttonsEnabled.set(true)
modal.componentInstance.buttonsEnabled = true
},
})
this.clearSelection()
@@ -82,7 +82,7 @@ export class TrashComponent
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.trashService.emptyTrash([document.id]).subscribe({
next: () => {
this.toastService.showInfo(
@@ -146,7 +146,7 @@ export class UsersAndGroupsComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.usersService.delete(user).subscribe({
next: () => {
modal.close()
@@ -199,7 +199,7 @@ export class UsersAndGroupsComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.groupsService.delete(group).subscribe({
next: () => {
modal.close()
@@ -111,7 +111,7 @@
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
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="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) {
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
}
@@ -47,12 +47,11 @@
.search-container {
max-height: 4.5rem;
overflow: visible;
overflow: hidden;
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
&.mobile-hidden {
max-height: 0;
overflow: hidden;
opacity: 0;
padding-top: 0 !important;
padding-bottom: 0 !important;
@@ -12,10 +12,10 @@
}
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
<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()">
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
<span>
{{btnCaption}}
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
@@ -25,7 +25,7 @@
}
</button>
@if (alternativeBtnCaption) {
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled()">
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled">
{{alternativeBtnCaption}}
</button>
}
@@ -64,22 +64,6 @@ describe('ConfirmDialogComponent', () => {
expect(confirmSubjectResult).toBeTruthy()
})
it('should re-render the buttons when they are toggled from outside', async () => {
const confirmButton: HTMLButtonElement =
fixture.nativeElement.querySelectorAll('.modal-footer button')[1]
expect(confirmButton.disabled).toBeFalsy()
// Deliberately no detectChanges: a request callback toggling this is all
// that happens, and nothing else schedules a render for the modal
component.buttonsEnabled.set(false)
await fixture.whenStable()
expect(confirmButton.disabled).toBeTruthy()
component.buttonsEnabled.set(true)
await fixture.whenStable()
expect(confirmButton.disabled).toBeFalsy()
})
it('should support cancel & close modal', () => {
let confirmSubjectResult
const closeModalSpy = jest.spyOn(modal, 'close')
@@ -1,12 +1,5 @@
import { DecimalPipe } from '@angular/common'
import {
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject } from 'rxjs'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@@ -53,7 +46,8 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
@Input()
cancelBtnCaption = $localize`Cancel`
readonly buttonsEnabled = signal(true)
@Input()
buttonsEnabled = true
confirmButtonEnabled = true
alternativeButtonEnabled = true
@@ -56,10 +56,10 @@
}
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
<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()">
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
@@ -57,7 +57,7 @@
class="btn"
[class]="cancelBtnClass"
(click)="cancel()"
[disabled]="!buttonsEnabled()"
[disabled]="!buttonsEnabled"
>
<span class="d-inline-block" style="padding-bottom: 1px;">
{{cancelBtnCaption}}
@@ -68,7 +68,7 @@
class="btn"
[class]="btnClass"
(click)="confirm()"
[disabled]="!confirmButtonEnabled || !buttonsEnabled()"
[disabled]="!confirmButtonEnabled || !buttonsEnabled"
>
{{btnCaption}}
</button>
@@ -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>
@@ -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()
})
})
@@ -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
}
}
@@ -34,10 +34,10 @@
<p class="mb-0 small"><b>{{messageBold}}</b></p>
}
</div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
<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() || degrees === 0">
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled || degrees === 0">
{{btnCaption}}
@if (!confirmButtonEnabled) {
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
@@ -455,6 +455,52 @@
</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>
</ng-template>
@@ -22,6 +22,7 @@ import {
} from 'src/app/data/matching-model'
import { Workflow } from 'src/app/data/workflow'
import {
AISuggestionField,
WorkflowAction,
WorkflowActionType,
} from 'src/app/data/workflow-action'
@@ -29,6 +30,7 @@ import {
DocumentSource,
WorkflowTriggerType,
} 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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
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 { EditDialogMode } from '../edit-dialog.component'
import {
AI_SUGGESTION_FIELD_OPTIONS,
DOCUMENT_SOURCE_OPTIONS,
SCHEDULE_DATE_FIELD_OPTIONS,
TriggerFilterType,
@@ -224,7 +227,12 @@ describe('WorkflowEditDialogComponent', () => {
).toEqual('Document Added')
expect(component.getTriggerTypeOptionName(null)).toEqual('')
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(
component.getActionTypeOptionName(WorkflowActionType.Assignment)
).toEqual('Assignment')
@@ -233,14 +241,231 @@ describe('WorkflowEditDialogComponent', () => {
SCHEDULE_DATE_FIELD_OPTIONS
)
// Email disabled
// Email, remote OCR and AI all disabled
jest.spyOn(settingsService, 'get').mockReturnValue(false)
component.ngOnInit()
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', () => {
component.object = workflow
component.addTrigger()
@@ -30,6 +30,7 @@ import { StoragePath } from 'src/app/data/storage-path'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { Workflow } from 'src/app/data/workflow'
import {
AISuggestionField,
WorkflowAction,
WorkflowActionType,
} from 'src/app/data/workflow-action'
@@ -148,6 +149,41 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.MoveToTrash,
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 {
@@ -504,8 +540,6 @@ export class WorkflowEditDialogComponent
expandedItem: number = null
readonly allowedActionTypes = signal([])
private readonly triggerFilterOptionsMap = new WeakMap<
FormArray,
TriggerFilterOption[]
@@ -548,13 +582,58 @@ export class WorkflowEditDialogComponent
this.checkRemovalActionFields.bind(this)
)
this.checkRemovalActionFields(this.objectForm.value)
this.allowedActionTypes.set(
this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)
? WORKFLOW_ACTION_OPTIONS
: WORKFLOW_ACTION_OPTIONS.filter(
(a) => a.id !== WorkflowActionType.Email
)
)
}
private allowedActionTypes: typeof WORKFLOW_ACTION_OPTIONS = null
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) {
@@ -1198,6 +1277,11 @@ export class WorkflowEditDialogComponent
passwords: new FormControl(
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 }
)
@@ -1279,13 +1363,18 @@ export class WorkflowEditDialogComponent
get actionTypeOptions() {
this.settingsService.trackChanges()
return this.allowedActionTypes()
// Computed on read rather than cached
return this.getAllowedActionTypes()
}
getActionTypeOptionName(type: WorkflowActionType): string {
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
}
get aiSuggestionFieldOptions() {
return AI_SUGGESTION_FIELD_OPTIONS
}
addAction() {
if (!this.object) {
this.object = Object.assign({}, this.objectForm.value)
@@ -1339,6 +1428,9 @@ export class WorkflowEditDialogComponent
include_document: false,
},
passwords: [],
ai_suggestion_fields: [],
ai_create_missing: false,
ai_overwrite_existing: false,
}
this.object.actions.push(action)
this.createActionField(action)
@@ -52,10 +52,10 @@ describe('CustomFieldsValuesComponent', () => {
})
it('should set selectedFields and map values correctly', () => {
component.value = { 1: 'value1', 3: 0, 4: false }
component.selectedFields = [1, 2, 3, 4]
expect(component.selectedFields).toEqual([1, 2, 3, 4])
expect(component.value).toEqual({ 1: 'value1', 2: null, 3: 0, 4: false })
component.value = { 1: 'value1' }
component.selectedFields = [1, 2]
expect(component.selectedFields).toEqual([1, 2])
expect(component.value).toEqual({ 1: 'value1', 2: null })
})
it('should return the correct custom field by id', () => {
@@ -77,7 +77,7 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
this._selectedFields = newFields
// map the selected fields to an object with field_id as key and value as value
this.value = newFields.reduce((acc, fieldId) => {
acc[fieldId] = this.value?.[fieldId] ?? null
acc[fieldId] = this.value?.[fieldId] || null
return acc
}, {})
this.onChange(this.value)
@@ -36,16 +36,7 @@
(focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()"
(blur)="onBlur()">
<ng-template ng-label-tmp let-item="item">
@if (iconField && item[iconField]) {
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
}
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
</ng-template>
<ng-template ng-option-tmp let-item="item">
@if (iconField && item[iconField]) {
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
}
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
</ng-template>
</ng-select>
@@ -35,7 +35,7 @@ import { AbstractInputComponent } from '../abstract-input'
NgxBootstrapIconsModule,
],
})
export class SelectComponent extends AbstractInputComponent<number | string> {
export class SelectComponent extends AbstractInputComponent<number> {
constructor() {
super()
this.addItemRef = this.addItem.bind(this)
@@ -100,9 +100,6 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
@Input()
bindLabel: string = 'name'
@Input()
iconField: string
public searchFn = (term: string, item: any): boolean =>
matchesSearchText(item?.[this.bindLabel], term)
@@ -100,7 +100,7 @@
</div>
}
<div class="form-group ms-md-auto">
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">{{ cancelBtnCaption }}</button>
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
</div>
</div>
@@ -17,10 +17,6 @@ const permissions = [
'view_document',
'change_document',
'delete_document',
'add_sharelinkbundle',
'view_sharelinkbundle',
'change_sharelinkbundle',
'delete_sharelinkbundle',
'change_tag',
'view_documenttype',
]
@@ -79,7 +75,6 @@ describe('PermissionsSelectComponent', () => {
component.ngOnInit()
component.writeValue(permissions)
expect(component.typesWithAllActions).toContain('Document')
expect(component.typesWithAllActions).toContain('ShareLinkBundle')
})
it('should update checkboxes on permissions set', () => {
@@ -90,10 +85,6 @@ describe('PermissionsSelectComponent', () => {
expect(input1.nativeElement.checked).toBeTruthy()
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
expect(input2.nativeElement.checked).toBeTruthy()
const bundleInput = fixture.debugElement.query(
By.css('input#ShareLinkBundle_Add')
)
expect(bundleInput.nativeElement.checked).toBeTruthy()
})
it('disable checkboxes when permissions are inherited', () => {
@@ -119,7 +119,7 @@
type="button"
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
(click)="submit()"
[disabled]="loading() || !buttonsEnabled()">
[disabled]="loading() || !buttonsEnabled">
@if (loading()) {
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
}
@@ -69,7 +69,7 @@ describe('ShareLinkBundleDialogComponent', () => {
file_version: FileVersion.Original,
expiration_days: 3,
})
expect(component.buttonsEnabled()).toBe(false)
expect(component.buttonsEnabled).toBe(false)
expect(confirmSpy).toHaveBeenCalled()
component.form.setValue({
@@ -78,7 +78,7 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
: FileVersion.Original,
expiration_days: this.form.value.expirationDays,
}
this.buttonsEnabled.set(false)
this.buttonsEnabled = false
super.confirm()
}
@@ -1,7 +1,6 @@
<pngx-widget-frame
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
[title]="savedView.name"
[titleIcon]="savedView.icon || 'funnel'"
[loading]="false"
[draggable]="savedView"
>
@@ -8,12 +8,7 @@
<i-bs name="grip-vertical"></i-bs>
</div>
}
<h6 class="card-title mb-0">
@if (titleIcon()) {
<i-bs class="me-2" [name]="titleIcon()"></i-bs>
}
{{title()}}
</h6>
<h6 class="card-title mb-0">{{title()}}</h6>
<ng-content select="[title-badge]"></ng-content>
@if (badge() !== null && badge() !== undefined) {
<span class="badge bg-info text-dark ms-2">{{badge()}}</span>
@@ -16,8 +16,6 @@ export class WidgetFrameComponent implements AfterViewInit {
title = input<string>()
titleIcon = input<string>()
draggable = input<any>()
cardless = input(false)
@@ -963,12 +963,24 @@ describe('DocumentDetailComponent', () => {
component.reprocess()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next()
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] })
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).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', () => {
initNormally()
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
@@ -1564,7 +1576,7 @@ describe('DocumentDetailComponent', () => {
dialog.confirmClicked.next()
await openModal.result
expect(dialog.buttonsEnabled()).toBe(false)
expect(dialog.buttonsEnabled).toBe(false)
expect(reloadSpy).toHaveBeenCalled()
expect((component as any).incomingUpdateModal).toBeNull()
})
@@ -1789,7 +1801,7 @@ describe('DocumentDetailComponent', () => {
expect(errorSpy).toHaveBeenCalled()
expect(component.networkActive()).toBe(false)
expect(dialog.buttonsEnabled()).toBe(true)
expect(dialog.buttonsEnabled).toBe(true)
})
it('should refresh the document when removing password in update mode', () => {
@@ -97,6 +97,7 @@ import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif'
import { DocumentDetailFieldID } from '../admin/settings/settings.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 { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
@@ -659,7 +660,7 @@ export class DocumentDetailComponent
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
this.reloadRemoteVersion()
})
@@ -1374,7 +1375,7 @@ export class DocumentDetailComponent
modal.componentInstance.confirmClicked
.pipe(
switchMap(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
return this.documentsService.delete(this.document())
})
)
@@ -1386,7 +1387,7 @@ export class DocumentDetailComponent
},
error: (error) => {
this.toastService.showError($localize`Error deleting document`, error)
modal.componentInstance.buttonsEnabled.set(true)
modal.componentInstance.buttonsEnabled = true
this.subscribeModalDelete(modal)
},
})
@@ -1402,7 +1403,7 @@ export class DocumentDetailComponent
}
reprocess() {
let modal = this.modalService.open(ConfirmDialogComponent, {
let modal = this.modalService.open(ReprocessConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Reprocess confirm`
@@ -1411,9 +1412,12 @@ export class DocumentDetailComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.documentsService
.reprocessDocuments({ documents: [this.document().id] })
.reprocessDocuments(
{ documents: [this.document().id] },
modal.componentInstance.remoteOcr
)
.subscribe({
next: () => {
this.toastService.showInfo(
@@ -1425,7 +1429,7 @@ export class DocumentDetailComponent
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled.set(true)
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing operation`,
@@ -1798,7 +1802,7 @@ export class DocumentDetailComponent
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.documentsService
.editPdfDocuments([sourceDocumentId], {
operations: modal.componentInstance.getOperations(),
@@ -1821,7 +1825,7 @@ export class DocumentDetailComponent
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled.set(true)
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing PDF edit operation`,
@@ -1855,7 +1859,7 @@ export class DocumentDetailComponent
const sourceDocumentId = this.selectedVersionId() ?? this.document().id
const dialog =
modal.componentInstance as PasswordRemovalConfirmDialogComponent
dialog.buttonsEnabled.set(false)
dialog.buttonsEnabled = false
this.networkActive.set(true)
this.documentsService
.removePasswordDocuments([sourceDocumentId], {
@@ -1880,7 +1884,7 @@ export class DocumentDetailComponent
}
},
error: (error) => {
dialog.buttonsEnabled.set(true)
dialog.buttonsEnabled = true
this.networkActive.set(false)
this.toastService.showError(
$localize`Error executing password removal operation`,
@@ -1122,6 +1122,7 @@ describe('BulkEditorComponent', () => {
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
remote_ocr: false,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
@@ -1683,7 +1684,7 @@ describe('BulkEditorComponent', () => {
expiration_days: 7,
},
loading: signal(false),
buttonsEnabled: signal(true),
buttonsEnabled: true,
copied: signal(false),
},
}
@@ -1715,7 +1716,7 @@ describe('BulkEditorComponent', () => {
expiration_days: 7,
})
expect(dialogInstance.loading()).toBe(false)
expect(dialogInstance.buttonsEnabled()).toBe(false)
expect(dialogInstance.buttonsEnabled).toBe(false)
expect(dialogInstance.createdBundle).toEqual({ id: 42 })
expect(typeof dialogInstance.onOpenManage).toBe('function')
expect(toastInfoSpy).toHaveBeenCalledWith(
@@ -1755,7 +1756,7 @@ describe('BulkEditorComponent', () => {
expiration_days: null,
},
loading: signal(false),
buttonsEnabled: signal(true),
buttonsEnabled: true,
},
}
@@ -1777,7 +1778,7 @@ describe('BulkEditorComponent', () => {
expect.any(Error)
)
expect(dialogInstance.loading()).toBe(false)
expect(dialogInstance.buttonsEnabled()).toBe(true)
expect(dialogInstance.buttonsEnabled).toBe(true)
openSpy.mockRestore()
})
@@ -51,6 +51,7 @@ import { ToastService } from 'src/app/services/toast.service'
import { flattenTags } from 'src/app/utils/flatten-tags'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
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 { 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'
@@ -273,7 +274,7 @@ export class BulkEditorComponent
overrideSelection?: DocumentSelectionQuery
) {
if (modal) {
modal.componentInstance.buttonsEnabled.set(false)
this.setModalButtonsEnabled(modal, false)
}
this.documentService
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
@@ -290,7 +291,7 @@ export class BulkEditorComponent
options: { deleteOriginals?: boolean } = {}
) {
if (modal) {
modal.componentInstance.buttonsEnabled.set(false)
this.setModalButtonsEnabled(modal, false)
}
request.pipe(first()).subscribe({
next: () => {
@@ -320,7 +321,7 @@ export class BulkEditorComponent
private handleOperationError(modal: NgbModalRef, error: any) {
if (modal) {
modal.componentInstance.buttonsEnabled.set(true)
this.setModalButtonsEnabled(modal, true)
}
this.toastService.showError(
$localize`Error executing bulk operation`,
@@ -328,6 +329,15 @@ export class BulkEditorComponent
)
}
private setModalButtonsEnabled(modal: NgbModalRef, enabled: boolean) {
const buttonsEnabled = modal.componentInstance.buttonsEnabled
if (typeof buttonsEnabled?.set === 'function') {
buttonsEnabled.set(enabled)
} else {
modal.componentInstance.buttonsEnabled = enabled
}
}
private applySelectionData(
items: SelectionDataItem[],
selectionModel: FilterableDropdownSelectionModel
@@ -863,7 +873,7 @@ export class BulkEditorComponent
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.deleteDocuments(this.getSelectionQuery())
@@ -900,7 +910,7 @@ export class BulkEditorComponent
}
reprocessSelected() {
let modal = this.modalService.open(ConfirmDialogComponent, {
let modal = this.modalService.open(ReprocessConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Reprocess confirm`
@@ -911,10 +921,13 @@ export class BulkEditorComponent
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.reprocessDocuments(this.getSelectionQuery())
this.documentService.reprocessDocuments(
this.getSelectionQuery(),
modal.componentInstance.remoteOcr
)
)
})
}
@@ -948,7 +961,7 @@ export class BulkEditorComponent
rotateDialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
rotateDialog.buttonsEnabled.set(false)
rotateDialog.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.rotateDocuments(
@@ -981,7 +994,7 @@ export class BulkEditorComponent
if (mergeDialog.archiveFallback()) {
args.archive_fallback = true
}
mergeDialog.buttonsEnabled.set(false)
mergeDialog.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
@@ -1054,14 +1067,14 @@ export class BulkEditorComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
dialog.loading.set(true)
dialog.buttonsEnabled.set(false)
dialog.buttonsEnabled = false
this.shareLinkBundleService
.createBundle(dialog.payload)
.pipe(first())
.subscribe({
next: (result) => {
dialog.loading.set(false)
dialog.buttonsEnabled.set(false)
dialog.buttonsEnabled = false
dialog.createdBundle = result
dialog.copied.set(false)
dialog.payload = null
@@ -1075,7 +1088,7 @@ export class BulkEditorComponent
},
error: (error) => {
dialog.loading.set(false)
dialog.buttonsEnabled.set(true)
dialog.buttonsEnabled = true
this.toastService.showError(
$localize`Share link bundle creation is not available yet.`,
error
@@ -97,9 +97,7 @@
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
@if (!list.activeSavedViewId) {
@for (view of savedViewService.allViews; track view) {
<button ngbDropdownItem (click)="loadViewConfig(view.id)">
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs>{{view.name}}
</button>
<button ngbDropdownItem (click)="loadViewConfig(view.id)">{{view.name}}</button>
}
@if (savedViewService.allViews.length > 0) {
<div class="dropdown-divider"></div>
@@ -457,7 +457,6 @@ export class DocumentListComponent
modal.componentInstance.buttonsEnabled.set(false)
let savedView: SavedView = {
name: formValue.name,
icon: formValue.icon,
filter_rules: this.list.filterRules,
sort_reverse: this.list.sortReverse,
sort_field: this.list.sortField,
@@ -6,14 +6,6 @@
</div>
<div class="modal-body">
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error()?.name" autocomplete="off"></pngx-input-text>
<pngx-input-select
i18n-title
title="Icon"
formControlName="icon"
[items]="savedViewIcons"
iconField="icon"
[error]="error()?.icon">
</pngx-input-select>
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
<pngx-permissions-form accordion="true" formControlName="permissions_form"></pngx-permissions-form>
@@ -9,7 +9,6 @@ import { CheckComponent } from '../../common/input/check/check.component'
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
import { SelectComponent } from '../../common/input/select/select.component'
import { TextComponent } from '../../common/input/text/text.component'
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
@@ -41,7 +40,6 @@ describe('SaveViewConfigDialogComponent', () => {
ReactiveFormsModule,
SaveViewConfigDialogComponent,
TextComponent,
SelectComponent,
CheckComponent,
PermissionsFormComponent,
PermissionsUserComponent,
@@ -65,7 +63,6 @@ describe('SaveViewConfigDialogComponent', () => {
expect(component.defaultName()).toEqual(name)
expect(result).toEqual({
name,
icon: 'funnel',
showInSideBar: false,
showOnDashboard: false,
})
@@ -97,7 +94,6 @@ describe('SaveViewConfigDialogComponent', () => {
component.save()
expect(result).toEqual({
name,
icon: 'funnel',
showInSideBar: true,
showOnDashboard: true,
})
@@ -117,7 +113,6 @@ describe('SaveViewConfigDialogComponent', () => {
component.save()
expect(result).toEqual({
name: '',
icon: 'funnel',
showInSideBar: false,
showOnDashboard: false,
permissions_form: permissions,
@@ -13,14 +13,9 @@ import {
ReactiveFormsModule,
} from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import {
DEFAULT_SAVED_VIEW_ICON,
SAVED_VIEW_ICONS,
} from 'src/app/data/saved-view-icons'
import { User } from 'src/app/data/user'
import { CheckComponent } from '../../common/input/check/check.component'
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
import { SelectComponent } from '../../common/input/select/select.component'
import { TextComponent } from '../../common/input/text/text.component'
@Component({
@@ -29,7 +24,6 @@ import { TextComponent } from '../../common/input/text/text.component'
styleUrls: ['./save-view-config-dialog.component.scss'],
imports: [
CheckComponent,
SelectComponent,
TextComponent,
PermissionsFormComponent,
FormsModule,
@@ -47,7 +41,6 @@ export class SaveViewConfigDialogComponent implements OnInit {
public saveClicked = new EventEmitter()
users: User[]
readonly savedViewIcons = SAVED_VIEW_ICONS
setDefaultName(value: string) {
this.defaultName.set(value)
@@ -56,7 +49,6 @@ export class SaveViewConfigDialogComponent implements OnInit {
saveViewConfigForm = new FormGroup({
name: new FormControl(''),
icon: new FormControl(DEFAULT_SAVED_VIEW_ICON),
showInSideBar: new FormControl(false),
showOnDashboard: new FormControl(false),
permissions_form: new FormControl(null),
@@ -73,7 +65,6 @@ export class SaveViewConfigDialogComponent implements OnInit {
const formValue = this.saveViewConfigForm.value
const saveViewConfig = {
name: formValue.name,
icon: formValue.icon,
showInSideBar: formValue.showInSideBar,
showOnDashboard: formValue.showOnDashboard,
}
@@ -105,7 +105,7 @@ export class CustomFieldsComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.customFieldsService.delete(field).subscribe({
next: () => {
modal.close()
@@ -274,7 +274,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
activeModal.componentInstance.btnClass = 'btn-danger'
activeModal.componentInstance.btnCaption = $localize`Delete`
activeModal.componentInstance.confirmClicked.subscribe(() => {
activeModal.componentInstance.buttonsEnabled.set(false)
activeModal.componentInstance.buttonsEnabled = false
this.service
.delete(object)
.pipe(takeUntil(this.unsubscribeNotifier))
@@ -284,7 +284,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.reloadData()
},
error: (error) => {
activeModal.componentInstance.buttonsEnabled.set(true)
activeModal.componentInstance.buttonsEnabled = true
this.toastService.showError(
$localize`Error while deleting element`,
error
@@ -455,7 +455,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.service
.bulk_edit_objects(
this.allSelectionActive ? [] : Array.from(this.selectedObjects),
@@ -472,7 +472,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.reloadData()
},
error: (error) => {
modal.componentInstance.buttonsEnabled.set(true)
modal.componentInstance.buttonsEnabled = true
this.toastService.showError(
$localize`Error deleting objects`,
error
@@ -196,7 +196,7 @@ export class MailComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.mailAccountService.delete(account).subscribe({
next: () => {
modal.close()
@@ -298,7 +298,7 @@ export class MailComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.mailRuleService.delete(rule).subscribe({
next: () => {
modal.close()
@@ -7,24 +7,15 @@
</pngx-page-header>
<form [formGroup]="savedViewsForm" (ngSubmit)="save()">
<ul class="list-group mb-3" formGroupName="savedViews">
@for (view of pagedSavedViews(); track view) {
@for (view of savedViews(); track view) {
<li class="list-group-item py-3">
<div [formGroupName]="view.id">
<div class="row">
<div class="col-md">
<div class="col">
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
</div>
<div class="col-md">
<pngx-input-select
i18n-title
title="Icon"
formControlName="icon"
[items]="savedViewIcons"
iconField="icon">
</pngx-input-select>
</div>
@if (canSaveSettings) {
<div class="col-md">
<div class="col">
<div class="form-check form-switch mt-3">
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
@@ -90,11 +81,6 @@
}
</ul>
<div class="d-flex align-items-center mb-3">
<button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
<button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
@if (savedViews()?.length > pageSize) {
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="savedViews().length" [page]="page()" [maxSize]="5" (pageChange)="page.set($event)" size="sm" aria-label="Pagination"></ngb-pagination>
}
</div>
<button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
<button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
</form>
@@ -4,7 +4,6 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { signal } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs'
@@ -26,20 +25,8 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon
import { SavedViewsComponent } from './saved-views.component'
const savedViews = [
{
id: 1,
name: 'view1',
icon: 'archive',
show_in_sidebar: true,
show_on_dashboard: true,
},
{
id: 2,
name: 'view2',
icon: 'funnel',
show_in_sidebar: false,
show_on_dashboard: false,
},
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
{ id: 2, name: 'view2', show_in_sidebar: false, show_on_dashboard: false },
]
describe('SavedViewsComponent', () => {
@@ -170,24 +157,6 @@ describe('SavedViewsComponent', () => {
expect(patchBody.show_in_sidebar).toBeUndefined()
})
it('should persist a changed icon', () => {
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
const view = savedViews[0]
const iconControl = component.savedViewsForm
.get('savedViews')
.get(view.id.toString())
.get('icon')
iconControl.setValue('bell')
iconControl.markAsDirty()
component.save()
expect(patchSpy.mock.calls[0][0][0]).toMatchObject({
id: view.id,
icon: 'bell',
})
})
it('should persist visibility changes to user settings', () => {
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
const updateVisibilitySpy = jest
@@ -253,44 +222,6 @@ describe('SavedViewsComponent', () => {
).toEqual(view.show_on_dashboard)
})
it('should page saved views, clamp the page if views are removed', () => {
const manyViews = Array.from({ length: 30 }, (_, i) => ({
id: i + 1,
name: `view${i + 1}`,
})) as SavedView[]
const listSpy = jest.spyOn(savedViewService, 'list').mockReturnValue(
of({
all: manyViews.map((v) => v.id),
count: manyViews.length,
results: manyViews.concat([]),
})
)
component.ngOnInit()
fixture.detectChanges()
expect(listSpy).toHaveBeenCalledWith(1, 100000, null, false, {
full_perms: true,
})
expect(component.pagedSavedViews()).toHaveLength(25)
expect(fixture.debugElement.query(By.css('ngb-pagination'))).not.toBeNull()
// all views have controls, not just the current page
expect(
Object.keys(component.savedViewsForm.get('savedViews').value)
).toHaveLength(30)
component.page.set(2)
expect(component.pagedSavedViews()).toHaveLength(5)
listSpy.mockReturnValue(
of({
all: manyViews.slice(0, 25).map((v) => v.id),
count: 25,
results: manyViews.slice(0, 25),
})
)
component.ngOnInit()
expect(component.page()).toEqual(1)
})
it('should support editing permissions', () => {
const confirmClicked = new Subject<any>()
const modalRef = {
@@ -1,29 +1,18 @@
import { AsyncPipe } from '@angular/common'
import {
Component,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core'
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { NgbModal, NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { dirtyCheck } from '@ngneat/dirty-check-forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { BehaviorSubject, Observable, of, switchMap, takeUntil } from 'rxjs'
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
import { DisplayMode } from 'src/app/data/document'
import { SavedView } from 'src/app/data/saved-view'
import {
DEFAULT_SAVED_VIEW_ICON,
SAVED_VIEW_ICONS,
} from 'src/app/data/saved-view-icons'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
PermissionAction,
@@ -36,7 +25,6 @@ import { ToastService } from 'src/app/services/toast.service'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { DragDropSelectComponent } from '../../common/input/drag-drop-select/drag-drop-select.component'
import { NumberComponent } from '../../common/input/number/number.component'
import { SelectComponent } from '../../common/input/select/select.component'
import { TextComponent } from '../../common/input/text/text.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@@ -48,14 +36,12 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
PageHeaderComponent,
ConfirmButtonComponent,
NumberComponent,
SelectComponent,
TextComponent,
IfPermissionsDirective,
DragDropSelectComponent,
FormsModule,
ReactiveFormsModule,
AsyncPipe,
NgbPaginationModule,
NgxBootstrapIconsModule,
],
})
@@ -70,17 +56,8 @@ export class SavedViewsComponent
private readonly modalService = inject(NgbModal)
DisplayMode = DisplayMode
readonly savedViewIcons = SAVED_VIEW_ICONS
readonly savedViews = signal<SavedView[]>(undefined)
readonly page = signal(1)
public readonly pageSize = 25
// All views are loaded at init, so paging is only for display
readonly pagedSavedViews = computed(() => {
const start = (this.page() - 1) * this.pageSize
return this.savedViews()?.slice(start, start + this.pageSize)
})
private savedViewsGroup = new FormGroup({})
public savedViewsForm: FormGroup = new FormGroup({
savedViews: this.savedViewsGroup,
@@ -107,11 +84,9 @@ export class SavedViewsComponent
private reloadViews(): void {
this.loading.set(true)
this.savedViewService
.list(1, 100000, null, false, { full_perms: true })
.list(null, null, null, false, { full_perms: true })
.subscribe((r) => {
this.savedViews.set(r.results)
const pageCount = Math.ceil(r.results.length / this.pageSize)
this.page.update((page) => Math.min(page, Math.max(1, pageCount)))
this.initialize()
})
}
@@ -135,7 +110,6 @@ export class SavedViewsComponent
storeData.savedViews[view.id.toString()] = {
id: view.id,
name: view.name,
icon: view.icon ?? DEFAULT_SAVED_VIEW_ICON,
show_on_dashboard: view.show_on_dashboard,
show_in_sidebar: view.show_in_sidebar,
page_size: view.page_size,
@@ -148,7 +122,6 @@ export class SavedViewsComponent
new FormGroup({
id: new FormControl({ value: null, disabled: !canEdit }),
name: new FormControl({ value: null, disabled: !canEdit }),
icon: new FormControl({ value: null, disabled: !canEdit }),
show_on_dashboard: new FormControl({
value: null,
disabled: false,
@@ -227,7 +200,6 @@ export class SavedViewsComponent
const modelFieldsChanged =
group.get('name')?.dirty ||
group.get('icon')?.dirty ||
group.get('page_size')?.dirty ||
group.get('display_mode')?.dirty ||
group.get('display_fields')?.dirty
@@ -134,7 +134,7 @@ export class WorkflowsComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.workflowService.delete(workflow).subscribe({
next: () => {
modal.close()
+55
View File
@@ -54,6 +54,10 @@ export const ConfigCategory = {
AI: $localize`AI Settings`,
}
export const ConfigSection = {
RemoteOCR: $localize`Remote OCR`,
}
export const LLMEmbeddingBackendConfig = {
OPENAI_LIKE: 'openai-like',
HUGGINGFACE: 'huggingface',
@@ -65,6 +69,15 @@ export const LLMBackendConfig = {
OLLAMA: 'ollama',
}
export const RemoteOCREngineConfig = {
AZURE_AI: 'azureai',
}
export const RemoteOCRModeConfig = {
ALWAYS: 'always',
WORKFLOW_ONLY: 'workflow_only',
}
export interface ConfigOption {
key: string
title: string
@@ -72,6 +85,7 @@ export interface ConfigOption {
choices?: Array<{ id: string; name: string }>
config_key?: string
category: string
section?: string
note?: string
}
@@ -181,6 +195,43 @@ export const PaperlessConfigOptions: ConfigOption[] = [
config_key: 'PAPERLESS_OCR_USER_ARGS',
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',
title: $localize`Application Logo`,
@@ -398,6 +449,10 @@ export interface PaperlessConfig extends ObjectWithId {
barcode_enable_tag: boolean
barcode_tag_mapping: object
barcode_tag_split: boolean
remote_ocr_engine: string
remote_ocr_api_key: string
remote_ocr_endpoint: string
remote_ocr_mode: string
ai_enabled: boolean
llm_embedding_backend: string
llm_embedding_model: string
-89
View File
@@ -1,89 +0,0 @@
export const DEFAULT_SAVED_VIEW_ICON = 'funnel'
export const SAVED_VIEW_ICONS = [
{ id: 'archive', name: $localize`Archive`, icon: 'archive' },
{ id: 'bank', name: $localize`Bank`, icon: 'bank' },
{ id: 'basket', name: $localize`Basket`, icon: 'basket' },
{ id: 'bell', name: $localize`Bell`, icon: 'bell' },
{ id: 'bookmark', name: $localize`Bookmark`, icon: 'bookmark' },
{ id: 'boxes', name: $localize`Boxes`, icon: 'boxes' },
{ id: 'briefcase', name: $localize`Briefcase`, icon: 'briefcase' },
{ id: 'building', name: $localize`Building`, icon: 'building' },
{ id: 'calculator', name: $localize`Calculator`, icon: 'calculator' },
{ id: 'calendar', name: $localize`Calendar`, icon: 'calendar' },
{ id: 'camera', name: $localize`Camera`, icon: 'camera' },
{
id: 'card-checklist',
name: $localize`Checklist`,
icon: 'card-checklist',
},
{ id: 'cash', name: $localize`Cash`, icon: 'cash' },
{ id: 'chat-left-text', name: $localize`Chat`, icon: 'chat-left-text' },
{ id: 'check-circle', name: $localize`Check`, icon: 'check-circle' },
{ id: 'clipboard', name: $localize`Clipboard`, icon: 'clipboard' },
{ id: 'clock-history', name: $localize`Clock`, icon: 'clock-history' },
{ id: 'credit-card', name: $localize`Credit card`, icon: 'credit-card' },
{ id: 'download', name: $localize`Download`, icon: 'download' },
{ id: 'envelope', name: $localize`Envelope`, icon: 'envelope' },
{
id: 'exclamation-triangle',
name: $localize`Warning`,
icon: 'exclamation-triangle',
},
{ id: 'file-earmark', name: $localize`File`, icon: 'file-earmark' },
{
id: 'file-earmark-check',
name: $localize`Checked file`,
icon: 'file-earmark-check',
},
{
id: 'file-earmark-lock',
name: $localize`Locked file`,
icon: 'file-earmark-lock',
},
{
id: 'file-earmark-medical',
name: $localize`Medical file`,
icon: 'file-earmark-medical',
},
{
id: 'file-earmark-person',
name: $localize`Person file`,
icon: 'file-earmark-person',
},
{
id: 'file-earmark-spreadsheet',
name: $localize`Spreadsheet`,
icon: 'file-earmark-spreadsheet',
},
{ id: 'file-text', name: $localize`Text file`, icon: 'file-text' },
{ id: 'files', name: $localize`Files`, icon: 'files' },
{ id: 'folder', name: $localize`Folder`, icon: 'folder' },
{ id: 'funnel', name: $localize`Filter`, icon: 'funnel' },
{ id: 'gear', name: $localize`Gear`, icon: 'gear' },
{ id: 'globe2', name: $localize`Globe`, icon: 'globe2' },
{ id: 'hash', name: $localize`Hash`, icon: 'hash' },
{ id: 'heart', name: $localize`Heart`, icon: 'heart' },
{ id: 'house', name: $localize`House`, icon: 'house' },
{ id: 'inbox', name: $localize`Inbox`, icon: 'inbox' },
{ id: 'journals', name: $localize`Journals`, icon: 'journals' },
{ id: 'list-task', name: $localize`Task list`, icon: 'list-task' },
{ id: 'newspaper', name: $localize`Newspaper`, icon: 'newspaper' },
{ id: 'paperclip', name: $localize`Attachment`, icon: 'paperclip' },
{ id: 'people', name: $localize`People`, icon: 'people' },
{ id: 'person', name: $localize`Person`, icon: 'person' },
{ id: 'printer', name: $localize`Printer`, icon: 'printer' },
{ id: 'receipt', name: $localize`Receipt`, icon: 'receipt' },
{ id: 'safe', name: $localize`Safe`, icon: 'safe' },
{ id: 'search', name: $localize`Search`, icon: 'search' },
{ id: 'send', name: $localize`Send`, icon: 'send' },
{ id: 'shop', name: $localize`Shop`, icon: 'shop' },
{ id: 'stack', name: $localize`Stack`, icon: 'stack' },
{ id: 'stars', name: $localize`Stars`, icon: 'stars' },
{ id: 'tag', name: $localize`Tag`, icon: 'tag' },
{ id: 'tags', name: $localize`Tags`, icon: 'tags' },
{ id: 'telephone', name: $localize`Telephone`, icon: 'telephone' },
{ id: 'truck', name: $localize`Truck`, icon: 'truck' },
{ id: 'upc-scan', name: $localize`Barcode`, icon: 'upc-scan' },
{ id: 'wallet2', name: $localize`Wallet`, icon: 'wallet2' },
]
-2
View File
@@ -5,8 +5,6 @@ import { ObjectWithPermissions } from './object-with-permissions'
export interface SavedView extends ObjectWithPermissions {
name?: string
icon?: string
show_on_dashboard?: boolean
show_in_sidebar?: boolean
+13
View File
@@ -1,5 +1,6 @@
import { PdfEditorEditMode } from '../components/common/pdf-editor/pdf-editor-edit-mode'
import { PdfZoomScale } from '../components/common/pdf-viewer/pdf-viewer.types'
import { RemoteOCRModeConfig } from './paperless-config'
import { User } from './user'
export interface UiSettings {
@@ -94,6 +95,8 @@ export const SETTINGS_KEYS = {
OUTLOOK_OAUTH_URL: 'outlook_oauth_url',
EMAIL_ENABLED: 'email_enabled',
AI_ENABLED: 'ai_enabled',
REMOTE_OCR_CONFIGURED: 'remote_ocr:configured',
REMOTE_OCR_MODE: 'remote_ocr:mode',
}
export const SETTINGS: UiSetting[] = [
@@ -347,4 +350,14 @@ export const SETTINGS: UiSetting[] = [
type: 'string',
default: PdfEditorEditMode.Create,
},
{
key: SETTINGS_KEYS.REMOTE_OCR_CONFIGURED,
type: 'boolean',
default: false,
},
{
key: SETTINGS_KEYS.REMOTE_OCR_MODE,
type: 'string',
default: RemoteOCRModeConfig.ALWAYS,
},
]
+18
View File
@@ -7,6 +7,18 @@ export enum WorkflowActionType {
Webhook = 4,
PasswordRemoval = 5,
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 {
@@ -101,4 +113,10 @@ export interface WorkflowAction extends ObjectWithId {
webhook?: WorkflowActionWebhook
passwords?: string[]
ai_suggestion_fields?: AISuggestionField[]
ai_create_missing?: boolean
ai_overwrite_existing?: boolean
}
+1 -1
View File
@@ -18,7 +18,7 @@ export class DirtyFormGuard extends DirtyCheckGuard {
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Leave page`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
})
const subject = new Subject<boolean>()
@@ -36,12 +36,12 @@ export class DirtySavedViewGuard {
modal.componentInstance.alternativeBtnClass = 'btn-primary'
modal.componentInstance.alternativeBtnCaption = $localize`Save and close`
modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
component.saveViewConfig()
modal.close()
})
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
})
@@ -142,7 +142,7 @@ export class OpenDocumentsService {
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Close document`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
this.openDocuments.splice(index, 1)
this.dirtyDocuments.delete(doc.id)
@@ -165,7 +165,7 @@ export class OpenDocumentsService {
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Close documents`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
this.openDocuments.splice(0, this.openDocuments.length)
this.dirtyDocuments.clear()
@@ -120,12 +120,6 @@ describe('PermissionsService', () => {
actionKey: 'View', // PermissionAction.View
typeKey: 'SystemMonitoring', // PermissionType.SystemMonitoring
})
expect(permissionsService.getPermissionKeys('add_sharelinkbundle')).toEqual(
{
actionKey: 'Add', // PermissionAction.Add
typeKey: 'ShareLinkBundle', // PermissionType.ShareLinkBundle
}
)
})
it('correctly checks explicit global permissions', () => {
@@ -275,10 +269,6 @@ describe('PermissionsService', () => {
'view_sharelink',
'change_sharelink',
'delete_sharelink',
'add_sharelinkbundle',
'view_sharelinkbundle',
'change_sharelinkbundle',
'delete_sharelinkbundle',
'add_workflow',
'view_workflow',
'change_workflow',
@@ -26,7 +26,6 @@ export enum PermissionType {
User = '%s_user',
Group = '%s_group',
ShareLink = '%s_sharelink',
ShareLinkBundle = '%s_sharelinkbundle',
CustomField = '%s_customfield',
Workflow = '%s_workflow',
ProcessedMail = '%s_processedmail',
@@ -284,6 +284,21 @@ describe(`DocumentService`, () => {
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
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'), {
...selection,
remote_ocr: remoteOcr,
})
}
@@ -13,6 +13,7 @@ import { environment } from 'src/environments/environment'
import { CustomFieldDataType } from '../data/custom-field'
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
import { SavedView } from '../data/saved-view'
import { RemoteOCRModeConfig } from '../data/paperless-config'
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
import { PermissionsService } from './permissions.service'
import { CustomFieldsService } from './rest/custom-fields.service'
@@ -434,4 +435,26 @@ describe('SettingsService', () => {
).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'
import { DEFAULT_APP_TITLE, environment } from 'src/environments/environment'
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
import { RemoteOCRModeConfig } from '../data/paperless-config'
import { SavedView } from '../data/saved-view'
import {
PAPERLESS_GREEN_HEX,
@@ -687,6 +688,17 @@ export class SettingsService {
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 {
return this.dashboardIsEmpty() && !this.get(SETTINGS_KEYS.TOUR_COMPLETE)
}
-46
View File
@@ -35,27 +35,19 @@ import {
arrowRightShort,
arrowUpRight,
asterisk,
bank,
basket,
bell,
bodyText,
bookmark,
boxArrowUp,
boxArrowUpRight,
boxes,
braces,
briefcase,
building,
calculator,
calendar,
calendarEvent,
calendarEventFill,
camera,
cardChecklist,
cardHeading,
caretDown,
caretUp,
cash,
chatLeftText,
chatSquareDots,
check,
@@ -73,7 +65,6 @@ import {
clipboardCheckFill,
clipboardFill,
clockHistory,
creditCard,
dash,
dashCircle,
diagram3,
@@ -92,12 +83,9 @@ import {
fileEarmarkDiff,
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMedical,
fileEarmarkMinus,
fileEarmarkPerson,
fileEarmarkPlus,
fileEarmarkRichtext,
fileEarmarkSpreadsheet,
fileText,
files,
filter,
@@ -105,15 +93,12 @@ import {
folderFill,
funnel,
gear,
globe2,
google,
grid,
gripVertical,
hash,
hddStack,
heart,
house,
inbox,
infoCircle,
journals,
link,
@@ -121,9 +106,7 @@ import {
listTask,
listUl,
microsoft,
newspaper,
nodePlus,
paperclip,
pencil,
people,
peopleFill,
@@ -138,12 +121,9 @@ import {
plusCircle,
printer,
questionCircle,
receipt,
safe,
scissors,
search,
send,
shop,
slashCircle,
sliders2Vertical,
sortAlphaDown,
@@ -153,17 +133,14 @@ import {
tag,
tagFill,
tags,
telephone,
textIndentLeft,
textLeft,
threeDots,
threeDotsVertical,
trash,
truck,
uiRadios,
unlock,
upcScan,
wallet2,
windowStack,
x,
xCircle,
@@ -281,22 +258,15 @@ const icons = {
arrowRightShort,
arrowUpRight,
asterisk,
bank,
basket,
bell,
braces,
bodyText,
bookmark,
boxArrowUp,
boxArrowUpRight,
boxes,
briefcase,
building,
calculator,
calendar,
calendarEvent,
calendarEventFill,
camera,
cardChecklist,
cardHeading,
caretDown,
@@ -318,8 +288,6 @@ const icons = {
clipboardCheckFill,
clipboardFill,
clockHistory,
cash,
creditCard,
dash,
dashCircle,
diagram3,
@@ -338,12 +306,9 @@ const icons = {
fileEarmarkDiff,
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMedical,
fileEarmarkMinus,
fileEarmarkPerson,
fileEarmarkPlus,
fileEarmarkRichtext,
fileEarmarkSpreadsheet,
files,
fileText,
filter,
@@ -351,15 +316,12 @@ const icons = {
folderFill,
funnel,
gear,
globe2,
google,
grid,
gripVertical,
hash,
hddStack,
heart,
house,
inbox,
infoCircle,
journals,
link,
@@ -367,10 +329,8 @@ const icons = {
listTask,
listUl,
microsoft,
newspaper,
nodePlus,
pencil,
paperclip,
people,
peopleFill,
person,
@@ -384,13 +344,10 @@ const icons = {
plusCircle,
printer,
questionCircle,
receipt,
safe,
scissors,
search,
send,
slashCircle,
shop,
sliders2Vertical,
sortAlphaDown,
sortAlphaUpAlt,
@@ -401,15 +358,12 @@ const icons = {
tags,
textIndentLeft,
textLeft,
telephone,
threeDots,
threeDotsVertical,
trash,
truck,
uiRadios,
unlock,
upcScan,
wallet2,
windowStack,
x,
xCircle,
+8 -2
View File
@@ -394,10 +394,16 @@ def delete(doc_ids: list[int]) -> Literal["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:
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},
)
+18
View File
@@ -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 run_subprocess
from paperless.config import OcrConfig
from paperless.config import RemoteOCRConfig
from paperless.models import ArchiveFileGenerationChoices
from paperless.parsers import ParserContext
from paperless.parsers import ParserProtocol
@@ -451,12 +452,19 @@ class ConsumerPlugin(
except Exception as 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
parser_class: type[ParserProtocol] | None = (
get_parser_registry().get_parser_for_file(
mime_type,
self.filename,
self.working_copy,
allow_remote=allow_remote,
)
)
if not parser_class:
@@ -465,6 +473,16 @@ class ConsumerPlugin(
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.
document_consumption_started.send(
+3
View File
@@ -34,6 +34,7 @@ class DocumentMetadataOverrides:
skip_asn_if_exists: bool = False
version_label: str | None = None
actor_id: int | None = None
remote_ocr: bool = False
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
"""
@@ -57,6 +58,8 @@ class DocumentMetadataOverrides:
self.actor_id = other.actor_id
if other.skip_asn_if_exists:
self.skip_asn_if_exists = True
if other.remote_ocr:
self.remote_ocr = True
if other.version_label is not None:
self.version_label = other.version_label
-106
View File
@@ -1,106 +0,0 @@
from __future__ import annotations
import importlib
import zipfile
# ZIP_ZSTANDARD exists only on Python 3.14+ (PEP 784). None elsewhere.
ZSTD: int | None = getattr(zipfile, "ZIP_ZSTANDARD", None)
# CLI choices are fixed across runtimes so argparse never hides zstd; runtime
# availability is enforced separately in compression_available().
COMPRESSION_CHOICES: tuple[str, ...] = (
"stored",
"deflated",
"bzip2",
"lzma",
"zstd",
)
# Method name -> zipfile compression constant (zstd only when supported).
COMPRESSION_METHODS: dict[str, int] = {
"stored": zipfile.ZIP_STORED,
"deflated": zipfile.ZIP_DEFLATED,
"bzip2": zipfile.ZIP_BZIP2,
"lzma": zipfile.ZIP_LZMA,
}
if ZSTD is not None:
COMPRESSION_METHODS["zstd"] = ZSTD
# Inclusive (min, max) level bounds per method; None => level not applicable.
# Verified on CPython 3.14.3.
#
# zstd's raw library bounds are (-131072, 22)
# (compression.zstd.CompressionParameter.compression_level.bounds()) — the
# minimum is an internal implementation constant (-ZSTD_TARGETLENGTH_MAX),
# not a meaningful distinct "level"; deeper negative values than -22 buy
# nothing over -22 in practice. We expose the conventional zstd CLI range
# instead of the raw library bounds.
LEVEL_BOUNDS: dict[str, tuple[int, int] | None] = {
"stored": None,
"deflated": (0, 9),
"bzip2": (1, 9),
"lzma": None,
"zstd": (-22, 22),
}
# zipfile compress_type id -> method name.
_COMPRESS_TYPE_TO_METHOD: dict[int, str] = {
zipfile.ZIP_STORED: "stored",
zipfile.ZIP_DEFLATED: "deflated",
zipfile.ZIP_BZIP2: "bzip2",
zipfile.ZIP_LZMA: "lzma",
93: "zstd",
}
def compression_available(method: str) -> bool:
"""Whether the running interpreter can actually use the given method."""
if method in ("stored", "deflated"):
# zlib is a hard CPython dependency; stored needs nothing.
return True
if method == "bzip2":
return _module_importable("bz2")
if method == "lzma":
return _module_importable("lzma")
if method == "zstd":
return ZSTD is not None and _module_importable("compression.zstd")
return False # pragma: no cover -- method is always one of COMPRESSION_CHOICES
def _module_importable(name: str) -> bool:
try:
importlib.import_module(name)
except ImportError:
return False
return True
def level_error(method: str, level: int | None) -> str | None:
"""Return a human message if (method, level) is invalid, else None."""
if level is None:
return None
bounds = LEVEL_BOUNDS[method]
if bounds is None:
return f"--zip-compression-level has no effect for '{method}'"
low, high = bounds
if not (low <= level <= high):
return (
f"--zip-compression-level for '{method}' must be between {low} and {high}"
)
return None
def compress_type_readable(compress_type: int) -> bool:
"""Whether this interpreter can decompress an entry of the given type."""
method = _COMPRESS_TYPE_TO_METHOD.get(compress_type)
if method is None:
return False
return compression_available(method)
def unreadable_method_names(compress_types: set[int]) -> set[str]:
"""Map a set of compress_type ids to human method names for error messages."""
names: set[str] = set()
for ct in compress_types:
names.add(_COMPRESS_TYPE_TO_METHOD.get(ct, f"method {ct}"))
return names
+2 -13
View File
@@ -243,21 +243,11 @@ class ZipExportSink(ExportSink):
added as an entry at finalize (a zip entry cannot be interleaved with others).
"""
def __init__(
self,
target: Path,
zip_name: str,
*,
delete: bool = False,
compression: int = zipfile.ZIP_DEFLATED,
compresslevel: int | None = None,
) -> None:
def __init__(self, target: Path, zip_name: str, *, delete: bool = False) -> None:
self._target = target.resolve()
self._zip_path = (self._target / zip_name).with_suffix(".zip")
self._tmp_path = self._zip_path.with_name(self._zip_path.name + ".tmp")
self._delete = delete
self._compression = compression
self._compresslevel = compresslevel
self._zip: zipfile.ZipFile | None = None
self._dirs: set[str] = set()
self._pending_manifest: tuple[Path, str] | None = None
@@ -268,8 +258,7 @@ class ZipExportSink(ExportSink):
self._zip = zipfile.ZipFile(
self._tmp_path,
"w",
compression=self._compression,
compresslevel=self._compresslevel,
compression=zipfile.ZIP_DEFLATED,
allowZip64=True,
)
@@ -29,11 +29,6 @@ if TYPE_CHECKING:
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import LogEntry
from documents.export.compression import COMPRESSION_CHOICES
from documents.export.compression import COMPRESSION_METHODS
from documents.export.compression import ZSTD
from documents.export.compression import compression_available
from documents.export.compression import level_error
from documents.export.sinks import DirectoryExportSink
from documents.export.sinks import ExportSink
from documents.export.sinks import StreamingManifestWriter
@@ -197,28 +192,6 @@ class Command(CryptMixin, PaperlessCommand):
help="Sets the export zip file name",
)
parser.add_argument(
"--zip-compression",
choices=COMPRESSION_CHOICES,
default=None,
help=(
"Compression method for the export zip (requires --zip). "
"Default: deflated. 'zstd' requires Python 3.14+ on both the "
"exporting and importing machine."
),
)
parser.add_argument(
"--zip-compression-level",
type=int,
default=None,
help=(
"Compression level for the export zip (requires --zip). "
"deflated: 0-9, bzip2: 1-9, zstd: -22..22; ignored for "
"stored/lzma."
),
)
parser.add_argument(
"--data-only",
default=False,
@@ -274,39 +247,12 @@ class Command(CryptMixin, PaperlessCommand):
if not os.access(self.target, os.W_OK):
raise CommandError("That path doesn't appear to be writable")
zip_compression: str | None = options["zip_compression"]
zip_compression_level: int | None = options["zip_compression_level"]
if not self.zip_export and (
zip_compression is not None or zip_compression_level is not None
):
raise CommandError(
"--zip-compression and --zip-compression-level require --zip",
)
compression_method = zip_compression or "deflated"
if self.zip_export:
if not compression_available(compression_method):
if compression_method == "zstd" and ZSTD is None:
raise CommandError(
"zstd compression requires Python 3.14 or newer",
)
raise CommandError(
f"Compression method '{compression_method}' is not "
f"available on this Python runtime",
)
level_msg = level_error(compression_method, zip_compression_level)
if level_msg is not None:
raise CommandError(level_msg)
sink: ExportSink
if self.zip_export:
sink = ZipExportSink(
self.target,
options["zip_name"],
delete=self.delete,
compression=COMPRESSION_METHODS[compression_method],
compresslevel=zip_compression_level,
)
else:
sink = DirectoryExportSink(
@@ -55,7 +55,7 @@ class Command(PaperlessCommand):
"--ratio",
default=85.0,
type=float,
help="Ratio to consider documents a match (0.0 - 100.0)",
help="Ratio to consider documents a match",
)
parser.add_argument(
"--delete",
@@ -69,17 +69,6 @@ class Command(PaperlessCommand):
action="store_true",
help="Skip the confirmation prompt when used with --delete",
)
parser.add_argument(
"--url",
default=None,
type=str,
help=(
"Base URL of the Paperless instance (e.g. "
"http://localhost:8000 or https://paperless.local). If set, matched "
"documents are shown as clickable (usually ctrl+click) links to "
"<url>/documents/<id>/details instead of by title."
),
)
def _render_results(
self,
@@ -87,7 +76,6 @@ class Command(PaperlessCommand):
*,
opt_ratio: float,
do_delete: bool,
base_url: str | None = None,
) -> list[int]:
"""Render match results as a Rich table. Returns list of PKs to delete."""
if not matches:
@@ -100,22 +88,13 @@ class Command(PaperlessCommand):
)
return []
# Fetch titles for matched documents in a single query, unless we're
# going to show URLs instead.
titles: dict[int, str] = {}
if not base_url:
all_pks = {pk for m in matches for pk in (m.doc_one_pk, m.doc_two_pk)}
titles = dict(
Document.objects.filter(pk__in=all_pks)
.only("pk", "title")
.values_list("pk", "title"),
)
def _cell(pk: int) -> str:
if base_url:
doc_url = f"{base_url.rstrip('/')}/documents/{pk}/details"
return f"[link={doc_url}]{doc_url}[/link]"
return f"[dim]#{pk}[/dim] {titles.get(pk, 'Unknown')}"
# Fetch titles for matched documents in a single query.
all_pks = {pk for m in matches for pk in (m.doc_one_pk, m.doc_two_pk)}
titles: dict[int, str] = dict(
Document.objects.filter(pk__in=all_pks)
.only("pk", "title")
.values_list("pk", "title"),
)
table = Table(
title=f"Fuzzy Matches (threshold: {opt_ratio:.1f}%)",
@@ -145,8 +124,8 @@ class Command(PaperlessCommand):
table.add_row(
str(i),
_cell(pk_a),
_cell(pk_b),
f"[dim]#{pk_a}[/dim] {titles.get(pk_a, 'Unknown')}",
f"[dim]#{pk_b}[/dim] {titles.get(pk_b, 'Unknown')}",
Text(f"{ratio:.1f}%", style=ratio_style),
)
maybe_delete_ids.append(pk_b)
@@ -229,7 +208,6 @@ class Command(PaperlessCommand):
matches,
opt_ratio=opt_ratio,
do_delete=options["delete"],
base_url=options["url"],
)
if options["delete"] and maybe_delete_ids:
@@ -32,8 +32,6 @@ from django.db.models.signals import post_save
from filelock import FileLock
from guardian.shortcuts import clear_ct_cache
from documents.export.compression import compress_type_readable
from documents.export.compression import unreadable_method_names
from documents.file_handling import create_source_path_directory
from documents.management.commands.base import PaperlessCommand
from documents.management.commands.mixins import CryptMixin
@@ -462,20 +460,6 @@ class Command(CryptMixin, PaperlessCommand):
with tempfile.TemporaryDirectory() as tmp_dir:
if is_zipfile(self.source):
with ZipFile(self.source) as zf:
unsupported = {
info.compress_type
for info in zf.infolist()
if not compress_type_readable(info.compress_type)
}
if unsupported:
names = sorted(unreadable_method_names(unsupported))
message = (
f"This archive uses compression this Python version cannot "
f"read ({', '.join(names)})."
)
if "zstd" in names:
message += " zstd archives require Python 3.14+."
raise CommandError(message)
zf.extractall(tmp_dir)
self.source = Path(tmp_dir)
self._run_import()
@@ -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", "0022_add_perf_indexes"),
]
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",
),
),
]
@@ -1,79 +0,0 @@
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0022_add_perf_indexes"),
]
operations = [
migrations.AddField(
model_name="savedview",
name="icon",
field=models.CharField(
choices=[
("archive", "Archive"),
("bank", "Bank"),
("basket", "Basket"),
("bell", "Bell"),
("bookmark", "Bookmark"),
("boxes", "Boxes"),
("briefcase", "Briefcase"),
("building", "Building"),
("calculator", "Calculator"),
("calendar", "Calendar"),
("camera", "Camera"),
("card-checklist", "Checklist"),
("cash", "Cash"),
("chat-left-text", "Chat"),
("check-circle", "Check"),
("clipboard", "Clipboard"),
("clock-history", "Clock"),
("credit-card", "Credit card"),
("download", "Download"),
("envelope", "Envelope"),
("exclamation-triangle", "Warning"),
("file-earmark", "File"),
("file-earmark-check", "Checked file"),
("file-earmark-lock", "Locked file"),
("file-earmark-medical", "Medical file"),
("file-earmark-person", "Person file"),
("file-earmark-spreadsheet", "Spreadsheet"),
("file-text", "Text file"),
("files", "Files"),
("folder", "Folder"),
("funnel", "Filter"),
("gear", "Gear"),
("globe2", "Globe"),
("hash", "Hash"),
("heart", "Heart"),
("house", "House"),
("inbox", "Inbox"),
("journals", "Journals"),
("list-task", "Task list"),
("newspaper", "Newspaper"),
("paperclip", "Attachment"),
("people", "People"),
("person", "Person"),
("printer", "Printer"),
("receipt", "Receipt"),
("safe", "Safe"),
("search", "Search"),
("send", "Send"),
("shop", "Shop"),
("stack", "Stack"),
("stars", "Stars"),
("tag", "Tag"),
("tags", "Tags"),
("telephone", "Telephone"),
("truck", "Truck"),
("upc-scan", "Barcode"),
("wallet2", "Wallet"),
],
default="funnel",
max_length=64,
verbose_name="icon",
),
),
]
@@ -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", "0023_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",
),
),
]
+44 -69
View File
@@ -519,68 +519,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
class SavedView(ModelWithOwner):
class Icon(models.TextChoices):
ARCHIVE = ("archive", _("Archive"))
BANK = ("bank", _("Bank"))
BASKET = ("basket", _("Basket"))
BELL = ("bell", _("Bell"))
BOOKMARK = ("bookmark", _("Bookmark"))
BOXES = ("boxes", _("Boxes"))
BRIEFCASE = ("briefcase", _("Briefcase"))
BUILDING = ("building", _("Building"))
CALCULATOR = ("calculator", _("Calculator"))
CALENDAR = ("calendar", _("Calendar"))
CAMERA = ("camera", _("Camera"))
CARD_CHECKLIST = ("card-checklist", _("Checklist"))
CASH = ("cash", _("Cash"))
CHAT_LEFT_TEXT = ("chat-left-text", _("Chat"))
CHECK_CIRCLE = ("check-circle", _("Check"))
CLIPBOARD = ("clipboard", _("Clipboard"))
CLOCK_HISTORY = ("clock-history", _("Clock"))
CREDIT_CARD = ("credit-card", _("Credit card"))
DOWNLOAD = ("download", _("Download"))
ENVELOPE = ("envelope", _("Envelope"))
EXCLAMATION_TRIANGLE = ("exclamation-triangle", _("Warning"))
FILE_EARMARK = ("file-earmark", _("File"))
FILE_EARMARK_CHECK = ("file-earmark-check", _("Checked file"))
FILE_EARMARK_LOCK = ("file-earmark-lock", _("Locked file"))
FILE_EARMARK_MEDICAL = ("file-earmark-medical", _("Medical file"))
FILE_EARMARK_PERSON = ("file-earmark-person", _("Person file"))
FILE_EARMARK_SPREADSHEET = (
"file-earmark-spreadsheet",
_("Spreadsheet"),
)
FILE_TEXT = ("file-text", _("Text file"))
FILES = ("files", _("Files"))
FOLDER = ("folder", _("Folder"))
FUNNEL = ("funnel", _("Filter"))
GEAR = ("gear", _("Gear"))
GLOBE = ("globe2", _("Globe"))
HASH = ("hash", _("Hash"))
HEART = ("heart", _("Heart"))
HOUSE = ("house", _("House"))
INBOX = ("inbox", _("Inbox"))
JOURNALS = ("journals", _("Journals"))
LIST_TASK = ("list-task", _("Task list"))
NEWSPAPER = ("newspaper", _("Newspaper"))
PAPERCLIP = ("paperclip", _("Attachment"))
PEOPLE = ("people", _("People"))
PERSON = ("person", _("Person"))
PRINTER = ("printer", _("Printer"))
RECEIPT = ("receipt", _("Receipt"))
SAFE = ("safe", _("Safe"))
SEARCH = ("search", _("Search"))
SEND = ("send", _("Send"))
SHOP = ("shop", _("Shop"))
STACK = ("stack", _("Stack"))
STARS = ("stars", _("Stars"))
TAG = ("tag", _("Tag"))
TAGS = ("tags", _("Tags"))
TELEPHONE = ("telephone", _("Telephone"))
TRUCK = ("truck", _("Truck"))
UPC_SCAN = ("upc-scan", _("Barcode"))
WALLET = ("wallet2", _("Wallet"))
class DisplayMode(models.TextChoices):
TABLE = ("table", _("Table"))
SMALL_CARDS = ("smallCards", _("Small Cards"))
@@ -603,13 +541,6 @@ class SavedView(ModelWithOwner):
name = models.CharField(_("name"), max_length=128)
icon = models.CharField(
_("icon"),
max_length=64,
choices=Icon.choices,
default=Icon.FUNNEL,
)
sort_field = models.CharField(
_("sort field"),
max_length=128,
@@ -764,6 +695,7 @@ class PaperlessTask(ModelWithOwner):
REPROCESS_DOCUMENT = "reprocess_document", _("Reprocess Document")
BUILD_SHARE_LINK = "build_share_link", _("Build Share Link")
BULK_DELETE = "bulk_delete", _("Bulk Delete")
APPLY_AI_SUGGESTIONS = "apply_ai_suggestions", _("Apply AI Suggestions")
COMPLETE_STATUSES = (
Status.SUCCESS,
@@ -1668,6 +1600,22 @@ class WorkflowAction(models.Model):
6,
_("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(
_("Workflow Action Type"),
@@ -1906,6 +1854,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:
verbose_name = _("workflow action")
verbose_name_plural = _("workflow actions")
+89 -12
View File
@@ -85,7 +85,6 @@ from documents.permissions import set_permissions_for_object
from documents.regex import validate_regex_pattern
from documents.templating.filepath import validate_filepath_template_and_render
from documents.templating.utils import convert_format_str_to_template_format
from documents.templating.workflows import validate_workflow_template
from documents.validators import uri_validator
from documents.validators import url_validator
@@ -1384,7 +1383,6 @@ class SavedViewSerializer(OwnedObjectSerializer):
fields = [
"id",
"name",
"icon",
"sort_field",
"sort_reverse",
"filter_rules",
@@ -1746,7 +1744,7 @@ class DeleteDocumentsSerializer(DocumentSelectionSerializer):
class ReprocessDocumentsSerializer(DocumentSelectionSerializer):
pass
remote_ocr = serializers.BooleanField(required=False, default=False)
class BulkEditSerializer(
@@ -2088,6 +2086,13 @@ class BulkEditSerializer(
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):
if "password" not in parameters:
raise serializers.ValidationError("password not specified")
@@ -2152,6 +2157,8 @@ class BulkEditSerializer(
self._validate_parameters_edit_pdf(parameters, attrs["documents"][0])
elif method == bulk_edit.remove_password:
self.validate_parameters_remove_password(parameters)
elif method == bulk_edit.reprocess:
self._validate_parameters_reprocess(parameters)
return attrs
@@ -3177,6 +3184,9 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
"email",
"webhook",
"passwords",
"ai_suggestion_fields",
"ai_create_missing",
"ai_overwrite_existing",
]
def validate(self, attrs):
@@ -3186,19 +3196,35 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
attrs["assign_title"] = None
else:
try:
validate_workflow_template(attrs["assign_title"])
# test against all placeholders, see consumer.py `parse_doc_title_w_placeholders`
attrs["assign_title"].format(
correspondent="",
document_type="",
added="",
added_year="",
added_year_short="",
added_month="",
added_month_name="",
added_month_name_short="",
added_day="",
added_time="",
owner_username="",
original_filename="",
filename="",
created="",
created_year="",
created_year_short="",
created_month="",
created_month_name="",
created_month_name_short="",
created_day="",
created_time="",
)
except (ValueError, KeyError) as e:
raise serializers.ValidationError(
{"assign_title": f"{e.args[0]}"},
{"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
)
if attrs.get("assign_custom_fields_values"):
# Empty strings treated as None to avoid unexpected behavior
attrs["assign_custom_fields_values"] = {
field_id: (None if value == "" else value)
for field_id, value in attrs["assign_custom_fields_values"].items()
}
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.EMAIL
@@ -3234,6 +3260,23 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
"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
@@ -3254,6 +3297,40 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
"actions",
]
def validate(self, attrs):
attrs = super().validate(attrs)
triggers = attrs.get("triggers") or []
actions = attrs.get("actions") or []
# Remote OCR can only work with consumption triggers
if any(
action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR
for action in actions
) and not any(
trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
for trigger in triggers
):
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 any(
action.get("type") == WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS
for action in actions
) and not any(
trigger.get("type") != WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
for trigger in triggers
):
raise serializers.ValidationError(
"Apply AI suggestions actions require a trigger other than "
"consumption started",
)
return attrs
def update_triggers_and_actions(
self,
instance: Workflow,
+40
View File
@@ -971,6 +971,39 @@ def run_workflows(
)
elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
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:
# limit title to 128 characters
@@ -1026,6 +1059,7 @@ TRACKED_TASKS: dict[str, PaperlessTask.TaskType] = {
"documents.tasks.update_document_content_maybe_archive_file": PaperlessTask.TaskType.REPROCESS_DOCUMENT,
"documents.tasks.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK,
"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] = {
@@ -1079,6 +1113,12 @@ def _extract_input_data(
return {"account_ids": account_ids}
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 {}
+49 -1
View File
@@ -66,6 +66,7 @@ from documents.utils import compute_checksum
from documents.utils import identity
from documents.workflows.utils import get_workflows_for_trigger
from paperless.config import AIConfig
from paperless.config import RemoteOCRConfig
from paperless.logging import consume_task_id
from paperless.parsers import ParserContext
from paperless.parsers.registry import get_parser_registry
@@ -337,10 +338,17 @@ def bulk_update_documents(document_ids) -> None:
@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
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)
@@ -350,6 +358,7 @@ def update_document_content_maybe_archive_file(document_id) -> None:
mime_type,
document.original_filename or "",
document.source_path,
allow_remote=remote_ocr or RemoteOCRConfig().remote_ocr_by_default,
)
if not parser_class:
@@ -704,6 +713,45 @@ def llmindex_index(
)
@shared_task(
bind=True,
autoretry_for=(Exception,),
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
def update_document_in_llm_index(document) -> None:
llm_index_add_or_update_document(document)
-45
View File
@@ -6,11 +6,9 @@ from pathlib import Path
from django.utils.text import slugify as django_slugify
from jinja2 import StrictUndefined
from jinja2 import Template
from jinja2 import TemplateAssertionError
from jinja2 import TemplateSyntaxError
from jinja2 import UndefinedError
from jinja2 import make_logging_undefined
from jinja2.meta import find_undeclared_variables
from jinja2.sandbox import SecurityError
from documents.templating.environment import _template_environment
@@ -31,49 +29,6 @@ _template_environment.filters["slugify"] = django_slugify
_template_environment.filters["localize_date"] = localize_date
_known_placeholder_names = {
"correspondent",
"document_type",
"added",
"added_year",
"added_year_short",
"added_month",
"added_month_name",
"added_month_name_short",
"added_day",
"added_time",
"owner_username",
"original_filename",
"filename",
"created",
"created_year",
"created_year_short",
"created_month",
"created_month_name",
"created_month_name_short",
"created_day",
"created_time",
"doc_title",
"doc_url",
"doc_id",
}
def validate_workflow_template(text: str) -> None:
try:
ast = _template_environment.parse(text)
undeclared_vars = find_undeclared_variables(ast)
except TemplateAssertionError as e:
raise ValueError(f"Template assertion error: {e}")
except TemplateSyntaxError as e:
raise ValueError(f"Template syntax error: {e}")
unknown_vars = undeclared_vars - _known_placeholder_names
if unknown_vars:
raise KeyError(
f"Template references unknown placeholders: {', '.join(unknown_vars)}",
)
def parse_w_workflow_placeholders(
text: str,
correspondent_name: str,
@@ -1,208 +0,0 @@
import sys
import zipfile
import pytest
import pytest_mock
from documents.export import compression
class TestCompressionMethods:
def test_choices_always_include_zstd(self) -> None:
"""
GIVEN:
- The compression policy module's CLI choices list
WHEN:
- Read on any runtime
THEN:
- zstd is always present; availability is checked separately so
argparse never hides it based on the current Python version
"""
assert compression.COMPRESSION_CHOICES == (
"stored",
"deflated",
"bzip2",
"lzma",
"zstd",
)
@pytest.mark.parametrize(
("name", "constant"),
[
("stored", zipfile.ZIP_STORED),
("deflated", zipfile.ZIP_DEFLATED),
("bzip2", zipfile.ZIP_BZIP2),
("lzma", zipfile.ZIP_LZMA),
],
)
def test_method_maps_to_zipfile_constant(self, name: str, constant: int) -> None:
"""
GIVEN:
- A compression method name
WHEN:
- Looked up in COMPRESSION_METHODS
THEN:
- It maps to the matching zipfile compression constant
"""
assert compression.COMPRESSION_METHODS[name] == constant
def test_stored_and_deflated_always_available(self) -> None:
"""
GIVEN:
- The stored and deflated compression methods
WHEN:
- Checked with compression_available()
THEN:
- Both are always available (zlib is a hard CPython dependency)
"""
assert compression.compression_available("stored")
assert compression.compression_available("deflated")
def test_zstd_availability_tracks_runtime(self) -> None:
"""
GIVEN:
- The zstd compression method
WHEN:
- Checked with compression_available() on this runtime
THEN:
- Availability matches whether Python is 3.14+
"""
expected: bool = sys.version_info >= (3, 14)
assert compression.compression_available("zstd") == expected
def test_unimportable_module_reports_unavailable(
self,
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- A compression method whose backing module fails to import
(e.g. a minimal Python build without bz2/lzma compiled in)
WHEN:
- Checked with compression_available()
THEN:
- False is returned rather than the ImportError propagating
"""
mocker.patch(
"documents.export.compression.importlib.import_module",
side_effect=ImportError,
)
assert not compression.compression_available("bzip2")
class TestLevelError:
@pytest.mark.parametrize(
("method", "level"),
[
("deflated", 0),
("deflated", 9),
("bzip2", 1),
("bzip2", 9),
("zstd", -22),
("zstd", 22),
("deflated", None),
("stored", None),
],
)
def test_valid_levels_return_none(self, method: str, level: int | None) -> None:
"""
GIVEN:
- A method and a level within its valid bounds (or no level)
WHEN:
- Checked with level_error()
THEN:
- No error message is returned
"""
assert compression.level_error(method, level) is None
@pytest.mark.parametrize(
("method", "level"),
[
("deflated", 10),
("deflated", -1),
("bzip2", 0),
("bzip2", 10),
("zstd", -23),
("zstd", 23),
],
)
def test_out_of_range_levels_return_message(
self,
method: str,
level: int,
) -> None:
"""
GIVEN:
- A method and a level outside its valid bounds
WHEN:
- Checked with level_error()
THEN:
- An error message naming the valid range is returned
"""
msg: str | None = compression.level_error(method, level)
assert msg is not None
assert "between" in msg
@pytest.mark.parametrize("method", ["stored", "lzma"])
def test_level_on_levelless_method_is_rejected(self, method: str) -> None:
"""
GIVEN:
- A method that ignores compression level (stored, lzma)
WHEN:
- A level is passed to level_error() anyway
THEN:
- An error message noting the level has no effect is returned
"""
msg: str | None = compression.level_error(method, 5)
assert msg is not None
assert "no effect" in msg
class TestCompressTypeReadable:
@pytest.mark.parametrize("ct", [zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED])
def test_stored_and_deflated_always_readable(self, ct: int) -> None:
"""
GIVEN:
- A stored or deflated compress_type id
WHEN:
- Checked with compress_type_readable()
THEN:
- It is always readable
"""
assert compression.compress_type_readable(ct)
def test_zstd_compress_type_readability_tracks_runtime(self) -> None:
"""
GIVEN:
- The zstd compress_type id (93, ZIP_ZSTANDARD)
WHEN:
- Checked with compress_type_readable() on this runtime
THEN:
- Readability matches whether Python is 3.14+
"""
expected: bool = sys.version_info >= (3, 14)
assert compression.compress_type_readable(93) == expected
def test_unknown_compress_type_is_unreadable(self) -> None:
"""
GIVEN:
- An unrecognized compress_type id
WHEN:
- Checked with compress_type_readable()
THEN:
- It is reported as unreadable
"""
assert not compression.compress_type_readable(9999)
def test_unreadable_method_names_lists_methods(self) -> None:
"""
GIVEN:
- A set containing an unknown compress_type id
WHEN:
- Passed to unreadable_method_names()
THEN:
- It is reported generically as "method <id>"
"""
# An unknown method id maps to no name and is reported generically.
names: set[str] = compression.unreadable_method_names({9999})
assert names == {"method 9999"}
-43
View File
@@ -5,7 +5,6 @@ import zipfile
from pathlib import Path
import pytest
import pytest_mock
from pytest_django.fixtures import SettingsWrapper
from documents.export.sinks import DirectoryExportSink
@@ -306,48 +305,6 @@ class TestZipExportSink:
assert not (target / "export.zip").exists()
class TestZipExportSinkCompression:
@pytest.mark.parametrize(
("method", "constant"),
[
("stored", zipfile.ZIP_STORED),
("deflated", zipfile.ZIP_DEFLATED),
("bzip2", zipfile.ZIP_BZIP2),
("lzma", zipfile.ZIP_LZMA),
],
)
def test_compression_and_level_forwarded_to_zipfile(
self,
mocker: pytest_mock.MockerFixture,
tmp_path: Path,
method: str,
constant: int,
) -> None:
"""
GIVEN:
- A ZipExportSink constructed with a compression method and level
WHEN:
- The sink is opened
THEN:
- zipfile.ZipFile is constructed with those values forwarded
unchanged (whether ZipFile actually compresses is Python's own
contract, not ours, so this checks the call args, not a real
archive)
"""
target: Path = tmp_path / "out"
target.mkdir()
zip_cls = mocker.patch("documents.export.sinks.zipfile.ZipFile")
sink = ZipExportSink(target, "export", compression=constant, compresslevel=5)
sink._open()
zip_cls.assert_called_once_with(
mocker.ANY,
"w",
compression=constant,
compresslevel=5,
allowZip64=True,
)
class TestStreamContract:
@pytest.fixture(params=["dir", "zip"])
def sink(self, request: pytest.FixtureRequest, tmp_path: Path) -> ExportSink:
@@ -72,6 +72,10 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
"barcode_enable_tag": None,
"barcode_tag_mapping": 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,
"llm_embedding_backend": None,
"llm_embedding_model": None,
@@ -870,6 +874,49 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
config.refresh_from_db()
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:
"""
GIVEN:
+46 -1
View File
@@ -532,7 +532,29 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
m.assert_called_once()
args, kwargs = m.call_args
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")
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")
def test_edit_pdf(self, m) -> None:
self.setup_mock(m, "edit_pdf")
+1 -10
View File
@@ -2905,20 +2905,18 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
v1 = SavedView.objects.get(name="test")
self.assertEqual(v1.sort_field, "created2")
self.assertEqual(v1.icon, SavedView.Icon.FUNNEL)
self.assertEqual(v1.filter_rules.count(), 1)
self.assertEqual(v1.owner, self.user)
response = self.client.patch(
f"/api/saved_views/{v1.id}/",
{"sort_reverse": True, "icon": SavedView.Icon.RECEIPT},
{"sort_reverse": True},
format="json",
)
v1 = SavedView.objects.get(id=v1.id)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(v1.sort_reverse)
self.assertEqual(v1.icon, SavedView.Icon.RECEIPT)
self.assertEqual(v1.filter_rules.count(), 1)
view["filter_rules"] = [{"rule_type": 12, "value": "secret"}]
@@ -2938,13 +2936,6 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
v1 = SavedView.objects.get(id=v1.id)
self.assertEqual(v1.filter_rules.count(), 0)
response = self.client.patch(
f"/api/saved_views/{v1.id}/",
{"icon": "not-an-icon"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_saved_view_display_options(self) -> None:
"""
GIVEN:
@@ -60,6 +60,10 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
},
"email_enabled": False,
"ai_enabled": False,
"remote_ocr": {
"configured": False,
"mode": "always",
},
},
)
@@ -154,6 +158,50 @@ class TestApiUiSettings(DirectoriesMixin, APITestCase):
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(
OAUTH_CALLBACK_BASE_URL="http://localhost:8000",
GMAIL_OAUTH_CLIENT_ID="abc123",
+191 -101
View File
@@ -351,21 +351,22 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
self.assertEqual(WorkflowTrigger.objects.count(), 1)
def test_api_create_complex_assign_title(self) -> None:
def test_api_create_invalid_assign_title(self) -> None:
"""
GIVEN:
- API request to create a workflow
- Template using Jinja flow control statements
- Invalid f-string for assign_title
WHEN:
- API is called
THEN:
- Workflow is created
- Correct HTTP 400 response
- No objects are created
"""
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow 2",
"name": "Workflow 1",
"order": 1,
"triggers": [
{
@@ -374,137 +375,235 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
],
"actions": [
{
"assign_title": '{# this is a comment #}foo{% if created_year < 2000 %}bar{% endif %}{{ "{:04d}".format(42) }}',
"assign_title": "{created_year]",
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
"Invalid f-string detected",
response.data["actions"][0]["assign_title"][0],
)
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)
self.assertEqual(Workflow.objects.count(), 2)
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}
def test_api_create_invalid_assign_title_syntax_error(self) -> None:
"""
GIVEN:
- API request to create a workflow
- Invalid template for assign_title
WHEN:
- API is called
THEN:
- Correct HTTP 400 response
- No objects are created
"""
response = self.client.post(
return self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow 2",
"name": "Apply AI suggestions",
"order": 1,
"triggers": [
{
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
},
],
"triggers": [trigger(t) for t in trigger_types],
"actions": [
{
"assign_title": "{{created_year}",
"type": WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
**action,
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
"Template syntax error",
response.data["actions"][0]["assign_title"][0],
)
self.assertEqual(Workflow.objects.count(), 1)
def test_api_create_invalid_assign_title_assertion_error(self) -> None:
def test_api_create_apply_ai_suggestions_action(self) -> None:
"""
GIVEN:
- API request to create a workflow
- Template using unknown filters for assign_title
- 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
"""
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow 2",
"order": 1,
"triggers": [
{
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
},
],
"actions": [
{
"assign_title": "{{ created_year | foo }}",
},
],
},
),
content_type="application/json",
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.assertIn(
"Template assertion error",
response.data["actions"][0]["assign_title"][0],
)
self.assertEqual(Workflow.objects.count(), existing_count)
self.assertEqual(Workflow.objects.count(), 1)
def test_api_create_invalid_assign_title_unknown_placeholder(self) -> None:
def test_api_create_apply_ai_suggestions_action_rejects_unknown_field(
self,
) -> None:
"""
GIVEN:
- API request to create a workflow
- Template with unknown placeholders for assign_title
- 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
"""
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow 2",
"order": 1,
"triggers": [
{
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
},
],
"actions": [
{
"assign_title": "{{creation_year}}",
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
"Template references unknown placeholders",
response.data["actions"][0]["assign_title"][0],
)
self.assertIn(
"creation_year",
response.data["actions"][0]["assign_title"][0],
existing_count = Workflow.objects.count()
response = self._post_ai_suggestions_workflow(
trigger_types=[WorkflowTrigger.WorkflowTriggerType.CONSUMPTION],
action={"ai_suggestion_fields": ["title"]},
)
self.assertEqual(Workflow.objects.count(), 1)
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:
"""
@@ -538,11 +637,6 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
json.dumps(
{
"assign_title": "",
"assign_custom_fields": [self.cf1.id, self.cf2.id],
"assign_custom_fields_values": {
str(self.cf1.id): "",
str(self.cf2.id): 0,
},
},
),
content_type="application/json",
@@ -550,10 +644,6 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
action = WorkflowAction.objects.get(id=response.data["id"])
self.assertIsNone(action.assign_title)
self.assertEqual(
action.assign_custom_fields_values,
{str(self.cf1.id): None, str(self.cf2.id): 0},
)
response = self.client.post(
self.ENDPOINT_TRIGGERS,
+53
View File
@@ -1782,3 +1782,56 @@ class TestPDFActions(DirectoriesMixin, TestCase):
self.assertIn("wrong password", str(exc.exception))
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"])
+80
View File
@@ -1559,6 +1559,72 @@ class PostConsumeTestCase(DirectoriesMixin, GetConsumerMixin, TestCase):
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):
def test_update_skip_asn_if_exists(self) -> None:
base = DocumentMetadataOverrides()
@@ -1566,6 +1632,20 @@ class TestMetadataOverrides(TestCase):
base.update(incoming)
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:
base = DocumentMetadataOverrides(
actor_id=1,
@@ -6,8 +6,6 @@ from datetime import timedelta
from io import StringIO
from pathlib import Path
from unittest import mock
from zipfile import ZIP_DEFLATED
from zipfile import ZIP_LZMA
from zipfile import ZipFile
import pytest
@@ -1080,197 +1078,6 @@ class TestExportImport(
skip_checks=True,
)
def test_compression_flags_require_zip(self) -> None:
"""
GIVEN:
- A request to export without --zip
WHEN:
- --zip-compression or --zip-compression-level is passed anyway
THEN:
- A CommandError is raised (the flags are meaningless without --zip)
"""
cases = {
"zip-compression": ["--zip-compression", "lzma"],
"zip-compression-level": ["--zip-compression-level", "5"],
}
for case_id, args in cases.items():
with self.subTest(case_id), self.assertRaises(CommandError):
call_command(
"document_exporter",
self.target,
*args,
skip_checks=True,
)
def test_zip_compression_level_out_of_range_raises(self) -> None:
"""
GIVEN:
- A request to export to a zip file
WHEN:
- --zip-compression-level is outside the chosen method's valid range
THEN:
- A CommandError is raised
"""
with self.assertRaises(CommandError):
call_command(
"document_exporter",
self.target,
"--zip",
"--zip-compression",
"deflated",
"--zip-compression-level",
"99",
skip_checks=True,
)
def test_zip_compression_level_rejected_for_levelless_method(self) -> None:
"""
GIVEN:
- A request to export to a zip file with a compression method
that ignores level entirely (stored, lzma)
WHEN:
- --zip-compression-level is also passed
THEN:
- A CommandError is raised
"""
for method in ("stored", "lzma"):
with self.subTest(method), self.assertRaises(CommandError):
call_command(
"document_exporter",
self.target,
"--zip",
"--zip-compression",
method,
"--zip-compression-level",
"5",
skip_checks=True,
)
def test_zstd_unavailable_raises_friendly_error(self) -> None:
"""
GIVEN:
- A Python runtime without zstd support (< 3.14)
WHEN:
- --zip-compression zstd is requested
THEN:
- A CommandError naming the Python version requirement is raised
zstd availability is mocked rather than relying on the actual
runtime: on a Python 3.14+ CI leg, ZSTD is not None, so without the
mock this check is skipped and the command falls through into the
real export, which fails on missing document files instead of
raising the expected CommandError.
"""
with (
mock.patch(
"documents.management.commands.document_exporter.ZSTD",
None,
),
mock.patch(
"documents.management.commands.document_exporter.compression_available",
return_value=False,
),
self.assertRaises(CommandError) as e,
):
call_command(
"document_exporter",
self.target,
"--zip",
"--zip-compression",
"zstd",
skip_checks=True,
)
self.assertIn("3.14", str(e.exception))
def test_non_zstd_unavailable_raises_generic_error(self) -> None:
"""
GIVEN:
- A Python runtime missing the module backing a non-zstd method
(e.g. bz2/lzma not compiled in on a minimal build)
WHEN:
- That method is requested via --zip-compression
THEN:
- A CommandError is raised naming the method, not the
zstd-specific "requires 3.14" message
"""
with (
mock.patch(
"documents.management.commands.document_exporter.compression_available",
return_value=False,
),
self.assertRaises(CommandError) as e,
):
call_command(
"document_exporter",
self.target,
"--zip",
"--zip-compression",
"bzip2",
skip_checks=True,
)
self.assertIn("bzip2", str(e.exception))
self.assertNotIn("3.14", str(e.exception))
def test_zip_compression_flag_resolves_to_sink_constant(self) -> None:
"""
GIVEN:
- A request to export to a zip file with --zip-compression lzma
WHEN:
- The export runs
THEN:
- ZipExportSink is constructed with the resolved ZIP_LZMA constant
(whether zipfile actually compresses with the chosen method is
Python's own contract, and ZipExportSink's own tests already
cover the forwarding; what this command owns is resolving the
CLI string to the right constant, so assert that resolution
directly)
"""
with mock.patch(
"documents.management.commands.document_exporter.ZipExportSink",
) as sink_cls:
call_command(
"document_exporter",
self.target,
"--zip",
"--zip-compression",
"lzma",
skip_checks=True,
)
sink_cls.assert_called_once_with(
mock.ANY,
mock.ANY,
delete=False,
compression=ZIP_LZMA,
compresslevel=None,
)
def test_default_zip_compression_resolves_to_deflate(self) -> None:
"""
GIVEN:
- A request to export to a zip file with no --zip-compression flag
WHEN:
- The export runs
THEN:
- ZipExportSink is constructed with the default ZIP_DEFLATED
constant and compresslevel=None, matching pre-existing behavior
"""
with mock.patch(
"documents.management.commands.document_exporter.ZipExportSink",
) as sink_cls:
call_command(
"document_exporter",
self.target,
"--zip",
skip_checks=True,
)
sink_cls.assert_called_once_with(
mock.ANY,
mock.ANY,
delete=False,
compression=ZIP_DEFLATED,
compresslevel=None,
)
@pytest.mark.management
class TestCryptExportImport(
+1 -41
View File
@@ -1,4 +1,3 @@
import os
from io import StringIO
from unittest.mock import patch
@@ -42,7 +41,7 @@ class TestFuzzyMatchCommand(TestCase):
def test_invalid_ratio_upper_limit(self) -> None:
"""
GIVEN:
GIVEN:s
- Invalid ratio above upper
WHEN:
- Command is called
@@ -109,45 +108,6 @@ class TestFuzzyMatchCommand(TestCase):
stdout, _ = self.call_command("--processes", "1")
self.assertIn("Found 1 matching pair(s)", stdout)
def test_with_matches_and_url(self) -> None:
"""
GIVEN:
- 2 documents exist
- Similarity between content is 86.667
- --url is provided
WHEN:
- Command is called with --url
THEN:
- 1 match is returned from doc 1 to doc 2
- No match from doc 2 to doc 1 reported
- Output contains clickable links to the documents instead of titles
"""
# Content similarity is 86.667
Document.objects.create(
checksum="BEEFCAFE",
title="A",
content="first document scanned by bob",
mime_type="application/pdf",
filename="test.pdf",
)
Document.objects.create(
checksum="DEADBEAF",
title="A",
content="first document scanned by alice",
mime_type="application/pdf",
filename="other_test.pdf",
)
with patch.dict(os.environ, {"COLUMNS": "200"}):
stdout, _ = self.call_command(
"--processes",
"1",
"--url",
"http://localhost:8000",
)
self.assertIn("Found 1 matching pair(s)", stdout)
self.assertIn("http://localhost:8000/documents/1/details", stdout)
self.assertIn("http://localhost:8000/documents/2/details", stdout)
def test_with_3_matches(self) -> None:
"""
GIVEN:

Some files were not shown because too many files have changed in this diff Show More