mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-14 06:43:18 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b347022c4c | ||
|
|
01c12d9ea4 | ||
|
|
f5c0d118f7 | ||
|
|
ff13847d0a | ||
|
|
634f803872 | ||
|
|
639d566a7c | ||
|
|
0a94f8f0d4 |
@@ -1,66 +0,0 @@
|
||||
---
|
||||
name: whoosh-compat-transition
|
||||
description: Use when integrating the whoosh-compat library into paperless-ngx search, replacing src/documents/search/_translate.py or _dates.py, building the search FieldRegistry, or changing user query parsing during the whoosh-to-tantivy transition
|
||||
---
|
||||
|
||||
# whoosh-compat transition
|
||||
|
||||
## Overview
|
||||
|
||||
whoosh-compat (github.com/stumpylog/whoosh-compat; local checkout usually at `../whoosh-compat`) replaces the hand-maintained translation layer (`src/documents/search/_translate.py`, `_dates.py`): it parses user queries with a faithful fork of whoosh's real grammar into a typed AST and emits programmatic tantivy queries. Read its README and ARCHITECTURE.md before wiring anything; its DIVERGENCES.md lists intended behavior differences and is the authority on "is this difference a bug".
|
||||
|
||||
## Decisions already made (do not re-derive)
|
||||
|
||||
- **Queries are user-typed free text.** The advanced search box passes whatever the user types straight to the parser (that is how the issue #13568 queries exist). Do NOT try to infer the supported field surface from frontend code; the frontend only generates a few date filter strings, everything else is typed by users.
|
||||
- **The field surface is a policy decision, not `KNOWN_FIELDS`.** Today's `KNOWN_FIELDS` accepts internal ID fields (`tag_id`, `owner_id`, `viewer_id`, other `*_id`) that are undocumented in `docs/usage.md` and were ruled not user-searchable by the maintainer: exclude them from the `FieldRegistry` (they stay as programmatic permission/filter fields in `build_permission_filter`, which never touches user query text). The registry is built from documented syntax in `docs/usage.md` plus the v2-compat aliases (`type`, `path`, `type_id`-style aliases follow their canonical field's fate). Undocumented-but-working fields (`asn`, `page_count`, `num_notes`, `original_filename`, `checksum`) need an explicit maintainer yes/no; since users type freely, silently dropping one breaks any saved view using it, so a drop must be a visible, documented decision.
|
||||
- **Analyzer seam:** `FieldSpec.analyzer` binds the live registered tantivy analyzer's `.analyze` (the same Rust analyzer used at index time; language-keyed, so rebuild the registry when `SEARCH_LANGUAGE` changes, on the same trigger as `register_tokenizers`). `pattern_normalizer` is `_tokenizer.ascii_fold`: character-level lowercase+fold only, NEVER stemming.
|
||||
- **Diagnostics before emit:** `whoosh_compat.parse()` never raises on bad input. Check `ParseResult.diagnostics` and map to `SearchQueryError`/`InvalidDateQuery` (HTTP 400) BEFORE calling `emit()`; also catch the emitter's `UnsupportedQueryError` into a 400. Never carry forward the legacy raw-string fallback (`except Exception: query_str = raw_query`) into the new path; it masks integration bugs.
|
||||
- **Build typed errors from structured diagnostic data, never by parsing `message`.** Each `Diagnostic` carries `kind`, `startchar`/`endchar`, and `field`/`raw_value`. `message` is human-readable text whose wording can change. For a range that fails on one bound, `raw_value` is the bound that actually failed.
|
||||
- `diagnostic.field` is a **`FieldRef`**, not a string: use `str(diagnostic.field)` for the canonical dotted name (`created`, `notes.user`) or `diagnostic.field.name` for the field alone. Note the name is canonical, so an aliased query (`type:`) reports the field it resolves to (`document_type`), and the diagnostic span covers the offending value rather than the field name, so the text the user typed for the field is not recoverable.
|
||||
- **The registry has one resolver.** `registry.make_ref(raw)` turns a raw field string into a `FieldRef` or `None` for an unknown field, and `registry.resolve(ref)` returns a `ResolvedField | None`, not a bare spec: read `.spec` for the `FieldSpec`, `.json_path` for the subpath (or `None`), `.is_subpath` and `.dotted_name` are convenience properties. There is no `resolve_json()`; a dotted name is interpreted only inside `make_ref`. Write `resolved = registry.resolve(ref)` then `resolved.spec.kind`, not `spec = registry.resolve(ref)` then `spec.kind`.
|
||||
- **`notes` and `custom_fields` are JSON fields** with fixed subpaths (`notes.user`/`notes.note`, `custom_fields.name`/`custom_fields.value`); the registry stays a static, language-keyed singleton, never per-request.
|
||||
- **`emit()`'s signature is `emit(node, *, index, registry)`, with no `schema` parameter.** Do not write a call site passing `schema=`. `emit()` calls the library's own `analyze()` pipeline stage internally (token analysis, multitoken resolution, zero-token drop), so paperless-ngx never needs to call `analyze()` itself.
|
||||
- **A wildcard/prefix pattern on a JSON subpath reports a parse-time diagnostic**, not a silent whole-field query: `custom_fields.value:abc*` reports `DiagnosticKind.UNSUPPORTED_PATTERN` (the same kind used for a wildcard on a numeric or BOOLEAN_EXISTS field) instead of matching against the wrong encoded bytes. Relevant here because `custom_fields.value` is exactly the kind of JSON subpath a user might expect to pattern-match; the error-mapping code needs a case for `UNSUPPORTED_PATTERN`, not just `BAD_DATE`/`BAD_NUMBER`.
|
||||
|
||||
## Mandatory before deleting old code
|
||||
|
||||
- Date-grammar parity audit, line by line: every keyword, relative unit, and abbreviation `_dates.py` and `_translate.py` accept today (including the whoosh-era abbreviations kept for old saved views) must have an accepted form in whoosh-compat's dateparse grammar. Silent keyword loss is the saved-view breakage class behind issue #13568.
|
||||
- Acceptance corpus compared by matched-document-ID sets, not query strings: the #13568 queries verbatim, real saved-view strings, every date keyword, field aliases, comma lists, date and numeric ranges, wildcards with bracket classes, boosts, JSON subpaths.
|
||||
|
||||
## Tests: what goes, what comes
|
||||
|
||||
Removed with their modules (do not port their string-level assertions):
|
||||
|
||||
- `src/documents/tests/search/test_translate.py`: its subject is deleted; string-translation unit cases are whoosh-compat's own responsibility now. Cases that encode real user-visible behavior get reincarnated as result-level acceptance cases, not string assertions.
|
||||
- Date-keyword unit tests tied to `_dates.py` internals: same treatment.
|
||||
- `test_query.py` cases asserting `parse_user_query` internals or intermediate query strings: rewritten against the new pipeline, asserting on matched results.
|
||||
|
||||
Kept: `test_migration_fulltext_query_field_prefixes.py` (data migration, orthogonal), `test_schema.py`, `test_tokenizer.py`, permission-filter and simple-search tests.
|
||||
|
||||
Added:
|
||||
|
||||
- A result-level acceptance module (paperless's analogue of whoosh-compat's `test_acceptance_e2e.py`): the corpus above against a real index built from `build_schema()`, asserting document-ID sets. Use `pytest.param(..., id="...")` for every case.
|
||||
- Registry unit tests: internal `*_id` names rejected, aliases resolve to canonical fields, JSON subpaths match `docs/usage.md`, construction deterministic per language.
|
||||
- One `Multitoken` case nested inside a top-level `OR` (whoosh-compat DIVERGENCES entry on Multitoken.DEFAULT) to prove it does not matter for paperless's data.
|
||||
- If acceptance work surfaces a new whoosh-compat divergence, that is a whoosh-compat-repo change (its `differential-triage` skill applies), not a silent paperless workaround.
|
||||
|
||||
## Fast JSON field existence checks
|
||||
|
||||
Existence checks against a fast JSON field work correctly, both whole-field (`notes:*`, which internally requires `json_subpaths=True`) and subpath-scoped (`custom_fields.value:*`, which checks only that subpath's own fast column). Both are covered by whoosh-compat's own test suite; see `DIVERGENCES.md` entry 20 for the exists-strategy design and its subpath-scoping note.
|
||||
|
||||
Whether to mark `notes`/`custom_fields` fast is a paperless-ngx-side tradeoff (fast fields cost index size/build time for cheaper existence/range queries) independent of whoosh-compat's correctness — worth a maintainer decision, not assumed by this document.
|
||||
|
||||
## Coordination
|
||||
|
||||
- whoosh-compat is pre-1.0: pin an exact version or git SHA; upgrades are deliberate, reviewed changes.
|
||||
- JSON subpath emission depends on the installed tantivy-py version (fallback until quickwit-oss/tantivy-py#716 ships). The whoosh-compat repo has a `carve-out-retirement` skill; coordinate tantivy pin bumps with it, in a separate PR from the parser migration.
|
||||
- Rollout: no feature flag, no shadow-compare period. Safety comes from the date-grammar parity audit and the result-level acceptance corpus instead; `_translate.py`/`_dates.py` are deleted once those are green.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- Inferring the field surface from frontend code (users type queries directly).
|
||||
- Copying `KNOWN_FIELDS` into the registry wholesale (resurfaces internal fields).
|
||||
- Wiring stemming into `pattern_normalizer`.
|
||||
- Calling `emit()` unconditionally, or porting the legacy raw-string fallback.
|
||||
- Deleting `_dates.py` without the parity audit.
|
||||
- Porting `test_translate.py`'s string assertions instead of writing result-level tests.
|
||||
@@ -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.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
```
|
||||
@@ -1,502 +0,0 @@
|
||||
# whoosh-compat transition design
|
||||
|
||||
Date: 2026-08-07
|
||||
Status: approved
|
||||
Related skill: `whoosh-compat-transition`
|
||||
|
||||
> **API reference used by this design.** `FieldRegistry` exposes one
|
||||
> resolution path: `registry.make_ref(raw) -> FieldRef | None` interprets a
|
||||
> raw, possibly dotted field string (an unknown field or an unknown subpath
|
||||
> both return `None`), and `registry.resolve(ref) -> ResolvedField | None`
|
||||
> looks up the resolved ref. `ResolvedField` carries `.spec` (the
|
||||
> `FieldSpec`), `.json_path` (the subpath, or `None`), `.is_subpath`, and
|
||||
> `.dotted_name` — read `resolved.spec.kind`, not `spec.kind` off a bare
|
||||
> `FieldSpec`. `Diagnostic.field` is a `FieldRef`, not a string: use
|
||||
> `str(d.field)` for the canonical dotted name, or `d.field.name` for the
|
||||
> field alone (the name is canonical, so an aliased query like `type:`
|
||||
> reports `document_type`). Every field-carrying AST leaf holds a `FieldRef`.
|
||||
> `emit()`'s signature is `emit(node, *, index, registry) -> tantivy.Query`,
|
||||
> with no `schema` parameter; it calls the library's own `analyze()` pipeline
|
||||
> stage internally (token analysis, multitoken resolution, zero-token drop)
|
||||
> before visiting the tree, so this design's call sites never invoke
|
||||
> `analyze()` themselves. `FieldSpec.subpaths` is stored internally as
|
||||
> `Mapping[str, SubpathSpec]`, though construction still accepts a plain
|
||||
> `tuple[str, ...]` as sugar and normalizes it automatically — this design's
|
||||
> own `PublicField.subpaths: tuple[str, ...]` (below) passes a tuple into
|
||||
> `FieldSpec(..., subpaths=...)` and needs nothing further.
|
||||
> `DiagnosticKind` has four members: `BAD_DATE`, `BAD_NUMBER`, `TOO_DEEP`, and
|
||||
> `UNSUPPORTED_PATTERN`; the error-mapping code below needs cases for all
|
||||
> four.
|
||||
>
|
||||
> A few library behaviors worth knowing before writing code against it:
|
||||
> `parse()` validates its own configuration eagerly — an empty or unknown
|
||||
> `default_fields`, or a `field_boosts` key that resolves to neither a known
|
||||
> field nor an alias, raises `ValueError` at the `parse()` call itself, and an
|
||||
> alias in either argument resolves normally. A naive `basedate` is rejected
|
||||
> (`ValueError`) rather than silently read in the host machine's local
|
||||
> timezone; pass an aware datetime. A wildcard/prefix pattern on a numeric
|
||||
> (`U64`) field, a `BOOLEAN_EXISTS` field, or a JSON subpath produces a
|
||||
> parse-time `Diagnostic(kind=UNSUPPORTED_PATTERN)` instead of silently
|
||||
> mangling to an exact-match term or matching the wrong encoded bytes — this
|
||||
> is directly relevant to `custom_fields.value`: a user typing
|
||||
> `custom_fields.value:abc*` gets a diagnostic, not a query that silently
|
||||
> matches the wrong documents. A bare JSON field name with no subpath
|
||||
> (`notes:foo`) demotes to an ordinary text search for the literal string,
|
||||
> the same treatment an unknown field or unknown subpath gets. Registry
|
||||
> construction validates its input eagerly: exists-target cycles, empty
|
||||
> field/alias names, duplicate aliases, dotted canonical names,
|
||||
> invalid-character or empty JSON subpath strings, and a subpath that would
|
||||
> shadow a registered plain field are all rejected at `FieldRegistry.__init__`
|
||||
> with an actionable message, not deferred to query time.
|
||||
>
|
||||
> Fast-field existence checks against a JSON field are correct, both for
|
||||
> whole-field existence (`notes:*`) and the per-subpath case
|
||||
> (`custom_fields.value:*`, which checks only that subpath's own fast
|
||||
> column). Marking `notes`/`custom_fields` fast is therefore a plain
|
||||
> paperless-ngx-side index-size/query-cost tradeoff, independent of
|
||||
> whoosh-compat correctness — worth a maintainer decision, not something this
|
||||
> document settles.
|
||||
|
||||
## Summary
|
||||
|
||||
Replace paperless-ngx's hand-maintained query-translation layer
|
||||
(`src/documents/search/_translate.py`, `src/documents/search/_dates.py`)
|
||||
with [whoosh-compat](https://github.com/stumpylog/whoosh-compat): a typed
|
||||
Whoosh-grammar parser that emits programmatically constructed
|
||||
`tantivy.Query` objects instead of building an intermediate Tantivy query
|
||||
_string_. The integration point is narrow: `parse_user_query()` in
|
||||
`src/documents/search/_query.py` is the only function whose implementation
|
||||
changes; `_backend.py`, `_tokenizer.py`, simple/title search, CJK handling,
|
||||
and permission filtering are all unaffected.
|
||||
|
||||
Delivered as a stack of four paperless-ngx PRs plus one prerequisite change
|
||||
in whoosh-compat itself (same maintainer, no cross-repo coordination
|
||||
overhead), landed with no feature flag and no shadow-compare rollout period
|
||||
— safety comes from a result-level acceptance test corpus and a
|
||||
date-grammar parity audit instead.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
raw_query (user-typed)
|
||||
│
|
||||
▼
|
||||
wc.parse(raw_query, registry=FIELD_REGISTRY, default_fields=DEFAULT_SEARCH_FIELDS,
|
||||
field_boosts=_FIELD_BOOSTS, tz=tz)
|
||||
│
|
||||
▼
|
||||
ParseResult(ast, diagnostics)
|
||||
│
|
||||
├─ diagnostics non-empty? → map ALL diagnostics to SearchQueryError
|
||||
│ subclass(es) → HTTP 400 (never just the first diagnostic)
|
||||
│
|
||||
▼
|
||||
emit(ast, index=index, registry=FIELD_REGISTRY)
|
||||
│ (calls whoosh-compat's own analyze() pipeline stage internally, then
|
||||
│ raises UnsupportedQueryError → mapped to SearchQueryError → 400,
|
||||
│ for constructs that parse but can't execute against tantivy)
|
||||
▼
|
||||
tantivy.Query
|
||||
│
|
||||
▼
|
||||
existing clause assembly in parse_user_query(): Should(exact) + optional
|
||||
fuzzy re-parse of raw_query + optional CJK bigram query, unchanged from today
|
||||
│
|
||||
▼
|
||||
_apply_permission_filter() in _backend.py wraps the result with
|
||||
build_permission_filter() — entirely independent of whoosh-compat, unchanged
|
||||
```
|
||||
|
||||
Permission filtering is explicitly out of scope for this migration:
|
||||
`build_permission_filter()` builds its `tantivy.Query` directly against
|
||||
`owner_id`/`viewer_id`/`viewer_group_id`, never through the parser or
|
||||
registry, and those fields are exactly the internal `*_id` fields excluded
|
||||
from the `FieldRegistry` (see "Field surface" below). Nothing in this
|
||||
migration's diff touches it.
|
||||
|
||||
## PR stack
|
||||
|
||||
Each PR is independently buildable, reviewable, and CI-able; later PRs
|
||||
rebase on earlier ones. No PR depends on whoosh-compat behavior it hasn't
|
||||
already proven correct in isolation.
|
||||
|
||||
1. **Refactor `_schema.py` to a shared field-definition table.** Pure
|
||||
refactor — `build_schema()`'s output is byte-identical before and after.
|
||||
`test_schema.py` (existing) proves it.
|
||||
2. **Pin whoosh-compat as a real dependency; build `FieldRegistry`.** New
|
||||
`_registry.py` built from the same table PR 1 introduced. Registry unit
|
||||
tests only — no wiring into search yet.
|
||||
3. **Date-grammar parity audit.** A transitional, executable differential
|
||||
test using the still-present `_dates.py`/`_translate.py` as the oracle.
|
||||
Any gap found is fixed in whoosh-compat directly before this PR closes.
|
||||
A whoosh-compat PyPI release is expected around this point (see
|
||||
"Dependency pinning").
|
||||
4. **Wire it in; delete the old path.** Rewrite `parse_user_query()`,
|
||||
diagnostics→exception mapping, add the result-level acceptance corpus,
|
||||
expand `test_api_search.py`, delete `_translate.py`/`_dates.py`/
|
||||
`test_translate.py` and the internals-testing classes in `test_query.py`,
|
||||
update `docs/usage.md` and changelog.
|
||||
|
||||
`Diagnostic` carries `field: FieldRef | None` and `raw_value: str | None`,
|
||||
populated at its construction sites (`dateparse.py`'s `_error()`,
|
||||
`default.py`'s `BAD_NUMBER` sites), so paperless can build typed exceptions
|
||||
without parsing whoosh-compat's human-readable `message` text. `field` is a
|
||||
`FieldRef`, not a plain string; see the API reference at the top of this
|
||||
document.
|
||||
|
||||
## Field surface
|
||||
|
||||
The `FieldRegistry` covers only query-syntax-addressable fields — a subset
|
||||
of the full Tantivy schema. Internal-only schema fields with no query-syntax
|
||||
meaning of their own (`title_sort`/`correspondent_sort`/`type_sort` shadow
|
||||
sort fields, `bigram_*` CJK fields, `simple_title`/`simple_content`,
|
||||
`autocomplete_word`, `notes_text`) stay hardcoded `sb.add_*` calls in
|
||||
`_schema.py`, untouched by the shared table.
|
||||
|
||||
**Decision: keep and document all five currently-undocumented-but-working
|
||||
fields** (`asn`, `page_count`, `num_notes`, `original_filename`,
|
||||
`checksum`) rather than dropping them — least risk of silently breaking an
|
||||
existing saved view. `docs/usage.md`'s advanced-search section gets these
|
||||
added with examples, as part of PR 4.
|
||||
|
||||
**Decision: `archive_checksum` stays out of scope.** Unlike `checksum`, it
|
||||
isn't indexed in the Tantivy schema at all today (confirmed: `_schema.py`
|
||||
only adds `checksum`; `_build_tantivy_doc` only calls
|
||||
`doc.add_text("checksum", document.checksum)`). Making it searchable is a
|
||||
schema-level change (new indexed field, new document population code), not
|
||||
a parser-migration concern — left as a separate follow-up.
|
||||
|
||||
**Decision: internal `*_id` fields (`tag_id`, `correspondent_id`,
|
||||
`document_type_id`, `storage_path_id`, `owner_id`, `viewer_id`,
|
||||
`viewer_group_id`) are excluded from the `FieldRegistry` entirely.** They
|
||||
remain Tantivy-schema-only, used exclusively by `build_permission_filter()`.
|
||||
Because whoosh-compat folds any unrecognized `field:` prefix into literal
|
||||
text (Whoosh-parity leniency, confirmed in `FieldsPlugin.do_fieldnames` —
|
||||
not an error), a saved view typed as `tag_id:5` won't 400: it silently
|
||||
becomes a text search for the literal string `tag_id:5`, most likely
|
||||
returning zero results. This is a real behavior change and gets a
|
||||
**changelog callout**, not just a docs update, since a docs addition alone
|
||||
wouldn't surface it to someone skimming release notes.
|
||||
|
||||
## Shared field-definition table (`_fields.py`)
|
||||
|
||||
```python
|
||||
from whoosh_compat import FieldKind # reused directly — no parallel enum
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PublicField:
|
||||
name: str
|
||||
kind: FieldKind
|
||||
aliases: tuple[str, ...] = ()
|
||||
comma_values: bool = False
|
||||
date_only: bool = False
|
||||
fast: bool = False
|
||||
subpaths: tuple[str, ...] = () # JSON kind only
|
||||
|
||||
PUBLIC_FIELDS = (
|
||||
PublicField("title", FieldKind.TEXT),
|
||||
PublicField("content", FieldKind.TEXT),
|
||||
PublicField("correspondent", FieldKind.TEXT),
|
||||
PublicField("document_type", FieldKind.TEXT, aliases=("type",)),
|
||||
PublicField("storage_path", FieldKind.TEXT, aliases=("path",)),
|
||||
PublicField("original_filename", FieldKind.TEXT),
|
||||
PublicField("tag", FieldKind.TEXT, comma_values=True),
|
||||
PublicField("checksum", FieldKind.KEYWORD),
|
||||
PublicField("asn", FieldKind.U64, fast=True),
|
||||
PublicField("page_count", FieldKind.U64, fast=True),
|
||||
PublicField("num_notes", FieldKind.U64, fast=True),
|
||||
PublicField("created", FieldKind.DATE, date_only=True, fast=True),
|
||||
PublicField("modified", FieldKind.DATETIME, fast=True),
|
||||
PublicField("added", FieldKind.DATETIME, fast=True),
|
||||
PublicField("notes", FieldKind.JSON, subpaths=("user", "note")),
|
||||
PublicField("custom_fields", FieldKind.JSON, subpaths=("name", "value")),
|
||||
)
|
||||
```
|
||||
|
||||
`build_schema()` derives its `sb.add_*` call and tokenizer from `kind`
|
||||
(TEXT/KEYWORD → `add_text_field` with `paperless_text`/`raw` tokenizer
|
||||
respectively; U64 → `add_unsigned_field`; DATE/DATETIME → `add_date_field`;
|
||||
JSON → `add_json_field`). The `notes_text` snippet-companion field stays a
|
||||
separate hardcoded line right after the `notes` entry — schema-only
|
||||
plumbing with no query-syntax meaning.
|
||||
|
||||
`_registry.py` maps each `PublicField` to a `whoosh_compat.FieldSpec`,
|
||||
kept as one flat dataclass (no kind-specific subclassing) to mirror
|
||||
whoosh-compat's own `FieldSpec` design, which validates kind-conditional
|
||||
attributes (e.g. JSON requires non-empty `subpaths`) at
|
||||
`FieldRegistry.__init__` rather than in the type system.
|
||||
|
||||
Footnote for whoever writes `_registry.py`: `FieldRegistry.__init__` forces
|
||||
`date_only=True` on _any_ `FieldKind.DATE` spec regardless of what's
|
||||
passed, unconditionally — `PublicField.date_only` isn't an independent
|
||||
knob for DATE fields the way it might look; it only matters in the sense
|
||||
that `created` sets it explicitly for clarity, while `modified`/`added`
|
||||
use `FieldKind.DATETIME` instead of relying on that override.
|
||||
|
||||
**`PublicField.subpaths` stays `tuple[str, ...]`, not a nested structure.**
|
||||
Confirmed against whoosh-compat's own `FieldRegistry.make_ref()`: it splits a
|
||||
dotted query term on the _first_ dot only and matches the remainder as an
|
||||
exact string against `spec.subpaths` — even the docstring's own
|
||||
`"metadata.author.name"` example is a single opaque string in the tuple,
|
||||
not a recursive tree. (`FieldSpec.subpaths` itself now stores a `Mapping[str,
|
||||
SubpathSpec]` internally, normalized from whatever tuple is passed at
|
||||
construction; that's an implementation detail of `FieldSpec.__post_init__`,
|
||||
not something `PublicField`'s own table needs to mirror — passing a plain
|
||||
tuple into `FieldSpec(..., subpaths=...)` still works exactly as written
|
||||
here.) A tuple of strings is exactly as expressive as the library it feeds;
|
||||
inventing richer structure in `PublicField` now would just get flattened
|
||||
back to strings at the registry-construction boundary. Real recursive
|
||||
nesting, if ever needed, is new whoosh-compat capability first (the
|
||||
per-subpath `SubpathSpec` container exists specifically to make that a
|
||||
later, additive change).
|
||||
|
||||
**JSON document population stays separate from `subpaths`.** `subpaths` is
|
||||
query-side only — it declares which dotted names are legal to type and
|
||||
which JSON keys the emitter should address. It says nothing about how
|
||||
`_backend.py::_build_tantivy_doc` builds the JSON documents at index-write
|
||||
time, and that logic isn't uniform attribute access (`note.user.username`
|
||||
needs a null guard and isn't `note.user`; `cfi.value_for_search` is a
|
||||
property, not a literal `value` attribute), so a generic
|
||||
`getattr(obj, subpath_name)` scheme would silently do the wrong thing for
|
||||
both. That code stays hand-written, unchanged by this migration. Mitigation
|
||||
instead: a coupling test (PR 2, alongside the registry unit tests) asserting
|
||||
the literal JSON keys used in `_build_tantivy_doc`'s `doc.add_json(...)`
|
||||
calls match `PUBLIC_FIELDS`' `notes`/`custom_fields` `subpaths` exactly, so
|
||||
drift between the two is caught rather than silently becoming an
|
||||
unqueryable (or silently unindexed) field.
|
||||
|
||||
**JSON subpath queries (`notes.*`, `custom_fields.*`) route through
|
||||
`index.parse_query()`, not programmatic construction, given paperless's
|
||||
pinned tantivy version.** Installed `tantivy-py`'s `Query.term_query`
|
||||
cannot resolve a JSON subpath by exact field name — it raises as if the
|
||||
field didn't exist. Until
|
||||
[tantivy-py#716](https://github.com/quickwit-oss/tantivy-py/pull/716) lands
|
||||
and ships, whoosh-compat's `TantivyEmitter._json_paths_supported()` feature-
|
||||
detects this per process and falls back to a strictly escaped, single-leaf
|
||||
`index.parse_query()` call for just that one leaf (whoosh-compat's README/
|
||||
ARCHITECTURE.md call this out as "the JSON subpath carve-out"). Paperless
|
||||
pins `tantivy~=0.26.0`, squarely inside the affected range (whoosh-compat's
|
||||
`tantivy` extra only requires `tantivy>=0.24`, so nothing prevents this
|
||||
combination). Nothing needs to change in this design because of it — the
|
||||
carve-out is self-retiring on whoosh-compat's side once tantivy-py catches
|
||||
up — but the acceptance corpus's `notes.user:`/`custom_fields.name:` cases
|
||||
(PR 4) are exercising that fallback escaping path specifically, not the
|
||||
programmatic path every other field goes through, and that's worth knowing
|
||||
if one of those cases ever behaves oddly around quoting/escaping. A
|
||||
multi-token JSON subpath value with `Multitoken.AND`/`OR` now gets correct
|
||||
combinator semantics through this fallback (each token becomes its own
|
||||
`index.parse_query()`-backed leaf, `Must`/`Should`-combined normally,
|
||||
instead of collapsing into one space-joined phrase-shaped query); a genuine
|
||||
quoted phrase on a JSON subpath still cannot carry an explicit slop through
|
||||
this fallback (silently ignored, `~N` has no effect) until the carve-out
|
||||
retires. Also worth knowing given `custom_fields.value` is JSON: this
|
||||
fallback's `index.parse_query()` call gives a JSON subpath term free
|
||||
numeric/boolean type inference tantivy's own query grammar provides (a
|
||||
query like `custom_fields.value:100` matches both a stored JSON number `100`
|
||||
and a stored JSON string `"100"`); the future programmatic path (once
|
||||
tantivy-py#716 ships) has no equivalent union and would need this
|
||||
re-evaluated for numeric/boolean custom field values specifically
|
||||
(whoosh-compat's `DIVERGENCES.md` entry 22 tracks this open question).
|
||||
|
||||
**Analyzer wiring**: `FieldSpec.analyzer` reuses the same `tantivy
|
||||
.TextAnalyzer` objects `_tokenizer.py` already builds (`_paperless_text
|
||||
(language)`, etc.) — standalone objects not dependent on index
|
||||
registration, so `_registry.py` calls the same builder functions and binds
|
||||
`.analyze` directly; `checksum` (KEYWORD, `raw` tokenizer) gets an identity
|
||||
analyzer (`lambda t: [t]`). `pattern_normalizer` for every field is
|
||||
`_tokenizer.ascii_fold` (character-fold only, never stemming) per the
|
||||
skill's explicit instruction. The whole `FieldRegistry` is built once,
|
||||
cached keyed by `settings.SEARCH_LANGUAGE`, rebuilt on the same trigger
|
||||
`register_tokenizers()` already uses.
|
||||
|
||||
## Error handling
|
||||
|
||||
```python
|
||||
class SearchQueryError(ValueError): ... # unchanged, base
|
||||
|
||||
class InvalidDateQuery(SearchQueryError): # unchanged
|
||||
def __init__(self, field, value): ...
|
||||
|
||||
class InvalidNumberQuery(SearchQueryError): # new
|
||||
def __init__(self, field: str | None, value: str | None) -> None:
|
||||
self.field = field
|
||||
self.value = value
|
||||
super().__init__(f"Invalid numeric value {value!r} for field {field!r}.")
|
||||
|
||||
class MultipleSearchQueryErrors(SearchQueryError): # new
|
||||
"""Aggregates every user-fixable error from one parse, not just the first."""
|
||||
def __init__(self, errors: Sequence[SearchQueryError]) -> None:
|
||||
self.errors = tuple(errors)
|
||||
super().__init__("; ".join(str(e) for e in self.errors))
|
||||
```
|
||||
|
||||
```python
|
||||
def parse_user_query(index, raw_query, tz):
|
||||
registry = get_field_registry(settings.SEARCH_LANGUAGE)
|
||||
result = wc.parse(
|
||||
raw_query, registry=registry, default_fields=DEFAULT_SEARCH_FIELDS,
|
||||
field_boosts=_FIELD_BOOSTS, tz=tz,
|
||||
)
|
||||
if result.diagnostics:
|
||||
raise _diagnostics_to_error(result.diagnostics) # ALL diagnostics, not [0]
|
||||
|
||||
try:
|
||||
exact = tantivy_emit(result.ast, index=index, registry=registry)
|
||||
except UnsupportedQueryError as e:
|
||||
raise SearchQueryError(str(e)) from e
|
||||
|
||||
# CJK: unchanged — already re-parses raw_query directly via index.parse_query,
|
||||
# never went through translate_query, so nothing here changes.
|
||||
cjk_query = _build_cjk_query(index, raw_query, _CJK_ALL_FIELDS) if _has_cjk(raw_query) else None
|
||||
|
||||
clauses = [(tantivy.Occur.Should, exact)]
|
||||
threshold = settings.ADVANCED_FUZZY_SEARCH_THRESHOLD
|
||||
if threshold is not None:
|
||||
# Fuzzy re-parses raw_query (not the AST) — no clean AST-level fuzzy
|
||||
# equivalent exists; fuzzy matching was always an approximate,
|
||||
# secondary clause, so this divergence from the exact-match path is
|
||||
# acceptable.
|
||||
fuzzy = index.parse_query(raw_query, DEFAULT_SEARCH_FIELDS, field_boosts=_FIELD_BOOSTS,
|
||||
fuzzy_fields={f: (True, 1, True) for f in DEFAULT_SEARCH_FIELDS})
|
||||
clauses.append((tantivy.Occur.Should, tantivy.Query.boost_query(fuzzy, 0.1)))
|
||||
if cjk_query is not None:
|
||||
clauses.append((tantivy.Occur.Should, cjk_query))
|
||||
|
||||
return exact if len(clauses) == 1 else tantivy.Query.boolean_query(clauses)
|
||||
|
||||
|
||||
def _diagnostics_to_error(diagnostics: tuple[Diagnostic, ...]) -> SearchQueryError:
|
||||
errors = [_single_diagnostic_to_error(d) for d in diagnostics]
|
||||
return errors[0] if len(errors) == 1 else MultipleSearchQueryErrors(errors)
|
||||
|
||||
|
||||
def _single_diagnostic_to_error(d: Diagnostic) -> SearchQueryError:
|
||||
# d.field is a FieldRef, not a string: str(d.field) gives the canonical
|
||||
# dotted name (e.g. "created", "custom_fields.value"); an aliased query
|
||||
# (type:) reports the field it resolves to (document_type).
|
||||
field_name = str(d.field) if d.field is not None else None
|
||||
if d.kind is DiagnosticKind.BAD_DATE:
|
||||
return InvalidDateQuery(field_name, d.raw_value)
|
||||
if d.kind is DiagnosticKind.BAD_NUMBER:
|
||||
return InvalidNumberQuery(field_name, d.raw_value)
|
||||
# TOO_DEEP (pathological paren nesting) and UNSUPPORTED_PATTERN (a
|
||||
# wildcard/prefix pattern on a numeric, BOOLEAN_EXISTS, or JSON-subpath
|
||||
# field) both fall through to the generic message; a typed subclass for
|
||||
# either isn't warranted unless a caller needs to branch on it.
|
||||
return SearchQueryError(d.message)
|
||||
```
|
||||
|
||||
No `except Exception: query_str = raw_query` fallback — per the skill, that
|
||||
legacy defensive branch is explicitly not carried forward. A bug in the new
|
||||
path must surface as a real error, not silently degrade to stale behavior.
|
||||
|
||||
`views.py`'s existing `except SearchQueryError as e: raise
|
||||
ValidationError({"query": [str(e)]}) from e` handler gets one added branch
|
||||
to surface every aggregated message instead of just one:
|
||||
|
||||
```python
|
||||
except SearchQueryError as e:
|
||||
messages = [str(sub) for sub in e.errors] if isinstance(e, MultipleSearchQueryErrors) else [str(e)]
|
||||
raise ValidationError({"query": messages}) from e
|
||||
```
|
||||
|
||||
`d.field`/`d.raw_value` are populated for `BAD_DATE` and `BAD_NUMBER`
|
||||
diagnostics; if either is `None` for a diagnostic kind that doesn't populate
|
||||
them, `_single_diagnostic_to_error`'s fallthrough to
|
||||
`SearchQueryError(d.message)` still applies.
|
||||
|
||||
Deferred, explicitly out of scope for this PR stack: any frontend use of
|
||||
`startchar`/`endchar` (already present on `Diagnostic` today) to highlight
|
||||
the offending span in the search box. Backend-only for now, per explicit
|
||||
decision.
|
||||
|
||||
## Testing
|
||||
|
||||
Existing test inventory (`src/documents/tests/search/` and
|
||||
`test_api_search.py`):
|
||||
|
||||
| File | Fate |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `test_translate.py` | Deleted (PR 4) — subject deleted |
|
||||
| `test_query.py`: `TestCreatedDateField`, `TestDateTimeFields`, `TestWhooshQueryRewriting`, `TestYearRangeRewriting`, `TestNonDateFieldsNotRewritten`, `TestPassthrough`, `TestNormalizeQuery` | Deleted (PR 4) — test `_translate.py`/`_dates.py` internals or intermediate query strings |
|
||||
| `test_query.py`: `TestParseUserQuery` | Reviewed at plan time; result-level assertions folded into the new acceptance module, internals-only assertions dropped |
|
||||
| `test_query.py`: `TestParseSimpleTextHighlightQuery`, `TestPermissionFilter` | Unchanged — never touched `translate_query` |
|
||||
| `test_schema.py`, `test_tokenizer.py`, `test_backend.py`, `test_lock_backoff.py`, `test_migration_fulltext_query_field_prefixes.py` | Unchanged |
|
||||
| `test_api_search.py` (`TestDocumentSearchApi`, 43 tests) | **Stays green across every PR in the stack** (hard gate, not just PR 4) — full HTTP+DB+index integration coverage catches wiring mistakes none of the narrower tests would |
|
||||
|
||||
New tests per PR:
|
||||
|
||||
- **PR 2**: `test_registry.py` — internal `*_id` names rejected; `type`/
|
||||
`path` aliases resolve to canonical fields; JSON subpaths match
|
||||
`docs/usage.md`; registry construction deterministic per language; the
|
||||
`notes`/`custom_fields` dict-key coupling test described above.
|
||||
- **PR 3**: `test_date_grammar_parity.py` — transitional, parametrized over
|
||||
every keyword/unit `_dates.py`/`_translate.py` accept today
|
||||
(`_DATE_KEYWORDS`, all of `_UNIT_ALIASES`'s Whoosh-era abbreviations —
|
||||
`yrs`/`mos`/`wks`/`hrs`/`mins`/`secs` etc. — digit-precision forms, ISO
|
||||
dash forms, `now-7d`/`now+1h`/`now-30m` compact offsets, open/reversed
|
||||
ranges). Each case parses through `wc.parse()` against a DATE-kind
|
||||
`FieldRegistry` and asserts no diagnostics come back — a coverage check
|
||||
only (does whoosh-compat accept this input at all), not a check on the
|
||||
bounds or AST shape it parses to, which is whoosh-compat's own
|
||||
differential-testing responsibility against a real whoosh oracle, not
|
||||
something to re-verify here against `_translate.py` as a second, weaker
|
||||
oracle. If the team wants confidence that actual search _behavior_ at a
|
||||
given keyword didn't change, that belongs in the PR 4 result-level
|
||||
acceptance corpus (real indexed documents at date boundaries, matched-ID
|
||||
assertions), not an AST/bounds comparison.
|
||||
Deleted again in PR 4 along with the legacy code it audits, superseded by
|
||||
the permanent acceptance corpus. This audit is scoped to _parity_ only —
|
||||
whoosh-compat's date grammar is a strict superset of what `_dates.py`
|
||||
accepts today (e.g. `tomorrow`, `now`, `midnight`, `noon`, weekday names
|
||||
like `next monday`), so the migration also grants new date vocabulary for
|
||||
free. That's a nice side effect, not something this PR needs to test or
|
||||
document beyond noting it in the changelog alongside the other behavior
|
||||
changes.
|
||||
- **PR 4**:
|
||||
- Result-level acceptance module (paperless's analogue of whoosh-compat's
|
||||
`test_acceptance_e2e.py`): a real index built via `build_schema()`, the
|
||||
issue #13568 queries verbatim, real saved-view strings, every date
|
||||
keyword/unit, field aliases, comma lists, numeric/date ranges,
|
||||
bracket-class wildcards, boosts, JSON subpaths — asserted by matched
|
||||
document-ID set, `pytest.param(..., id=...)` per case. Plus a
|
||||
multi-diagnostic case (two bad fields → `MultipleSearchQueryErrors`
|
||||
with both messages present) and one `Multitoken` case nested inside a
|
||||
top-level `OR` (proves DIVERGENCES entry 15 doesn't matter for
|
||||
paperless's data, per the skill).
|
||||
- `test_api_search.py` expanded: a multi-bad-field query (e.g.
|
||||
`created:notadate AND asn:notanumber`) asserting the 400 response's
|
||||
`query` list contains both messages; end-to-end searches on the five
|
||||
newly-documented fields (`asn:`, `page_count:`, `num_notes:`,
|
||||
`original_filename:`, `checksum:`) returning the right documents
|
||||
through the real index.
|
||||
|
||||
## Dependency pinning
|
||||
|
||||
Stays `path = "../whoosh-compat"` in `[tool.uv.sources]` through the whole
|
||||
PR stack — both repos are being actively co-developed. The final swap
|
||||
happens at PR 4:
|
||||
|
||||
- **Primary plan**: whoosh-compat is released to PyPI around PR 3 (per
|
||||
your stated intent), assuming the parity audit doesn't turn up anything
|
||||
needing a second round. PR 4 switches to a pinned PyPI version
|
||||
(`whoosh-compat[tantivy]==X.Y.Z` in `dependencies`, the
|
||||
`[tool.uv.sources]` override removed entirely).
|
||||
- **Fallback**: if the PyPI release slips past PR 4's start, pin an exact
|
||||
git commit SHA instead (`whoosh-compat[tantivy] @ git+https://github.com/
|
||||
stumpylog/whoosh-compat@<sha>`), per the skill's "pre-1.0: pin an exact
|
||||
version or git SHA, upgrades are deliberate" guidance.
|
||||
|
||||
The `TODO` comment already sitting in `pyproject.toml` (from the earlier
|
||||
smoke-test setup) gets updated to reflect this — "release, else pinned SHA"
|
||||
— rather than committing hard to one path before it's known which applies.
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- `archive_checksum` indexing/search (separate schema-level follow-up).
|
||||
- Frontend consumption of `Diagnostic.startchar`/`endchar` for in-box error
|
||||
highlighting (backend-only for this PR stack).
|
||||
- A feature flag or shadow-compare rollout period — explicitly decided
|
||||
against; safety comes from the acceptance corpus and parity audit instead.
|
||||
- Any change to `build_permission_filter()`/`_apply_permission_filter()` —
|
||||
confirmed untouched by this migration.
|
||||
@@ -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.
|
||||
+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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user