mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-14 06:43:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b347022c4c | ||
|
|
01c12d9ea4 | ||
|
|
f5c0d118f7 | ||
|
|
ff13847d0a | ||
|
|
634f803872 | ||
|
|
639d566a7c | ||
|
|
0a94f8f0d4 |
@@ -299,6 +299,8 @@ optional arguments:
|
||||
-sm, --split-manifest
|
||||
-z, --zip
|
||||
-zn, --zip-name
|
||||
--zip-compression
|
||||
--zip-compression-level
|
||||
--data-only
|
||||
--no-progress-bar
|
||||
--passphrase
|
||||
@@ -361,6 +363,19 @@ 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: 0–9, bzip2: 1–9, zstd: -22–22; 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.
|
||||
|
||||
|
||||
@@ -2048,18 +2048,6 @@ 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}
|
||||
|
||||
@@ -456,20 +456,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
# 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 1–2 (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 1–2) 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 1–4 (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 1–4.
|
||||
|
||||
- [ ] **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 1–4 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"
|
||||
```
|
||||
@@ -0,0 +1,158 @@
|
||||
# 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.
|
||||
+1
-6
@@ -1086,16 +1086,11 @@ Paperless-ngx supports performing OCR on documents using remote services. At the
|
||||
[Microsoft's Azure "Document Intelligence" service](https://azure.microsoft.com/en-us/products/ai-services/ai-document-intelligence).
|
||||
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. These
|
||||
settings can be supplied as environment variables or via **Application Configuration**.
|
||||
the local OCR engine. See the [configuration](configuration.md#PAPERLESS_REMOTE_OCR_ENGINE) options for more details.
|
||||
|
||||
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 workflow explicitly enables remote OCR for them, so you can limit the remote engine to particular documents.
|
||||
|
||||
## Architecture
|
||||
|
||||
Paperless-ngx consists of the following components:
|
||||
|
||||
+53
-53
@@ -539,7 +539,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context>
|
||||
<context context-type="linenumber">47</context>
|
||||
<context context-type="linenumber">54</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
|
||||
@@ -3098,15 +3098,15 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">919</context>
|
||||
<context context-type="linenumber">910</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">955</context>
|
||||
<context context-type="linenumber">946</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">978</context>
|
||||
<context context-type="linenumber">969</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context>
|
||||
@@ -3684,14 +3684,14 @@
|
||||
<source>Confirmation</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context>
|
||||
<context context-type="linenumber">23</context>
|
||||
<context context-type="linenumber">30</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="9178182467454450952" datatype="html">
|
||||
<source>Confirm</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/confirm-dialog/confirm-dialog.component.ts</context>
|
||||
<context context-type="linenumber">35</context>
|
||||
<context context-type="linenumber">42</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
|
||||
@@ -3703,27 +3703,27 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">556</context>
|
||||
<context context-type="linenumber">547</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">596</context>
|
||||
<context context-type="linenumber">587</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">634</context>
|
||||
<context context-type="linenumber">625</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">672</context>
|
||||
<context context-type="linenumber">663</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">734</context>
|
||||
<context context-type="linenumber">725</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">867</context>
|
||||
<context context-type="linenumber">858</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="994016933065248559" datatype="html">
|
||||
@@ -5743,7 +5743,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">871</context>
|
||||
<context context-type="linenumber">862</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4522609911791833187" datatype="html">
|
||||
@@ -7613,7 +7613,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">501</context>
|
||||
<context context-type="linenumber">492</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">this string is used to separate processing, failed and added on the file upload widget</note>
|
||||
</trans-unit>
|
||||
@@ -8148,7 +8148,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">869</context>
|
||||
<context context-type="linenumber">860</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7295637485862454066" datatype="html">
|
||||
@@ -8166,7 +8166,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">915</context>
|
||||
<context context-type="linenumber">906</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2951161989614003846" datatype="html">
|
||||
@@ -8523,18 +8523,18 @@
|
||||
<source>"<x id="PH" equiv-text="items[0].name"/>"</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">493</context>
|
||||
<context context-type="linenumber">484</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">499</context>
|
||||
<context context-type="linenumber">490</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8639884465898458690" datatype="html">
|
||||
<source>"<x id="PH" equiv-text="items[0].name"/>" and "<x id="PH_1" equiv-text="items[1].name"/>"</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">495</context>
|
||||
<context context-type="linenumber">486</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">This is for messages like 'modify "tag1" and "tag2"'</note>
|
||||
</trans-unit>
|
||||
@@ -8542,7 +8542,7 @@
|
||||
<source><x id="PH" equiv-text="list"/> and "<x id="PH_1" equiv-text="items[items.length - 1].name"/>"</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">503,505</context>
|
||||
<context context-type="linenumber">494,496</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">this is for messages like 'modify "tag1", "tag2" and "tag3"'</note>
|
||||
</trans-unit>
|
||||
@@ -8550,14 +8550,14 @@
|
||||
<source>Confirm tags assignment</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">520</context>
|
||||
<context context-type="linenumber">511</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6619516195038467207" datatype="html">
|
||||
<source>This operation will add the tag "<x id="PH" equiv-text="tag.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">526</context>
|
||||
<context context-type="linenumber">517</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1894412783609570695" datatype="html">
|
||||
@@ -8566,14 +8566,14 @@
|
||||
)"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">531,533</context>
|
||||
<context context-type="linenumber">522,524</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7181166515756808573" datatype="html">
|
||||
<source>This operation will remove the tag "<x id="PH" equiv-text="tag.name"/>" from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">539</context>
|
||||
<context context-type="linenumber">530</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3819792277998068944" datatype="html">
|
||||
@@ -8582,7 +8582,7 @@
|
||||
)"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">544,546</context>
|
||||
<context context-type="linenumber">535,537</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2739066218579571288" datatype="html">
|
||||
@@ -8593,84 +8593,84 @@
|
||||
)"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">548,552</context>
|
||||
<context context-type="linenumber">539,543</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2996713129519325161" datatype="html">
|
||||
<source>Confirm correspondent assignment</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">589</context>
|
||||
<context context-type="linenumber">580</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6900893559485781849" datatype="html">
|
||||
<source>This operation will assign the correspondent "<x id="PH" equiv-text="correspondent.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">591</context>
|
||||
<context context-type="linenumber">582</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1257522660364398440" datatype="html">
|
||||
<source>This operation will remove the correspondent from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">593</context>
|
||||
<context context-type="linenumber">584</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5393409374423140648" datatype="html">
|
||||
<source>Confirm document type assignment</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">627</context>
|
||||
<context context-type="linenumber">618</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="332180123895325027" datatype="html">
|
||||
<source>This operation will assign the document type "<x id="PH" equiv-text="documentType.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">629</context>
|
||||
<context context-type="linenumber">620</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2236642492594872779" datatype="html">
|
||||
<source>This operation will remove the document type from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">631</context>
|
||||
<context context-type="linenumber">622</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6386555513013840736" datatype="html">
|
||||
<source>Confirm storage path assignment</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">665</context>
|
||||
<context context-type="linenumber">656</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8750527458618415924" datatype="html">
|
||||
<source>This operation will assign the storage path "<x id="PH" equiv-text="storagePath.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">667</context>
|
||||
<context context-type="linenumber">658</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="60728365335056946" datatype="html">
|
||||
<source>This operation will remove the storage path from <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">669</context>
|
||||
<context context-type="linenumber">660</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4187352575310415704" datatype="html">
|
||||
<source>Confirm custom field assignment</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">698</context>
|
||||
<context context-type="linenumber">689</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7966494636326273856" datatype="html">
|
||||
<source>This operation will assign the custom field "<x id="PH" equiv-text="customField.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">704</context>
|
||||
<context context-type="linenumber">695</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5789455969634598553" datatype="html">
|
||||
@@ -8679,14 +8679,14 @@
|
||||
)"/> to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">709,711</context>
|
||||
<context context-type="linenumber">700,702</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5648572354333199245" datatype="html">
|
||||
<source>This operation will remove the custom field "<x id="PH" equiv-text="customField.name"/>" from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">717</context>
|
||||
<context context-type="linenumber">708</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6666899594015948817" datatype="html">
|
||||
@@ -8695,7 +8695,7 @@
|
||||
)"/> from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">722,724</context>
|
||||
<context context-type="linenumber">713,715</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8050047262594964176" datatype="html">
|
||||
@@ -8706,91 +8706,91 @@
|
||||
)"/> on <x id="PH_2" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">726,730</context>
|
||||
<context context-type="linenumber">717,721</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8615059324209654051" datatype="html">
|
||||
<source>Move <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s) to the trash?</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">868</context>
|
||||
<context context-type="linenumber">859</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8585195717323764335" datatype="html">
|
||||
<source>This operation will permanently recreate the archive files for <x id="PH" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">916</context>
|
||||
<context context-type="linenumber">907</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7366623494074776040" datatype="html">
|
||||
<source>The archive files will be re-generated with the current settings.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">917</context>
|
||||
<context context-type="linenumber">908</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6555329262222566158" datatype="html">
|
||||
<source>Rotate confirm</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">952</context>
|
||||
<context context-type="linenumber">943</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5203024009814367559" datatype="html">
|
||||
<source>This operation will add rotated versions of the <x id="PH" equiv-text="this.getSelectionSize()"/> document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">953</context>
|
||||
<context context-type="linenumber">944</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7910756456450124185" datatype="html">
|
||||
<source>Merge confirm</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">976</context>
|
||||
<context context-type="linenumber">967</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7643543647233874431" datatype="html">
|
||||
<source>This operation will merge <x id="PH" equiv-text="this.getSelectionSize()"/> selected documents into a new document.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">977</context>
|
||||
<context context-type="linenumber">968</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7869008840945899895" datatype="html">
|
||||
<source>Merged document will be queued for consumption.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">1000</context>
|
||||
<context context-type="linenumber">991</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="476913782630693351" datatype="html">
|
||||
<source>Custom fields updated.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">1025</context>
|
||||
<context context-type="linenumber">1016</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3873496751167944011" datatype="html">
|
||||
<source>Error updating custom fields.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">1034</context>
|
||||
<context context-type="linenumber">1025</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6144801143088984138" datatype="html">
|
||||
<source>Share link bundle creation requested.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">1082</context>
|
||||
<context context-type="linenumber">1073</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="46019676931295023" datatype="html">
|
||||
<source>Share link bundle creation is not available yet.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">1089</context>
|
||||
<context context-type="linenumber">1080</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6307402210351946694" datatype="html">
|
||||
|
||||
@@ -14,48 +14,43 @@
|
||||
<a ngbNavLink>{{category}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="p-3">
|
||||
@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 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>
|
||||
}
|
||||
</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,11 +8,7 @@ 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 {
|
||||
ConfigCategory,
|
||||
ConfigSection,
|
||||
OutputTypeConfig,
|
||||
} from 'src/app/data/paperless-config'
|
||||
import { 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'
|
||||
@@ -162,24 +158,4 @@ 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,20 +74,8 @@ export class ConfigComponent
|
||||
return Object.values(ConfigCategory)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
getCategoryOptions(category: string): ConfigOption[] {
|
||||
return PaperlessConfigOptions.filter((o) => o.category === category)
|
||||
}
|
||||
|
||||
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 = false
|
||||
modal.componentInstance.buttonsEnabled.set(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 = true
|
||||
modal.componentInstance.buttonsEnabled.set(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 = false
|
||||
modal.componentInstance.buttonsEnabled.set(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 = true
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
},
|
||||
})
|
||||
this.clearSelection()
|
||||
|
||||
@@ -82,7 +82,7 @@ export class TrashComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(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 = false
|
||||
modal.componentInstance.buttonsEnabled.set(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 = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.groupsService.delete(group).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
|
||||
@@ -47,11 +47,12 @@
|
||||
|
||||
.search-container {
|
||||
max-height: 4.5rem;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
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,6 +64,22 @@ 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,5 +1,12 @@
|
||||
import { DecimalPipe } from '@angular/common'
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject } from 'rxjs'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
@@ -46,8 +53,7 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
|
||||
@Input()
|
||||
cancelBtnCaption = $localize`Cancel`
|
||||
|
||||
@Input()
|
||||
buttonsEnabled = true
|
||||
readonly buttonsEnabled = signal(true)
|
||||
|
||||
confirmButtonEnabled = true
|
||||
alternativeButtonEnabled = true
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
}
|
||||
|
||||
+1
-1
@@ -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({
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
|
||||
: FileVersion.Original,
|
||||
expiration_days: this.form.value.expirationDays,
|
||||
}
|
||||
this.buttonsEnabled = false
|
||||
this.buttonsEnabled.set(false)
|
||||
super.confirm()
|
||||
}
|
||||
|
||||
|
||||
@@ -1564,7 +1564,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 +1789,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', () => {
|
||||
|
||||
@@ -659,7 +659,7 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
|
||||
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.close()
|
||||
this.reloadRemoteVersion()
|
||||
})
|
||||
@@ -1374,7 +1374,7 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(
|
||||
switchMap(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
return this.documentsService.delete(this.document())
|
||||
})
|
||||
)
|
||||
@@ -1386,7 +1386,7 @@ export class DocumentDetailComponent
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error deleting document`, error)
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
this.subscribeModalDelete(modal)
|
||||
},
|
||||
})
|
||||
@@ -1411,7 +1411,7 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.documentsService
|
||||
.reprocessDocuments({ documents: [this.document().id] })
|
||||
.subscribe({
|
||||
@@ -1425,7 +1425,7 @@ export class DocumentDetailComponent
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing operation`,
|
||||
@@ -1798,7 +1798,7 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.documentsService
|
||||
.editPdfDocuments([sourceDocumentId], {
|
||||
operations: modal.componentInstance.getOperations(),
|
||||
@@ -1821,7 +1821,7 @@ export class DocumentDetailComponent
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing PDF edit operation`,
|
||||
@@ -1855,7 +1855,7 @@ export class DocumentDetailComponent
|
||||
const sourceDocumentId = this.selectedVersionId() ?? this.document().id
|
||||
const dialog =
|
||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
||||
dialog.buttonsEnabled = false
|
||||
dialog.buttonsEnabled.set(false)
|
||||
this.networkActive.set(true)
|
||||
this.documentsService
|
||||
.removePasswordDocuments([sourceDocumentId], {
|
||||
@@ -1880,7 +1880,7 @@ export class DocumentDetailComponent
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
dialog.buttonsEnabled = true
|
||||
dialog.buttonsEnabled.set(true)
|
||||
this.networkActive.set(false)
|
||||
this.toastService.showError(
|
||||
$localize`Error executing password removal operation`,
|
||||
|
||||
@@ -1683,7 +1683,7 @@ describe('BulkEditorComponent', () => {
|
||||
expiration_days: 7,
|
||||
},
|
||||
loading: signal(false),
|
||||
buttonsEnabled: true,
|
||||
buttonsEnabled: signal(true),
|
||||
copied: signal(false),
|
||||
},
|
||||
}
|
||||
@@ -1715,7 +1715,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 +1755,7 @@ describe('BulkEditorComponent', () => {
|
||||
expiration_days: null,
|
||||
},
|
||||
loading: signal(false),
|
||||
buttonsEnabled: true,
|
||||
buttonsEnabled: signal(true),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1777,7 +1777,7 @@ describe('BulkEditorComponent', () => {
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(dialogInstance.loading()).toBe(false)
|
||||
expect(dialogInstance.buttonsEnabled).toBe(true)
|
||||
expect(dialogInstance.buttonsEnabled()).toBe(true)
|
||||
openSpy.mockRestore()
|
||||
})
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ export class BulkEditorComponent
|
||||
overrideSelection?: DocumentSelectionQuery
|
||||
) {
|
||||
if (modal) {
|
||||
this.setModalButtonsEnabled(modal, false)
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
}
|
||||
this.documentService
|
||||
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
|
||||
@@ -290,7 +290,7 @@ export class BulkEditorComponent
|
||||
options: { deleteOriginals?: boolean } = {}
|
||||
) {
|
||||
if (modal) {
|
||||
this.setModalButtonsEnabled(modal, false)
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
}
|
||||
request.pipe(first()).subscribe({
|
||||
next: () => {
|
||||
@@ -320,7 +320,7 @@ export class BulkEditorComponent
|
||||
|
||||
private handleOperationError(modal: NgbModalRef, error: any) {
|
||||
if (modal) {
|
||||
this.setModalButtonsEnabled(modal, true)
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
}
|
||||
this.toastService.showError(
|
||||
$localize`Error executing bulk operation`,
|
||||
@@ -328,15 +328,6 @@ 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
|
||||
@@ -872,7 +863,7 @@ export class BulkEditorComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.deleteDocuments(this.getSelectionQuery())
|
||||
@@ -920,7 +911,7 @@ export class BulkEditorComponent
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.reprocessDocuments(this.getSelectionQuery())
|
||||
@@ -957,7 +948,7 @@ export class BulkEditorComponent
|
||||
rotateDialog.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
rotateDialog.buttonsEnabled = false
|
||||
rotateDialog.buttonsEnabled.set(false)
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.rotateDocuments(
|
||||
@@ -990,7 +981,7 @@ export class BulkEditorComponent
|
||||
if (mergeDialog.archiveFallback()) {
|
||||
args.archive_fallback = true
|
||||
}
|
||||
mergeDialog.buttonsEnabled = false
|
||||
mergeDialog.buttonsEnabled.set(false)
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
|
||||
@@ -1063,14 +1054,14 @@ export class BulkEditorComponent
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
dialog.loading.set(true)
|
||||
dialog.buttonsEnabled = false
|
||||
dialog.buttonsEnabled.set(false)
|
||||
this.shareLinkBundleService
|
||||
.createBundle(dialog.payload)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
dialog.loading.set(false)
|
||||
dialog.buttonsEnabled = false
|
||||
dialog.buttonsEnabled.set(false)
|
||||
dialog.createdBundle = result
|
||||
dialog.copied.set(false)
|
||||
dialog.payload = null
|
||||
@@ -1084,7 +1075,7 @@ export class BulkEditorComponent
|
||||
},
|
||||
error: (error) => {
|
||||
dialog.loading.set(false)
|
||||
dialog.buttonsEnabled = true
|
||||
dialog.buttonsEnabled.set(true)
|
||||
this.toastService.showError(
|
||||
$localize`Share link bundle creation is not available yet.`,
|
||||
error
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ export class CustomFieldsComponent
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.customFieldsService.delete(field).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
|
||||
+4
-4
@@ -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 = false
|
||||
activeModal.componentInstance.buttonsEnabled.set(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 = true
|
||||
activeModal.componentInstance.buttonsEnabled.set(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 = false
|
||||
modal.componentInstance.buttonsEnabled.set(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 = true
|
||||
modal.componentInstance.buttonsEnabled.set(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 = false
|
||||
modal.componentInstance.buttonsEnabled.set(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 = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.mailRuleService.delete(rule).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
|
||||
@@ -134,7 +134,7 @@ export class WorkflowsComponent
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.workflowService.delete(workflow).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
|
||||
@@ -54,10 +54,6 @@ export const ConfigCategory = {
|
||||
AI: $localize`AI Settings`,
|
||||
}
|
||||
|
||||
export const ConfigSection = {
|
||||
RemoteOCR: $localize`Remote OCR`,
|
||||
}
|
||||
|
||||
export const LLMEmbeddingBackendConfig = {
|
||||
OPENAI_LIKE: 'openai-like',
|
||||
HUGGINGFACE: 'huggingface',
|
||||
@@ -69,15 +65,6 @@ 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
|
||||
@@ -85,7 +72,6 @@ export interface ConfigOption {
|
||||
choices?: Array<{ id: string; name: string }>
|
||||
config_key?: string
|
||||
category: string
|
||||
section?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
@@ -195,43 +181,6 @@ 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`,
|
||||
@@ -449,10 +398,6 @@ 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
|
||||
|
||||
@@ -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 = false
|
||||
modal.componentInstance.buttonsEnabled.set(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 = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
component.saveViewConfig()
|
||||
modal.close()
|
||||
})
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.componentInstance.buttonsEnabled.set(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 = false
|
||||
modal.componentInstance.buttonsEnabled.set(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 = false
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
modal.close()
|
||||
this.openDocuments.splice(0, this.openDocuments.length)
|
||||
this.dirtyDocuments.clear()
|
||||
|
||||
@@ -53,7 +53,6 @@ from documents.utils import copy_basic_file_stats
|
||||
from documents.utils import copy_file_with_basic_stats
|
||||
from documents.utils import 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
|
||||
@@ -452,19 +451,12 @@ 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:
|
||||
|
||||
@@ -34,7 +34,6 @@ 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":
|
||||
"""
|
||||
@@ -58,8 +57,6 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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
|
||||
@@ -243,11 +243,21 @@ 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) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
target: Path,
|
||||
zip_name: str,
|
||||
*,
|
||||
delete: bool = False,
|
||||
compression: int = zipfile.ZIP_DEFLATED,
|
||||
compresslevel: int | None = None,
|
||||
) -> 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
|
||||
@@ -258,7 +268,8 @@ class ZipExportSink(ExportSink):
|
||||
self._zip = zipfile.ZipFile(
|
||||
self._tmp_path,
|
||||
"w",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compression=self._compression,
|
||||
compresslevel=self._compresslevel,
|
||||
allowZip64=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -29,6 +29,11 @@ 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
|
||||
@@ -192,6 +197,28 @@ 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,
|
||||
@@ -247,12 +274,39 @@ 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(
|
||||
|
||||
@@ -32,6 +32,8 @@ 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
|
||||
@@ -460,6 +462,20 @@ 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()
|
||||
|
||||
@@ -85,6 +85,7 @@ 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
|
||||
|
||||
@@ -3185,33 +3186,10 @@ class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
|
||||
attrs["assign_title"] = None
|
||||
else:
|
||||
try:
|
||||
# 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="",
|
||||
)
|
||||
validate_workflow_template(attrs["assign_title"])
|
||||
except (ValueError, KeyError) as e:
|
||||
raise serializers.ValidationError(
|
||||
{"assign_title": f'Invalid f-string detected: "{e.args[0]}"'},
|
||||
{"assign_title": f"{e.args[0]}"},
|
||||
)
|
||||
|
||||
if attrs.get("assign_custom_fields_values"):
|
||||
|
||||
+1
-10
@@ -66,7 +66,6 @@ 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
|
||||
@@ -338,17 +337,10 @@ def bulk_update_documents(document_ids) -> None:
|
||||
|
||||
|
||||
@shared_task
|
||||
def update_document_content_maybe_archive_file(
|
||||
document_id,
|
||||
*,
|
||||
remote_ocr: bool = False,
|
||||
) -> None:
|
||||
def update_document_content_maybe_archive_file(document_id) -> 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)
|
||||
|
||||
@@ -358,7 +350,6 @@ def update_document_content_maybe_archive_file(
|
||||
mime_type,
|
||||
document.original_filename or "",
|
||||
document.source_path,
|
||||
allow_remote=remote_ocr or RemoteOCRConfig().remote_ocr_by_default,
|
||||
)
|
||||
|
||||
if not parser_class:
|
||||
|
||||
@@ -6,9 +6,11 @@ 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
|
||||
@@ -29,6 +31,49 @@ _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,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
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"}
|
||||
@@ -5,6 +5,7 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pytest_mock
|
||||
from pytest_django.fixtures import SettingsWrapper
|
||||
|
||||
from documents.export.sinks import DirectoryExportSink
|
||||
@@ -305,6 +306,48 @@ 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,10 +72,6 @@ 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,
|
||||
@@ -874,49 +870,6 @@ 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:
|
||||
|
||||
@@ -351,11 +351,45 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
|
||||
self.assertEqual(WorkflowTrigger.objects.count(), 1)
|
||||
|
||||
def test_api_create_invalid_assign_title(self) -> None:
|
||||
def test_api_create_complex_assign_title(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a workflow
|
||||
- Invalid f-string for assign_title
|
||||
- Template using Jinja flow control statements
|
||||
WHEN:
|
||||
- API is called
|
||||
THEN:
|
||||
- Workflow is created
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Workflow 2",
|
||||
"order": 1,
|
||||
"triggers": [
|
||||
{
|
||||
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||
},
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"assign_title": '{# this is a comment #}foo{% if created_year < 2000 %}bar{% endif %}{{ "{:04d}".format(42) }}',
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
self.assertEqual(Workflow.objects.count(), 2)
|
||||
|
||||
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:
|
||||
@@ -366,7 +400,7 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
self.ENDPOINT,
|
||||
json.dumps(
|
||||
{
|
||||
"name": "Workflow 1",
|
||||
"name": "Workflow 2",
|
||||
"order": 1,
|
||||
"triggers": [
|
||||
{
|
||||
@@ -375,7 +409,7 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"assign_title": "{created_year]",
|
||||
"assign_title": "{{created_year}",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -384,7 +418,89 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(
|
||||
"Invalid f-string detected",
|
||||
"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:
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a workflow
|
||||
- Template using unknown filters for assign_title
|
||||
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",
|
||||
)
|
||||
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(), 1)
|
||||
|
||||
def test_api_create_invalid_assign_title_unknown_placeholder(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- API request to create a workflow
|
||||
- Template with unknown placeholders for assign_title
|
||||
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],
|
||||
)
|
||||
|
||||
|
||||
@@ -1559,72 +1559,6 @@ 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()
|
||||
@@ -1632,20 +1566,6 @@ 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,6 +6,8 @@ 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
|
||||
@@ -1078,6 +1080,197 @@ 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(
|
||||
|
||||
@@ -525,6 +525,71 @@ class TestCommandImport(
|
||||
self.assertEqual(doc.tags.count(), 1)
|
||||
self.assertEqual(doc.tags.first().name, "batch-flush-tag")
|
||||
|
||||
def test_import_rejects_unreadable_compression(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A zip archive with an entry whose compression this Python can't read
|
||||
WHEN:
|
||||
- Import is attempted
|
||||
THEN:
|
||||
- A CommandError naming the issue is raised, before extraction
|
||||
"""
|
||||
import zipfile
|
||||
from unittest import mock
|
||||
|
||||
archive = Path(self.dirs.scratch_dir) / "export.zip"
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.writestr("manifest.json", "[]")
|
||||
|
||||
with mock.patch(
|
||||
"documents.management.commands.document_importer.compress_type_readable",
|
||||
return_value=False,
|
||||
):
|
||||
with self.assertRaises(CommandError) as e:
|
||||
call_command(
|
||||
"document_importer",
|
||||
str(archive),
|
||||
"--no-progress-bar",
|
||||
skip_checks=True,
|
||||
)
|
||||
self.assertIn("compression", str(e.exception))
|
||||
|
||||
def test_import_rejects_unreadable_zstd_with_version_hint(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A zip archive with an entry compressed with zstd
|
||||
WHEN:
|
||||
- Import is attempted on a Python runtime that can't read zstd
|
||||
THEN:
|
||||
- The CommandError names the 3.14+ requirement, not just the
|
||||
generic "can't read" message
|
||||
"""
|
||||
import zipfile
|
||||
from unittest import mock
|
||||
|
||||
archive = Path(self.dirs.scratch_dir) / "export.zip"
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.writestr("manifest.json", "[]")
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"documents.management.commands.document_importer.compress_type_readable",
|
||||
return_value=False,
|
||||
),
|
||||
mock.patch(
|
||||
"documents.management.commands.document_importer.unreadable_method_names",
|
||||
return_value={"zstd"},
|
||||
),
|
||||
):
|
||||
with self.assertRaises(CommandError) as e:
|
||||
call_command(
|
||||
"document_importer",
|
||||
str(archive),
|
||||
"--no-progress-bar",
|
||||
skip_checks=True,
|
||||
)
|
||||
self.assertIn("3.14", str(e.exception))
|
||||
|
||||
|
||||
@pytest.mark.management
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -287,45 +287,6 @@ class TestUpdateContent(DirectoriesMixin, TestCase):
|
||||
self.assertNotEqual(Document.objects.get(pk=doc.pk).content, "test")
|
||||
|
||||
|
||||
class TestUpdateContentRemoteOCR(DirectoriesMixin, TestCase):
|
||||
"""
|
||||
Consumption workflows do not run on reprocess, so the remote parser is
|
||||
used only in 'always' mode or when the caller explicitly asks for it.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
patcher = mock.patch("documents.tasks.get_parser_registry")
|
||||
self.mock_registry = patcher.start()
|
||||
self.mock_registry.return_value.get_parser_for_file.return_value = None
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
self.doc = Document.objects.create(
|
||||
title="test",
|
||||
content="my document",
|
||||
checksum="wow",
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
|
||||
def _allow_remote(self, **kwargs) -> bool:
|
||||
tasks.update_document_content_maybe_archive_file(self.doc.pk, **kwargs)
|
||||
_, call_kwargs = self.mock_registry.return_value.get_parser_for_file.call_args
|
||||
return call_kwargs["allow_remote"]
|
||||
|
||||
@override_settings(REMOTE_OCR_MODE="always")
|
||||
def test_always_mode_allows_remote(self) -> None:
|
||||
self.assertTrue(self._allow_remote())
|
||||
|
||||
@override_settings(REMOTE_OCR_MODE="workflow_only")
|
||||
def test_workflow_only_mode_denies_remote_by_default(self) -> None:
|
||||
self.assertFalse(self._allow_remote())
|
||||
|
||||
@override_settings(REMOTE_OCR_MODE="workflow_only")
|
||||
def test_workflow_only_mode_allows_remote_when_requested(self) -> None:
|
||||
self.assertTrue(self._allow_remote(remote_ocr=True))
|
||||
|
||||
|
||||
class TestAIIndex(DirectoriesMixin, TestCase):
|
||||
@override_settings(
|
||||
AI_ENABLED=True,
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-12 19:04+0000\n"
|
||||
"POT-Creation-Date: 2026-08-13 19:47+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -1575,49 +1575,49 @@ msgstr ""
|
||||
msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:521 documents/serialisers.py:873
|
||||
#: documents/serialisers.py:2768 documents/views.py:299 documents/views.py:2555
|
||||
#: documents/serialisers.py:522 documents/serialisers.py:874
|
||||
#: documents/serialisers.py:2769 documents/views.py:299 documents/views.py:2555
|
||||
#: paperless_mail/serialisers.py:155
|
||||
msgid "Insufficient permissions."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:709
|
||||
#: documents/serialisers.py:710
|
||||
msgid "Invalid color."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2245
|
||||
#: documents/serialisers.py:2246
|
||||
#, python-format
|
||||
msgid "File type %(type)s not supported"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2289
|
||||
#: documents/serialisers.py:2290
|
||||
#, python-format
|
||||
msgid "Custom field id must be an integer: %(id)s"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2296
|
||||
#: documents/serialisers.py:2297
|
||||
#, python-format
|
||||
msgid "Custom field with id %(id)s does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2313 documents/serialisers.py:2323
|
||||
#: documents/serialisers.py:2314 documents/serialisers.py:2324
|
||||
msgid ""
|
||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2318
|
||||
#: documents/serialisers.py:2319
|
||||
msgid "Some custom fields don't exist or were specified twice."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2465
|
||||
#: documents/serialisers.py:2466
|
||||
msgid "Invalid variable detected."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2824
|
||||
#: documents/serialisers.py:2825
|
||||
msgid "Duplicate document identifiers are not allowed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2854 documents/views.py:4509
|
||||
#: documents/serialisers.py:2855 documents/views.py:4509
|
||||
#, python-format
|
||||
msgid "Documents not found: %(ids)s"
|
||||
msgstr ""
|
||||
|
||||
@@ -338,16 +338,13 @@ def check_deprecated_v2_ocr_env_vars(
|
||||
|
||||
|
||||
@register()
|
||||
def check_remote_ocr_mode(app_configs: Any, **kwargs: Any) -> list[Error]:
|
||||
# Import here because checks.py runs before the app registry is ready
|
||||
from paperless.models import RemoteOCRMode
|
||||
|
||||
valid_modes = {mode.value for mode in RemoteOCRMode}
|
||||
if settings.REMOTE_OCR_MODE not in valid_modes:
|
||||
def check_remote_parser_configured(app_configs: Any, **kwargs: Any) -> list[Error]:
|
||||
if settings.REMOTE_OCR_ENGINE == "azureai" and not (
|
||||
settings.REMOTE_OCR_ENDPOINT and settings.REMOTE_OCR_API_KEY
|
||||
):
|
||||
return [
|
||||
Error(
|
||||
f"PAPERLESS_REMOTE_OCR_MODE is set to {settings.REMOTE_OCR_MODE!r}, "
|
||||
f"expected one of {sorted(valid_modes)}.",
|
||||
"Azure AI remote parser requires endpoint and API key to be configured.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from paperless.models import CleanChoices
|
||||
from paperless.models import ColorConvertChoices
|
||||
from paperless.models import ModeChoices
|
||||
from paperless.models import OutputTypeChoices
|
||||
from paperless.models import RemoteOCRMode
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -186,45 +185,6 @@ class GeneralConfig(BaseConfig):
|
||||
self.app_logo = app_config.app_logo.url if app_config.app_logo else None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class RemoteOCRConfig(BaseConfig):
|
||||
"""
|
||||
Settings for the remote (cloud) OCR parser
|
||||
"""
|
||||
|
||||
remote_ocr_engine: str | None = dataclasses.field(init=False)
|
||||
remote_ocr_api_key: str | None = dataclasses.field(init=False)
|
||||
remote_ocr_endpoint: str | None = dataclasses.field(init=False)
|
||||
remote_ocr_mode: RemoteOCRMode = dataclasses.field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
app_config = self._get_config_instance()
|
||||
|
||||
self.remote_ocr_engine = (
|
||||
app_config.remote_ocr_engine or settings.REMOTE_OCR_ENGINE
|
||||
)
|
||||
self.remote_ocr_api_key = (
|
||||
app_config.remote_ocr_api_key or settings.REMOTE_OCR_API_KEY
|
||||
)
|
||||
self.remote_ocr_endpoint = (
|
||||
app_config.remote_ocr_endpoint or settings.REMOTE_OCR_ENDPOINT
|
||||
)
|
||||
self.remote_ocr_mode = app_config.remote_ocr_mode or RemoteOCRMode(
|
||||
settings.REMOTE_OCR_MODE,
|
||||
)
|
||||
|
||||
@property
|
||||
def remote_ocr_by_default(self) -> bool:
|
||||
"""
|
||||
Whether every supported document goes to the remote engine.
|
||||
|
||||
When False the remote engine is used only for documents that
|
||||
explicitly asked for it, i.e. a workflow matched during consumption or
|
||||
the user ticked the box when reprocessing.
|
||||
"""
|
||||
return self.remote_ocr_mode == RemoteOCRMode.ALWAYS
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AIConfig(BaseConfig):
|
||||
"""
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Generated by Django 5.2.16 on 2026-08-10 14:37
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless", "0013_applicationconfiguration_llm_request_timeout"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="applicationconfiguration",
|
||||
name="remote_ocr_api_key",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
max_length=1024,
|
||||
null=True,
|
||||
verbose_name="Sets the remote OCR API key",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="applicationconfiguration",
|
||||
name="remote_ocr_endpoint",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
max_length=256,
|
||||
null=True,
|
||||
verbose_name="Sets the remote OCR endpoint",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="applicationconfiguration",
|
||||
name="remote_ocr_engine",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[("azureai", "Azure AI Document Intelligence")],
|
||||
max_length=32,
|
||||
null=True,
|
||||
verbose_name="Sets the remote OCR engine",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
# Generated by Django 5.2.16 on 2026-08-10 15:43
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("paperless", "0014_applicationconfiguration_remote_ocr_api_key_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="applicationconfiguration",
|
||||
name="remote_ocr_mode",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("always", "All supported documents"),
|
||||
("workflow_only", "Only when a workflow enables it"),
|
||||
],
|
||||
max_length=32,
|
||||
null=True,
|
||||
verbose_name="Sets which documents are sent to the remote OCR engine",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -74,23 +74,6 @@ class ColorConvertChoices(models.TextChoices):
|
||||
CMYK = ("CMYK", _("CMYK"))
|
||||
|
||||
|
||||
class RemoteOCREngine(models.TextChoices):
|
||||
"""
|
||||
Matches to PAPERLESS_REMOTE_OCR_ENGINE
|
||||
"""
|
||||
|
||||
AZURE_AI = ("azureai", _("Azure AI Document Intelligence"))
|
||||
|
||||
|
||||
class RemoteOCRMode(models.TextChoices):
|
||||
"""
|
||||
Matches to PAPERLESS_REMOTE_OCR_MODE
|
||||
"""
|
||||
|
||||
ALWAYS = ("always", _("All supported documents"))
|
||||
WORKFLOW_ONLY = ("workflow_only", _("Only when a workflow enables it"))
|
||||
|
||||
|
||||
class LLMEmbeddingBackend(models.TextChoices):
|
||||
OPENAI_LIKE = ("openai-like", _("OpenAI-compatible"))
|
||||
HUGGINGFACE = ("huggingface", _("Huggingface"))
|
||||
@@ -303,44 +286,6 @@ class ApplicationConfiguration(AbstractSingletonModel):
|
||||
null=True,
|
||||
)
|
||||
|
||||
"""
|
||||
Settings for the remote OCR parser
|
||||
"""
|
||||
|
||||
# PAPERLESS_REMOTE_OCR_ENGINE
|
||||
remote_ocr_engine = models.CharField(
|
||||
verbose_name=_("Sets the remote OCR engine"),
|
||||
blank=True,
|
||||
null=True,
|
||||
max_length=32,
|
||||
choices=RemoteOCREngine.choices,
|
||||
)
|
||||
|
||||
# PAPERLESS_REMOTE_OCR_API_KEY
|
||||
remote_ocr_api_key = models.CharField(
|
||||
verbose_name=_("Sets the remote OCR API key"),
|
||||
blank=True,
|
||||
null=True,
|
||||
max_length=1024,
|
||||
)
|
||||
|
||||
# PAPERLESS_REMOTE_OCR_ENDPOINT
|
||||
remote_ocr_endpoint = models.CharField(
|
||||
verbose_name=_("Sets the remote OCR endpoint"),
|
||||
blank=True,
|
||||
null=True,
|
||||
max_length=256,
|
||||
)
|
||||
|
||||
# PAPERLESS_REMOTE_OCR_MODE
|
||||
remote_ocr_mode = models.CharField(
|
||||
verbose_name=_("Sets which documents are sent to the remote OCR engine"),
|
||||
blank=True,
|
||||
null=True,
|
||||
max_length=32,
|
||||
choices=RemoteOCRMode.choices,
|
||||
)
|
||||
|
||||
"""
|
||||
AI related settings
|
||||
"""
|
||||
|
||||
@@ -134,11 +134,6 @@ class ParserProtocol(Protocol):
|
||||
Author or organisation name.
|
||||
url : str
|
||||
URL for documentation, source code, or issue tracker.
|
||||
|
||||
Parsers that send document content to a remote service should additionally
|
||||
set ``uses_remote_service = True`` so the registry can exclude them when
|
||||
remote processing has not been requested for a document. The attribute is
|
||||
optional so a parser that omits it is treated as fully local.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -150,10 +145,6 @@ class ParserProtocol(Protocol):
|
||||
author: str
|
||||
url: str
|
||||
|
||||
# NOTE: uses_remote_service is not declared here, the registry reads it
|
||||
# with getattr(cls, ..., False) for backwards-compatibility with existing
|
||||
# parsers
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Class methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -334,8 +334,6 @@ class ParserRegistry:
|
||||
mime_type: str,
|
||||
filename: str,
|
||||
path: Path | None = None,
|
||||
*,
|
||||
allow_remote: bool = True,
|
||||
) -> type[ParserProtocol] | None:
|
||||
"""Return the best parser class for the given file, or None.
|
||||
|
||||
@@ -361,11 +359,6 @@ class ParserRegistry:
|
||||
path:
|
||||
Optional filesystem path to the file. Forwarded to each
|
||||
parser's score method.
|
||||
allow_remote:
|
||||
When False, parsers that declare ``uses_remote_service = True``
|
||||
are excluded from consideration, so a document is never sent to
|
||||
a remote service. Parsers that do not declare the attribute
|
||||
are treated as local and are always considered.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -381,13 +374,6 @@ class ParserRegistry:
|
||||
if mime_type not in parser_class.supported_mime_types():
|
||||
continue
|
||||
|
||||
if not allow_remote and getattr(
|
||||
parser_class,
|
||||
"uses_remote_service",
|
||||
False,
|
||||
):
|
||||
continue
|
||||
|
||||
score = parser_class.score(mime_type, filename, path)
|
||||
if score is None:
|
||||
continue
|
||||
|
||||
@@ -61,18 +61,6 @@ class RemoteEngineConfig:
|
||||
self.api_key = api_key
|
||||
self.endpoint = endpoint
|
||||
|
||||
@classmethod
|
||||
def from_app_config(cls) -> Self:
|
||||
"""Build the config from the app config, falling back to the env."""
|
||||
from paperless.config import RemoteOCRConfig
|
||||
|
||||
app_config = RemoteOCRConfig()
|
||||
return cls(
|
||||
engine=app_config.remote_ocr_engine,
|
||||
api_key=app_config.remote_ocr_api_key,
|
||||
endpoint=app_config.remote_ocr_endpoint,
|
||||
)
|
||||
|
||||
def engine_is_valid(self) -> bool:
|
||||
"""Return True when the engine is known and fully configured."""
|
||||
return (
|
||||
@@ -102,9 +90,6 @@ class RemoteDocumentParser:
|
||||
Maintainer name.
|
||||
url : str
|
||||
Issue tracker / source URL.
|
||||
uses_remote_service : bool
|
||||
Content is sent to a remote service, True so that the registry
|
||||
can skip this parser if remote processing was not requested.
|
||||
"""
|
||||
|
||||
name: str = "Paperless-ngx Remote OCR Parser"
|
||||
@@ -112,8 +97,6 @@ class RemoteDocumentParser:
|
||||
author: str = "Paperless-ngx Contributors"
|
||||
url: str = "https://github.com/paperless-ngx/paperless-ngx"
|
||||
|
||||
uses_remote_service: bool = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Class methods
|
||||
# ------------------------------------------------------------------
|
||||
@@ -162,7 +145,11 @@ class RemoteDocumentParser:
|
||||
20 when the remote engine is configured and the MIME type is
|
||||
supported, otherwise None.
|
||||
"""
|
||||
config = RemoteEngineConfig.from_app_config()
|
||||
config = RemoteEngineConfig(
|
||||
engine=settings.REMOTE_OCR_ENGINE,
|
||||
api_key=settings.REMOTE_OCR_API_KEY,
|
||||
endpoint=settings.REMOTE_OCR_ENDPOINT,
|
||||
)
|
||||
if not config.engine_is_valid():
|
||||
return None
|
||||
if mime_type not in _SUPPORTED_MIME_TYPES:
|
||||
@@ -257,7 +244,11 @@ class RemoteDocumentParser:
|
||||
Whether an archive copy is wanted. For PDFs, False skips the
|
||||
remote engine and uses locally-extracted text instead.
|
||||
"""
|
||||
config = RemoteEngineConfig.from_app_config()
|
||||
config = RemoteEngineConfig(
|
||||
engine=settings.REMOTE_OCR_ENGINE,
|
||||
api_key=settings.REMOTE_OCR_API_KEY,
|
||||
endpoint=settings.REMOTE_OCR_ENDPOINT,
|
||||
)
|
||||
|
||||
if not config.engine_is_valid():
|
||||
logger.warning(
|
||||
|
||||
@@ -219,13 +219,6 @@ class ApplicationConfigurationSerializer(
|
||||
allow_null=True,
|
||||
max_length=1024,
|
||||
)
|
||||
remote_ocr_api_key = ObfuscatedPasswordField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
max_length=1024,
|
||||
)
|
||||
|
||||
OBFUSCATED_FIELDS = ("llm_api_key", "remote_ocr_api_key")
|
||||
|
||||
def run_validation(self, data):
|
||||
# Empty strings treated as None to avoid unexpected behavior
|
||||
@@ -237,13 +230,11 @@ class ApplicationConfigurationSerializer(
|
||||
data["language"] = None
|
||||
if "llm_output_language" in data and data["llm_output_language"] == "":
|
||||
data["llm_output_language"] = None
|
||||
for field in self.OBFUSCATED_FIELDS:
|
||||
if field in data and data[field] is not None:
|
||||
if data[field] == "":
|
||||
data[field] = None
|
||||
# Not a real value, don't overwrite the stored one
|
||||
elif len(data[field].replace("*", "")) == 0:
|
||||
del data[field]
|
||||
if "llm_api_key" in data and data["llm_api_key"] is not None:
|
||||
if data["llm_api_key"] == "":
|
||||
data["llm_api_key"] = None
|
||||
elif len(data["llm_api_key"].replace("*", "")) == 0:
|
||||
del data["llm_api_key"]
|
||||
return super().run_validation(data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
|
||||
@@ -1197,7 +1197,6 @@ WEBHOOKS_ALLOW_INTERNAL_REQUESTS = get_bool_from_env(
|
||||
REMOTE_OCR_ENGINE = os.getenv("PAPERLESS_REMOTE_OCR_ENGINE")
|
||||
REMOTE_OCR_API_KEY = os.getenv("PAPERLESS_REMOTE_OCR_API_KEY")
|
||||
REMOTE_OCR_ENDPOINT = os.getenv("PAPERLESS_REMOTE_OCR_ENDPOINT")
|
||||
REMOTE_OCR_MODE = os.getenv("PAPERLESS_REMOTE_OCR_MODE", "always")
|
||||
|
||||
################################################################################
|
||||
# AI Settings #
|
||||
|
||||
@@ -21,7 +21,6 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
|
||||
from documents.parsers import ParseError
|
||||
from paperless.models import ApplicationConfiguration
|
||||
from paperless.parsers import ParserContext
|
||||
from paperless.parsers import ParserProtocol
|
||||
from paperless.parsers.remote import RemoteDocumentParser
|
||||
@@ -34,10 +33,6 @@ if TYPE_CHECKING:
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
|
||||
# Remote ocr config from ApplicationConfiguration needs DB access
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-local fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -232,18 +227,6 @@ class TestRemoteParserScore:
|
||||
score = RemoteDocumentParser.score("application/pdf", "doc.pdf")
|
||||
assert score is not None and score > 10
|
||||
|
||||
@pytest.mark.usefixtures("no_engine_settings")
|
||||
def test_score_uses_app_config_when_env_unset(self) -> None:
|
||||
"""The app config alone is enough to activate the parser."""
|
||||
config = ApplicationConfiguration.objects.first()
|
||||
assert config is not None
|
||||
config.remote_ocr_engine = "azureai"
|
||||
config.remote_ocr_api_key = "app-config-key"
|
||||
config.remote_ocr_endpoint = "https://config.cognitiveservices.azure.com"
|
||||
config.save()
|
||||
|
||||
assert RemoteDocumentParser.score("application/pdf", "doc.pdf") == 20
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Properties
|
||||
|
||||
@@ -1277,8 +1277,6 @@ class TestParserFileTypes:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Remote ocr config from ApplicationConfiguration needs DB access
|
||||
@pytest.mark.django_db
|
||||
class TestRasterisedDocumentParserRegistry:
|
||||
def test_registered_in_defaults(self) -> None:
|
||||
from paperless.parsers.registry import ParserRegistry
|
||||
|
||||
@@ -15,7 +15,7 @@ from paperless.checks import audit_log_check
|
||||
from paperless.checks import binaries_check
|
||||
from paperless.checks import check_default_language_available
|
||||
from paperless.checks import check_deprecated_db_settings
|
||||
from paperless.checks import check_remote_ocr_mode
|
||||
from paperless.checks import check_remote_parser_configured
|
||||
from paperless.checks import check_v3_minimum_upgrade_version
|
||||
from paperless.checks import debug_mode_check
|
||||
from paperless.checks import paths_check
|
||||
@@ -631,21 +631,29 @@ class TestV3MinimumUpgradeVersionCheck:
|
||||
assert check_v3_minimum_upgrade_version(None) == []
|
||||
|
||||
|
||||
class TestRemoteOCRModeCheck:
|
||||
def test_valid_mode(self, settings: SettingsWrapper) -> None:
|
||||
settings.REMOTE_OCR_MODE = "workflow_only"
|
||||
|
||||
msgs = check_remote_ocr_mode(None)
|
||||
class TestRemoteParserChecks:
|
||||
def test_no_engine(self, settings: SettingsWrapper) -> None:
|
||||
settings.REMOTE_OCR_ENGINE = None
|
||||
msgs = check_remote_parser_configured(None)
|
||||
|
||||
assert len(msgs) == 0
|
||||
|
||||
def test_invalid_mode(self, settings: SettingsWrapper) -> None:
|
||||
settings.REMOTE_OCR_MODE = "sometimes"
|
||||
def test_azure_no_endpoint(self, settings: SettingsWrapper) -> None:
|
||||
|
||||
msgs = check_remote_ocr_mode(None)
|
||||
settings.REMOTE_OCR_ENGINE = "azureai"
|
||||
settings.REMOTE_OCR_API_KEY = "somekey"
|
||||
settings.REMOTE_OCR_ENDPOINT = None
|
||||
|
||||
msgs = check_remote_parser_configured(None)
|
||||
|
||||
assert len(msgs) == 1
|
||||
assert "PAPERLESS_REMOTE_OCR_MODE is set to 'sometimes'" in msgs[0].msg
|
||||
|
||||
msg = msgs[0]
|
||||
|
||||
assert (
|
||||
"Azure AI remote parser requires endpoint and API key to be configured."
|
||||
in msg.msg
|
||||
)
|
||||
|
||||
|
||||
class TestTesseractChecks:
|
||||
|
||||
@@ -468,124 +468,6 @@ class TestParserRegistryGetParserForFile:
|
||||
assert result is AcceptingBuiltin
|
||||
|
||||
|
||||
class TestParserRegistryRemoteParsers:
|
||||
"""Verify the allow_remote filter in ParserRegistry.get_parser_for_file()."""
|
||||
|
||||
@staticmethod
|
||||
def _remote_parser_cls() -> type:
|
||||
class RemoteParser:
|
||||
name = "remote"
|
||||
version = "1.0"
|
||||
author = "A"
|
||||
url = "https://example.com/remote"
|
||||
uses_remote_service = True
|
||||
|
||||
@classmethod
|
||||
def supported_mime_types(cls):
|
||||
return {"text/plain": ".txt"}
|
||||
|
||||
@classmethod
|
||||
def score(cls, mime_type, filename, path=None):
|
||||
return 20
|
||||
|
||||
return RemoteParser
|
||||
|
||||
def test_remote_parser_wins_when_remote_allowed(
|
||||
self,
|
||||
dummy_parser_cls: type,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN: A remote parser scoring 20 and a local parser scoring 10.
|
||||
WHEN: get_parser_for_file() is called with allow_remote=True.
|
||||
THEN: The remote parser is returned.
|
||||
"""
|
||||
remote_parser_cls = self._remote_parser_cls()
|
||||
registry = ParserRegistry()
|
||||
registry.register_builtin(dummy_parser_cls)
|
||||
registry.register_builtin(remote_parser_cls)
|
||||
|
||||
result = registry.get_parser_for_file(
|
||||
"text/plain",
|
||||
"readme.txt",
|
||||
allow_remote=True,
|
||||
)
|
||||
assert result is remote_parser_cls
|
||||
|
||||
def test_remote_parser_skipped_when_remote_not_allowed(
|
||||
self,
|
||||
dummy_parser_cls: type,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN: A remote parser scoring 20 and a local parser scoring 10.
|
||||
WHEN: get_parser_for_file() is called with allow_remote=False.
|
||||
THEN: The local parser is returned despite its lower score.
|
||||
"""
|
||||
registry = ParserRegistry()
|
||||
registry.register_builtin(dummy_parser_cls)
|
||||
registry.register_builtin(self._remote_parser_cls())
|
||||
|
||||
result = registry.get_parser_for_file(
|
||||
"text/plain",
|
||||
"readme.txt",
|
||||
allow_remote=False,
|
||||
)
|
||||
assert result is dummy_parser_cls
|
||||
|
||||
def test_no_parser_when_only_remote_available_and_not_allowed(self) -> None:
|
||||
"""
|
||||
GIVEN: A registry whose only candidate declares uses_remote_service.
|
||||
WHEN: get_parser_for_file() is called with allow_remote=False.
|
||||
THEN: None is returned — the remote parser is never used as a
|
||||
fallback when remote processing was not requested.
|
||||
"""
|
||||
registry = ParserRegistry()
|
||||
registry.register_builtin(self._remote_parser_cls())
|
||||
|
||||
result = registry.get_parser_for_file(
|
||||
"text/plain",
|
||||
"readme.txt",
|
||||
allow_remote=False,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_parser_without_attribute_treated_as_local(
|
||||
self,
|
||||
dummy_parser_cls: type,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN: A third-party parser predating uses_remote_service, so it does
|
||||
not declare the attribute at all.
|
||||
WHEN: get_parser_for_file() is called with allow_remote=False.
|
||||
THEN: It is still considered, i.e. treated as fully local, rather
|
||||
than raising AttributeError.
|
||||
"""
|
||||
assert not hasattr(dummy_parser_cls, "uses_remote_service")
|
||||
|
||||
registry = ParserRegistry()
|
||||
registry.register_builtin(dummy_parser_cls)
|
||||
|
||||
result = registry.get_parser_for_file(
|
||||
"text/plain",
|
||||
"readme.txt",
|
||||
allow_remote=False,
|
||||
)
|
||||
assert result is dummy_parser_cls
|
||||
|
||||
def test_remote_allowed_by_default(self) -> None:
|
||||
"""
|
||||
GIVEN: A registry containing only a remote parser.
|
||||
WHEN: get_parser_for_file() is called without allow_remote.
|
||||
THEN: The remote parser is returned — callers that do not opt in to
|
||||
the filter keep the previous behaviour.
|
||||
"""
|
||||
remote_parser_cls = self._remote_parser_cls()
|
||||
registry = ParserRegistry()
|
||||
registry.register_builtin(remote_parser_cls)
|
||||
|
||||
result = registry.get_parser_for_file("text/plain", "readme.txt")
|
||||
assert result is remote_parser_cls
|
||||
|
||||
|
||||
class TestDiscover:
|
||||
"""Verify entrypoint discovery in ParserRegistry.discover()."""
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Tests for RemoteOCRConfig precedence between app config and Django settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
|
||||
from paperless.config import RemoteOCRConfig
|
||||
from paperless.models import RemoteOCRMode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def null_app_config(mocker) -> MagicMock:
|
||||
"""Mock ApplicationConfiguration with all fields None → falls back to Django settings."""
|
||||
return mocker.MagicMock(
|
||||
remote_ocr_engine=None,
|
||||
remote_ocr_api_key=None,
|
||||
remote_ocr_endpoint=None,
|
||||
remote_ocr_mode=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def make_remote_ocr_config(mocker):
|
||||
def _make(app_config, **django_settings_overrides):
|
||||
mocker.patch(
|
||||
"paperless.config.BaseConfig._get_config_instance",
|
||||
return_value=app_config,
|
||||
)
|
||||
with override_settings(**django_settings_overrides):
|
||||
return RemoteOCRConfig()
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
class TestRemoteOCRConfig:
|
||||
def test_falls_back_to_settings(
|
||||
self,
|
||||
make_remote_ocr_config,
|
||||
null_app_config,
|
||||
) -> None:
|
||||
cfg = make_remote_ocr_config(
|
||||
null_app_config,
|
||||
REMOTE_OCR_ENGINE="azureai",
|
||||
REMOTE_OCR_API_KEY="env-key",
|
||||
REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com",
|
||||
REMOTE_OCR_MODE=RemoteOCRMode.WORKFLOW_ONLY,
|
||||
)
|
||||
assert cfg.remote_ocr_engine == "azureai"
|
||||
assert cfg.remote_ocr_api_key == "env-key"
|
||||
assert cfg.remote_ocr_endpoint == "https://env.cognitiveservices.azure.com"
|
||||
assert cfg.remote_ocr_mode == RemoteOCRMode.WORKFLOW_ONLY
|
||||
|
||||
def test_app_config_takes_precedence(
|
||||
self,
|
||||
make_remote_ocr_config,
|
||||
mocker,
|
||||
) -> None:
|
||||
app_config = mocker.MagicMock(
|
||||
remote_ocr_engine="azureai",
|
||||
remote_ocr_api_key="db-key",
|
||||
remote_ocr_endpoint="https://db.cognitiveservices.azure.com",
|
||||
remote_ocr_mode=RemoteOCRMode.WORKFLOW_ONLY,
|
||||
)
|
||||
cfg = make_remote_ocr_config(
|
||||
app_config,
|
||||
REMOTE_OCR_ENGINE=None,
|
||||
REMOTE_OCR_API_KEY="env-key",
|
||||
REMOTE_OCR_ENDPOINT="https://env.cognitiveservices.azure.com",
|
||||
REMOTE_OCR_MODE=RemoteOCRMode.ALWAYS,
|
||||
)
|
||||
assert cfg.remote_ocr_engine == "azureai"
|
||||
assert cfg.remote_ocr_api_key == "db-key"
|
||||
assert cfg.remote_ocr_endpoint == "https://db.cognitiveservices.azure.com"
|
||||
assert cfg.remote_ocr_mode == RemoteOCRMode.WORKFLOW_ONLY
|
||||
|
||||
def test_unset_everywhere(
|
||||
self,
|
||||
make_remote_ocr_config,
|
||||
null_app_config,
|
||||
) -> None:
|
||||
cfg = make_remote_ocr_config(
|
||||
null_app_config,
|
||||
REMOTE_OCR_ENGINE=None,
|
||||
REMOTE_OCR_API_KEY=None,
|
||||
REMOTE_OCR_ENDPOINT=None,
|
||||
)
|
||||
assert cfg.remote_ocr_engine is None
|
||||
assert cfg.remote_ocr_api_key is None
|
||||
assert cfg.remote_ocr_endpoint is None
|
||||
|
||||
|
||||
class TestRemoteOCRByDefault:
|
||||
def test_always_mode(self, make_remote_ocr_config, null_app_config) -> None:
|
||||
cfg = make_remote_ocr_config(
|
||||
null_app_config,
|
||||
REMOTE_OCR_MODE=RemoteOCRMode.ALWAYS,
|
||||
)
|
||||
|
||||
assert cfg.remote_ocr_by_default is True
|
||||
|
||||
def test_workflow_only_mode(self, make_remote_ocr_config, null_app_config) -> None:
|
||||
cfg = make_remote_ocr_config(
|
||||
null_app_config,
|
||||
REMOTE_OCR_MODE=RemoteOCRMode.WORKFLOW_ONLY,
|
||||
)
|
||||
|
||||
assert cfg.remote_ocr_by_default is False
|
||||
Reference in New Issue
Block a user