Compare commits

..
Author SHA1 Message Date
stumpylog 5e4936ee83 Fix: Speed up AI chat scoping for users who can see most documents
A NOT IN list of the denied ids is as fast as an unfiltered query.

Chat now diffs the indexed document ids against the permitted ids and filters with whichever list is shorter.
A NOT IN filter would fail open for a document indexed after the permitted ids were read, so retrieved
nodes are also checked against the permitted set and dropped when they are not in it.
2026-11-12 08:58:56 -08:00
stumpylog 119a779629 Fix: Allow AI chat filters over more than 32700 documents
Non-superusers chatting across a large document set got "couldn't find any content" once they could see more than 32700 documents.

Lists above that size are now bound as a single JSON array and expanded with json_each(), which has no parameter limit.
2026-11-12 08:58:56 -08:00
Trenton H a1d76f3a19 Fix: Enforce CRLF when writing an email message to the file (#14745) 2026-11-12 00:18:43 +00:00
GitHub Actions f5ca9bbcb6 Auto translate strings 2026-11-11 23:51:43 +00:00
shamoon 60a35edee1 Enhancement: make bulk metadata operations run async (#14711) 2026-11-11 15:50:15 -08:00
Trenton H 7cdcd71a6d Performance: load a cached Hugging Face embedding model without contacting the Hub (#14728)
Every chat question and AI suggestion request builds the embedding model, and sentence-transformers
checks the Hub for a newer revision each time. With the model already cached that check cost about
2 seconds per request (0.2 seconds without it), and it fails outright when the Hub is unreachable.

Load from the local cache first and only download when the model is missing. A full rebuild still
checks the Hub, so a new model release is picked up together with re-embedding every document
instead of silently mixing revisions.
2026-11-11 21:53:27 +00:00
shamoon 759f9cd22b Fix: restore image without archive version thumbnail generation (#14741) 2026-11-11 10:12:47 -08:00
Trenton H d05e96cda2 Fix: Blacklist certain OCR user arguments that paperless itself controls/uses (#14734) 2026-11-11 03:13:20 +00:00
shamoon fefce7be1b Fix: remove redundant ui text filters (#14736) 2026-11-10 18:06:51 -08:00
GitHub Actions cc65226d6d Auto translate strings 2026-11-11 00:55:05 +00:00
shamoon 00096e0252 Fix: fix allowed_hosts default (#14735) 2026-11-11 00:53:49 +00:00
shamoon 2829e78009 Chore: add agents.md, update contributing guidelines (#14713) 2026-11-10 23:35:41 +00:00
dependabot[bot]andstumpylog d7d4014bbd Chore(deps): Bump the pre-commit-dependencies group with 4 updates (#14636)
* Chore(deps): Bump the pre-commit-dependencies group with 4 updates

Bumps the pre-commit-dependencies group with 4 updates: [https://github.com/rbubley/mirrors-prettier](https://github.com/rbubley/mirrors-prettier), prettier, [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit) and [https://github.com/tox-dev/pyproject-fmt](https://github.com/tox-dev/pyproject-fmt).


Updates `https://github.com/rbubley/mirrors-prettier` from v3.9.9 to 3.9.10
- [Commits](https://github.com/rbubley/mirrors-prettier/compare/v3.9.9...v3.9.10)

Updates `prettier` from 3.9.9 to 3.9.10

Updates `https://github.com/astral-sh/ruff-pre-commit` from v0.16.9 to 0.17.2
- [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases)
- [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.16.9...v0.17.2)

Updates `https://github.com/tox-dev/pyproject-fmt` from v2.29.4 to 2.30.3
- [Release notes](https://github.com/tox-dev/pyproject-fmt/releases)
- [Commits](https://github.com/tox-dev/pyproject-fmt/compare/v2.29.4...v2.30.3)

---
updated-dependencies:
- dependency-name: https://github.com/rbubley/mirrors-prettier
  dependency-version: 3.9.10
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
- dependency-name: prettier
  dependency-version: 3.9.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pre-commit-dependencies
- dependency-name: https://github.com/astral-sh/ruff-pre-commit
  dependency-version: 0.17.2
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
- dependency-name: https://github.com/tox-dev/pyproject-fmt
  dependency-version: 2.30.3
  dependency-type: direct:production
  dependency-group: pre-commit-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* Linting fixes

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: stumpylog <797416+stumpylog@users.noreply.github.com>
2026-11-10 17:49:06 +00:00
shamoon a139bdff46 Fixhancement: add automatic clearing of sessions (#14722) 2026-11-10 08:09:25 -08:00
79 changed files with 2527 additions and 2871 deletions

No files matched your search

@@ -1,5 +1,23 @@
title: "[Feature Request] "
body:
- type: markdown
attributes:
value: |
#### Use of AI tools
If this request was generated, in whole or in part, by an AI tool or agent you **must** say so using the field below. Failing to clearly disclose this is a violation of our [Code of Conduct](https://github.com/paperless-ngx/paperless-ngx/blob/main/CODE_OF_CONDUCT.md).
The project prefers thoughtful, well-described ideas to AI-generated content. Please describe the problem, your use case, the desired behavior, and who would benefit.
- type: dropdown
id: ai-usage
attributes:
label: AI usage
description: Was any part of this request written by an AI tool or agent?
options:
- No AI tools were used
- Yes, AI tools helped write this request
validations:
required: true
- type: textarea
id: description
attributes:
+16
View File
@@ -1,5 +1,21 @@
title: "[Support] "
body:
- type: markdown
attributes:
value: |
#### Use of AI tools
If this post was generated, in whole or in part, by an AI tool or agent you **must** say so using the field below. Failing to clearly disclose this is a violation of our [Code of Conduct](https://github.com/paperless-ngx/paperless-ngx/blob/main/CODE_OF_CONDUCT.md).
- type: dropdown
id: ai-usage
attributes:
label: AI usage
description: Was any part of this post written by an AI tool or agent?
options:
- No AI tools were used
- Yes, AI tools helped write this post
validations:
required: true
- type: textarea
id: description
attributes:
+4 -4
View File
@@ -38,7 +38,7 @@ repos:
- json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.9'
rev: 'v3.9.10'
hooks:
- id: prettier
types_or:
@@ -46,16 +46,16 @@ repos:
- ts
- markdown
additional_dependencies:
- prettier@3.9.9
- prettier@3.9.10
- 'prettier-plugin-organize-imports@4.3.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.9
rev: v0.17.2
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.29.4"
rev: "v2.30.3"
hooks:
- id: pyproject-fmt
additional_dependencies: [tomli]
+44
View File
@@ -0,0 +1,44 @@
# Guidance for AI agents
Read [CONTRIBUTING.md](CONTRIBUTING.md) before working on this repository, especially [Use of AI Tools](CONTRIBUTING.md#use-of-ai-tools). These contribution rules apply to all agent-assisted work.
## Contribution boundaries
- Do not open or submit pull requests, including "on behalf of" a human. The project does not accept PRs from AI agents. Prepare local changes for a human to review, verify, and submit personally.
- The project does not accept PRs that are entirely or mostly AI-derived. Human submission and disclosure do not override this rule.
- AI-generated content must be clearly disclosed as described in the contributing guidelines. When handing work back to a human, identify what was generated or changed with AI assistance so they can disclose it accurately.
- Trust matters. Encourage first-time contributors to discuss their proposed change before preparing a PR, especially for large changes or changes involving AI-generated code. New features and enhancements should almost always target an existing feature request with community interest and discussion.
- The project prefers thoughtful, well-described ideas to AI-generated pull requests. Help contributors describe their problem, use case, desired behavior, and who would benefit. An implementation is not required for a valuable contribution, and a flawed implementation can obscure a useful idea before it receives proper discussion.
- AI tools have lowered the effort needed to produce a PR, but reviewing and maintaining it still requires substantial work. Encourage discussion of the idea before investing in implementation and review.
- Issue reports must describe observed behavior, reproduction steps, and relevant logs. Do not add speculative root causes, code analysis, or proposed fixes to agent-generated reports.
## Security reports
- AI-assisted security reports must meet the legitimacy and verification requirements in [SECURITY.md](SECURITY.md#ai-assisted-submissions). A human must verify a real Paperless-ngx code path, concrete reproduction steps, affected versions, and realistic security impact before submission.
- Account for existing permission checks, configuration requirements, and trust boundaries. Speculation, scanner output, or concerns about third-party software or deployment choices without a demonstrated Paperless-ngx vulnerability do not meet this threshold and may be closed without extended discussion.
- Disclose AI assistance and use the private vulnerability reporting channel specified in [SECURITY.md](SECURITY.md#reporting-a-vulnerability).
## Working on changes
- Read the relevant code, tests, and documentation before editing. Paperless-ngx is complex: trace interactions between affected components rather than assuming a change is isolated.
- Keep changes focused on the requested work and follow existing patterns. Avoid unrelated refactoring, formatting, dependency updates, or generated-file changes.
- Consider effects on permissions, document processing, background tasks, storage, and the API when those areas are involved.
- Add or update meaningful tests for behavior changes and regressions. Do not weaken tests or checks to make a change pass.
- Update user or developer documentation when behavior or setup changes.
- Keep credentials and private documents out of commits, fixtures, logs, and examples.
## Repository and validation
- `src/`: Django back end.
- `src-ui/`: Angular front end, managed with `pnpm`.
- `docs/`: Documentation.
- `docker/` and `scripts/`: Deployment and development tooling.
- Base functional changes on `dev`; documentation-only changes may target `main`.
Follow [the development documentation](docs/development.md) for environment setup and testing. Common checks after setup are:
- Back end: run `uv run pytest` from `src/` (use a test path to focus on the affected area).
- Front end: run `pnpm test` and `pnpm lint` from `src-ui/`.
- Formatting and other pre-commit checks: run `uv run prek run --files <changed-files>` from the repository root.
When handing work back, summarize the changes, the checks actually run and their results, and any remaining uncertainty. Clearly state when a check could not be run; do not claim verification that did not happen. The human contributor must understand and independently verify the contribution before submitting it.
+16 -3
View File
@@ -4,6 +4,18 @@ If you feel like contributing to the project, please do! Bug fixes and improveme
⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged.
## Trust and collaboration
Paperless-ngx places a high value on trust between contributors and maintainers. If you have not contributed before, consider starting with a discussion before preparing a pull request, especially for a large contribution or one involving AI-generated code.
Paperless-ngx is complex, and understanding how its components interact takes time. A change that appears correct in isolation can have consequences elsewhere in the application. Contributors should understand the code they submit, explain how it fits into the project, and work with maintainers through review. Starting with smaller, focused contributions can help build that understanding and trust.
We prefer thoughtful, well-described ideas to AI-generated pull requests. You do not need to supply an implementation for your contribution to be valuable. Explain the problem you have encountered, your use case, the behavior you would like to see, and who would benefit. Concrete examples, alternatives you have considered, and relevant constraints help the community discuss and refine the idea.
AI tools have lowered the effort needed to produce a pull request, but evaluating one still requires substantial maintainer time. Maintainers still need to establish whether the implementation is correct, fits the project, and can be maintained over time.
A flawed implementation can also obscure a good idea: discussion becomes focused on problems with the generated code before the community has considered whether the underlying proposal is useful. Starting with the idea gives it a fair discussion on its own merits and lets contributors and maintainers agree on an approach before investing in implementation and review.
If you want to implement something big:
- As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together.
@@ -61,9 +73,10 @@ This project does not specifically prohibit the use of AI tools or agents _durin
1. Any content — code, pull request descriptions, issue reports or comments — that was generated in whole or in part by an AI tool or agent **must be clearly disclosed as such**. Failing to clearly disclose this is considered a violation of our [Code of Conduct](CODE_OF_CONDUCT.md) and may result in your contributions being closed and further action being taken.
2. AI-generated code must not violate copyright protections.
3. We will not accept PRs that are entirely or mostly AI-derived.
4. Issue reports must describe _observed behavior only_: what happened, how to reproduce it and the relevant logs. Reports generated by an AI agent should not include code analysis, speculation about the root cause or suggest fixes.
5. Contributors are responsible for everything they submit, regardless of how it was produced. Please do not open issues or PRs you have not verified yourself.
3. We will not accept PRs opened or submitted by AI agents, including agents acting "on behalf of" a human. A human contributor must personally review, verify, and submit the contribution and take responsibility for it throughout review.
4. We will not accept PRs that are entirely or mostly AI-derived.
5. Issue reports must describe _observed behavior only_: what happened, how to reproduce it and the relevant logs. Reports generated by an AI agent should not include code analysis, speculation about the root cause or suggest fixes.
6. Contributors are responsible for everything they submit, regardless of how it was produced. Please do not open issues or PRs you have not verified yourself.
# Translating Paperless-ngx
+2
View File
@@ -565,6 +565,8 @@ Specify `rebuild` to build the index from scratch from all documents in the data
this the first time you enable the feature, or after changing the embedding backend or
model.
When using the `huggingface` embedding backend, a model that is already downloaded is loaded from the local cache without contacting the Hugging Face Hub. Only `rebuild` checks the Hub for a newer revision of the model, so a model update is picked up together with re-embedding every document. If the model is not cached yet, it is downloaded on first use.
Specify `update` to incrementally index new and changed documents. This is what the
scheduled task runs.
+22 -11
View File
@@ -501,20 +501,20 @@ See the [Django project documentation on the settings](https://docs.djangoprojec
#### [`PAPERLESS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_ALLOWED_HOSTS) {#PAPERLESS_ALLOWED_HOSTS}
: If you're planning on putting Paperless on the open internet, then
you really should set this value to the domain name you're using.
Failing to do so leaves you open to HTTP host header attacks.
: If you expose Paperless to the internet, set `PAPERLESS_URL` to its public URL
or list its public hostname in `PAPERLESS_ALLOWED_HOSTS` to help protect against
HTTP Host header attacks.
You can read more about this in [the Django project's documentation](https://docs.djangoproject.com/en/4.1/topics/security/#host-header-validation)
Just remember that this is a comma-separated list, so
"example.com" is fine, as is "example.com,www.example.com", but
NOT " example.com" or "example.com,"
Separate multiple hostnames with commas, as in `example.com,www.example.com`.
Can also be set using PAPERLESS_URL (see above).
"localhost" is always allowed for docker healthcheck
Defaults to "\*", which is all hosts.
- With neither setting configured, Paperless accepts any host.
- With only `PAPERLESS_URL` set, Paperless accepts the hostname from that URL
and `localhost` (used by the Docker health check).
- `PAPERLESS_ALLOWED_HOSTS` adds hosts to `localhost` and the hostname from
`PAPERLESS_URL`, if set. A blank list adds no hosts.
- With `PAPERLESS_ALLOWED_HOSTS=*`, Paperless accepts any host, even when
`PAPERLESS_URL` is set.
#### [`PAPERLESS_CORS_ALLOWED_HOSTS=<comma-separated-list>`](#PAPERLESS_CORS_ALLOWED_HOSTS) {#PAPERLESS_CORS_ALLOWED_HOSTS}
@@ -873,6 +873,14 @@ If both the [PAPERLESS_ACCOUNT_DEFAULT_GROUPS](#PAPERLESS_ACCOUNT_DEFAULT_GROUPS
Defaults to 1209600 (2 weeks)
#### [`PAPERLESS_SESSION_CLEANUP_TASK_CRON=<cron expression>`](#PAPERLESS_SESSION_CLEANUP_TASK_CRON) {#PAPERLESS_SESSION_CLEANUP_TASK_CRON}
: Configures the schedule to remove expired sessions from the session store.
Django does not prune these automatically, so without this they would
accumulate indefinitely.
Defaults to `0 3 * * *`, once per day at 03:00.
#### [`PAPERLESS_TOKEN_THROTTLE_RATE=<rate>`](#PAPERLESS_TOKEN_THROTTLE_RATE) {#PAPERLESS_TOKEN_THROTTLE_RATE}
: Rate limit for the API token authentication endpoint (`/api/token/`), used to mitigate brute-force login attempts.
@@ -1149,6 +1157,9 @@ they use underscores instead of dashes.
be changed by trusted users. This applies to the `AppConfig` permission as well,
which allows setting these arguments from the UI.
Arguments paperless manages itself (`input_file_or_options`, `output_file`
and `sidecar`) are rejected by the API and ignored if set here.
Specify arguments as a JSON dictionary. Keep note of lower case
booleans and double quoted parameter names and strings. Examples:
+1 -6
View File
@@ -212,12 +212,7 @@ extend-select = [
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
]
ignore = [
"DJ001",
"PLC0415",
"RUF012",
"SIM105",
]
ignore = [ "DJ001", "ICN001", "PLC0415", "RUF012", "SIM105" ]
# Migrations
per-file-ignores."*/migrations/*.py" = [
"E501",
+160 -125
View File
@@ -1989,15 +1989,15 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">214</context>
<context context-type="linenumber">260</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">332</context>
<context context-type="linenumber">378</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">361</context>
<context context-type="linenumber">407</context>
</context-group>
</trans-unit>
<trans-unit id="6904866445262015585" datatype="html">
@@ -2052,7 +2052,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">58</context>
<context context-type="linenumber">60</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.html</context>
@@ -2098,7 +2098,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">224</context>
<context context-type="linenumber">227</context>
</context-group>
</trans-unit>
<trans-unit id="131016739441837046" datatype="html">
@@ -2109,7 +2109,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">236</context>
<context context-type="linenumber">239</context>
</context-group>
</trans-unit>
<trans-unit id="3643866539136239218" datatype="html">
@@ -2178,7 +2178,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">53</context>
<context context-type="linenumber">54</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context>
@@ -2471,11 +2471,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">326</context>
<context context-type="linenumber">340</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">360</context>
<context context-type="linenumber">374</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
@@ -2563,56 +2563,63 @@
<source>Result</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">54</context>
<context context-type="linenumber">55</context>
</context-group>
</trans-unit>
<trans-unit id="7839508346035554208" datatype="html">
<source>Task ID</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">56</context>
</context-group>
</trans-unit>
<trans-unit id="2952127331314675199" datatype="html">
<source>Needs attention</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">59</context>
<context context-type="linenumber">61</context>
</context-group>
</trans-unit>
<trans-unit id="3307214558906712688" datatype="html">
<source>In progress</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">60</context>
<context context-type="linenumber">62</context>
</context-group>
</trans-unit>
<trans-unit id="1371178633976505412" datatype="html">
<source>Recently completed</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">61</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit>
<trans-unit id="2936452645673957909" datatype="html">
<source>Consume File</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">70</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="5622386857946132209" datatype="html">
<source>Train Classifier</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">74</context>
<context context-type="linenumber">76</context>
</context-group>
</trans-unit>
<trans-unit id="776309955507105337" datatype="html">
<source>Sanity Check</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">78</context>
<context context-type="linenumber">80</context>
</context-group>
</trans-unit>
<trans-unit id="7502272564743467653" datatype="html">
<source>Mail Fetch</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">80</context>
<context context-type="linenumber">82</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
@@ -2623,63 +2630,63 @@
<source>LLM Index</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">81</context>
<context context-type="linenumber">83</context>
</context-group>
</trans-unit>
<trans-unit id="6402092370576716734" datatype="html">
<source>Empty Trash</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">84</context>
<context context-type="linenumber">86</context>
</context-group>
</trans-unit>
<trans-unit id="4173754487295857311" datatype="html">
<source>Check Workflows</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">88</context>
<context context-type="linenumber">90</context>
</context-group>
</trans-unit>
<trans-unit id="2065831805515896240" datatype="html">
<source>Bulk Update</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">92</context>
<context context-type="linenumber">94</context>
</context-group>
</trans-unit>
<trans-unit id="5062348912423871240" datatype="html">
<source>Reprocess Document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">96</context>
<context context-type="linenumber">98</context>
</context-group>
</trans-unit>
<trans-unit id="6903696804811148799" datatype="html">
<source>Build Share Link</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">100</context>
<context context-type="linenumber">102</context>
</context-group>
</trans-unit>
<trans-unit id="6581973658756561124" datatype="html">
<source>Bulk Delete</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">104</context>
<context context-type="linenumber">106</context>
</context-group>
</trans-unit>
<trans-unit id="5672013191898828327" datatype="html">
<source>Apply AI Suggestions</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">108</context>
<context context-type="linenumber">110</context>
</context-group>
</trans-unit>
<trans-unit id="9172233176401579786" datatype="html">
<source>Scheduled</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">118</context>
<context context-type="linenumber">120</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
@@ -2690,7 +2697,7 @@
<source>Web UI</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">120</context>
<context context-type="linenumber">122</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
@@ -2701,7 +2708,7 @@
<source>API Upload</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">123</context>
<context context-type="linenumber">125</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
@@ -2712,106 +2719,106 @@
<source>Folder Consume</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">127</context>
<context context-type="linenumber">129</context>
</context-group>
</trans-unit>
<trans-unit id="1456901843819961615" datatype="html">
<source>Email Consume</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">131</context>
<context context-type="linenumber">133</context>
</context-group>
</trans-unit>
<trans-unit id="29832309535656200" datatype="html">
<source>System</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">133</context>
<context context-type="linenumber">135</context>
</context-group>
</trans-unit>
<trans-unit id="2233560223291461480" datatype="html">
<source>Manual</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">134</context>
<context context-type="linenumber">136</context>
</context-group>
</trans-unit>
<trans-unit id="5404910960991552159" datatype="html">
<source>Dismiss selected</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">248</context>
<context context-type="linenumber">251</context>
</context-group>
</trans-unit>
<trans-unit id="9169677036332103838" datatype="html">
<source>Dismiss visible</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">249</context>
<context context-type="linenumber">252</context>
</context-group>
</trans-unit>
<trans-unit id="3169751690815214293" datatype="html">
<source>Confirm Dismiss</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">323</context>
<context context-type="linenumber">337</context>
</context-group>
</trans-unit>
<trans-unit id="5029621907742319073" datatype="html">
<source>Dismiss <x id="PH" equiv-text="tasks.size"/> tasks?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">324</context>
<context context-type="linenumber">338</context>
</context-group>
</trans-unit>
<trans-unit id="3597309129998924778" datatype="html">
<source>Error dismissing tasks</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">335</context>
<context context-type="linenumber">349</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">369</context>
<context context-type="linenumber">383</context>
</context-group>
</trans-unit>
<trans-unit id="2132179171926568807" datatype="html">
<source>Error dismissing task</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">347</context>
<context context-type="linenumber">361</context>
</context-group>
</trans-unit>
<trans-unit id="1323591410517879795" datatype="html">
<source>Confirm Dismiss All</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">357</context>
<context context-type="linenumber">371</context>
</context-group>
</trans-unit>
<trans-unit id="4157200209636243740" datatype="html">
<source>Dismiss all <x id="PH" equiv-text="this.totalTasks()"/> tasks?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">358</context>
<context context-type="linenumber">372</context>
</context-group>
</trans-unit>
<trans-unit id="8149502458056418229" datatype="html">
<source>Success. New document id <x id="PH" equiv-text="documentId"/> created</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">417</context>
<context context-type="linenumber">431</context>
</context-group>
</trans-unit>
<trans-unit id="8760066891202884337" datatype="html">
<source>Duplicate of document #<x id="PH" equiv-text="duplicateOf"/></source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">427</context>
<context context-type="linenumber">441</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.ts</context>
<context context-type="linenumber">461</context>
<context context-type="linenumber">475</context>
</context-group>
</trans-unit>
<trans-unit id="3418677553313974490" datatype="html">
@@ -3409,19 +3416,19 @@
</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">954</context>
<context context-type="linenumber">976</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">993</context>
<context context-type="linenumber">1015</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">1016</context>
<context context-type="linenumber">1038</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">1052</context>
<context context-type="linenumber">1074</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/custom-fields/custom-fields.component.ts</context>
@@ -3764,25 +3771,46 @@
<context context-type="linenumber">418</context>
</context-group>
</trans-unit>
<trans-unit id="3562016764120788499" datatype="html">
<source>Bulk operation completed.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">202</context>
</context-group>
</trans-unit>
<trans-unit id="1633341269280627980" datatype="html">
<source>Bulk operation revoked.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">204</context>
</context-group>
</trans-unit>
<trans-unit id="8606600295919857288" datatype="html">
<source>Bulk operation failed. Some changes may already have been applied.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">207</context>
</context-group>
</trans-unit>
<trans-unit id="1542489069631984294" datatype="html">
<source>Sidebar views updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">497</context>
<context context-type="linenumber">543</context>
</context-group>
</trans-unit>
<trans-unit id="3547923076537026828" datatype="html">
<source>Error updating sidebar views</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">500</context>
<context context-type="linenumber">546</context>
</context-group>
</trans-unit>
<trans-unit id="2526035785704676448" datatype="html">
<source>An error occurred while saving update checking settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">521</context>
<context context-type="linenumber">567</context>
</context-group>
</trans-unit>
<trans-unit id="4580988005648117665" datatype="html">
@@ -4163,27 +4191,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">572</context>
<context context-type="linenumber">594</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">612</context>
<context context-type="linenumber">634</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">650</context>
<context context-type="linenumber">672</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">688</context>
<context context-type="linenumber">710</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">750</context>
<context context-type="linenumber">772</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">888</context>
<context context-type="linenumber">910</context>
</context-group>
</trans-unit>
<trans-unit id="6088768251338567957" datatype="html">
@@ -6586,7 +6614,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">892</context>
<context context-type="linenumber">914</context>
</context-group>
</trans-unit>
<trans-unit id="1616074213645042277" datatype="html">
@@ -8363,7 +8391,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">517</context>
<context context-type="linenumber">539</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>
@@ -9160,7 +9188,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">890</context>
<context context-type="linenumber">912</context>
</context-group>
</trans-unit>
<trans-unit id="5992101894268263750" datatype="html">
@@ -9185,7 +9213,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">950</context>
<context context-type="linenumber">972</context>
</context-group>
</trans-unit>
<trans-unit id="2951161989614003846" datatype="html">
@@ -9571,7 +9599,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">1050</context>
<context context-type="linenumber">1072</context>
</context-group>
</trans-unit>
<trans-unit id="7251533806673754434" datatype="html">
@@ -9602,29 +9630,43 @@
<context context-type="linenumber">165,166</context>
</context-group>
</trans-unit>
<trans-unit id="3401633333450192408" datatype="html">
<source>Bulk operation queued.</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">315</context>
</context-group>
</trans-unit>
<trans-unit id="5709555629190115111" datatype="html">
<source>View task</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">319</context>
</context-group>
</trans-unit>
<trans-unit id="1215215387232313677" datatype="html">
<source>Error executing bulk operation</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">349</context>
<context context-type="linenumber">371</context>
</context-group>
</trans-unit>
<trans-unit id="7894972847287473517" datatype="html">
<source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot;</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">509</context>
<context context-type="linenumber">531</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">515</context>
<context context-type="linenumber">537</context>
</context-group>
</trans-unit>
<trans-unit id="8639884465898458690" datatype="html">
<source>&quot;<x id="PH" equiv-text="items[0].name"/>&quot; and &quot;<x id="PH_1" equiv-text="items[1].name"/>&quot;</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">511</context>
<context context-type="linenumber">533</context>
</context-group>
<note priority="1" from="description">This is for messages like &apos;modify &quot;tag1&quot; and &quot;tag2&quot;&apos;</note>
</trans-unit>
@@ -9632,7 +9674,7 @@
<source><x id="PH" equiv-text="list"/> and &quot;<x id="PH_1" equiv-text="items[items.length - 1].name"/>&quot;</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">519,521</context>
<context context-type="linenumber">541,543</context>
</context-group>
<note priority="1" from="description">this is for messages like &apos;modify &quot;tag1&quot;, &quot;tag2&quot; and &quot;tag3&quot;&apos;</note>
</trans-unit>
@@ -9640,14 +9682,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">536</context>
<context context-type="linenumber">558</context>
</context-group>
</trans-unit>
<trans-unit id="6619516195038467207" datatype="html">
<source>This operation will add the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; 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">542</context>
<context context-type="linenumber">564</context>
</context-group>
</trans-unit>
<trans-unit id="1894412783609570695" datatype="html">
@@ -9656,14 +9698,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">547,549</context>
<context context-type="linenumber">569,571</context>
</context-group>
</trans-unit>
<trans-unit id="7181166515756808573" datatype="html">
<source>This operation will remove the tag &quot;<x id="PH" equiv-text="tag.name"/>&quot; 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">555</context>
<context context-type="linenumber">577</context>
</context-group>
</trans-unit>
<trans-unit id="3819792277998068944" datatype="html">
@@ -9672,7 +9714,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">560,562</context>
<context context-type="linenumber">582,584</context>
</context-group>
</trans-unit>
<trans-unit id="2739066218579571288" datatype="html">
@@ -9683,84 +9725,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">564,568</context>
<context context-type="linenumber">586,590</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">605</context>
<context context-type="linenumber">627</context>
</context-group>
</trans-unit>
<trans-unit id="6900893559485781849" datatype="html">
<source>This operation will assign the correspondent &quot;<x id="PH" equiv-text="correspondent.name"/>&quot; 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">607</context>
<context context-type="linenumber">629</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">609</context>
<context context-type="linenumber">631</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">643</context>
<context context-type="linenumber">665</context>
</context-group>
</trans-unit>
<trans-unit id="332180123895325027" datatype="html">
<source>This operation will assign the document type &quot;<x id="PH" equiv-text="documentType.name"/>&quot; 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">645</context>
<context context-type="linenumber">667</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">647</context>
<context context-type="linenumber">669</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">681</context>
<context context-type="linenumber">703</context>
</context-group>
</trans-unit>
<trans-unit id="8750527458618415924" datatype="html">
<source>This operation will assign the storage path &quot;<x id="PH" equiv-text="storagePath.name"/>&quot; 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">683</context>
<context context-type="linenumber">705</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">685</context>
<context context-type="linenumber">707</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">714</context>
<context context-type="linenumber">736</context>
</context-group>
</trans-unit>
<trans-unit id="7966494636326273856" datatype="html">
<source>This operation will assign the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; 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">720</context>
<context context-type="linenumber">742</context>
</context-group>
</trans-unit>
<trans-unit id="5789455969634598553" datatype="html">
@@ -9769,14 +9811,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">725,727</context>
<context context-type="linenumber">747,749</context>
</context-group>
</trans-unit>
<trans-unit id="5648572354333199245" datatype="html">
<source>This operation will remove the custom field &quot;<x id="PH" equiv-text="customField.name"/>&quot; 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">733</context>
<context context-type="linenumber">755</context>
</context-group>
</trans-unit>
<trans-unit id="6666899594015948817" datatype="html">
@@ -9785,7 +9827,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">738,740</context>
<context context-type="linenumber">760,762</context>
</context-group>
</trans-unit>
<trans-unit id="8050047262594964176" datatype="html">
@@ -9796,112 +9838,105 @@
)"/> 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">742,746</context>
<context context-type="linenumber">764,768</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">889</context>
<context context-type="linenumber">911</context>
</context-group>
</trans-unit>
<trans-unit id="135114644550399974" datatype="html">
<source>Moved <x id="PH" equiv-text="count"/> 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">916</context>
<context context-type="linenumber">938</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">951</context>
<context context-type="linenumber">973</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">952</context>
<context context-type="linenumber">974</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">990</context>
<context context-type="linenumber">1012</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">991</context>
<context context-type="linenumber">1013</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">1014</context>
<context context-type="linenumber">1036</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">1015</context>
<context context-type="linenumber">1037</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">1038</context>
<context context-type="linenumber">1060</context>
</context-group>
</trans-unit>
<trans-unit id="1549333914563613027" datatype="html">
<source>The selected documents will become versions of the root 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">1051</context>
<context context-type="linenumber">1073</context>
</context-group>
</trans-unit>
<trans-unit id="7099667366799629283" datatype="html">
<source>Documents merged as versions.</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">1066</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">1092</context>
<context context-type="linenumber">1088</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">1101</context>
<context context-type="linenumber">1118</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">1149</context>
<context context-type="linenumber">1166</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">1156</context>
<context context-type="linenumber">1173</context>
</context-group>
</trans-unit>
<trans-unit id="6307402210351946694" datatype="html">
@@ -13369,42 +13404,42 @@
<source>Document already exists.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">27</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="5103087968344279314" datatype="html">
<source>Document already exists. Note: existing document is in the trash.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">28</context>
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="6108404046106249255" datatype="html">
<source>Document with ASN already exists.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">29</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="65951081560571094" datatype="html">
<source>Document with ASN already exists. Note: existing document is in the trash.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">30</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="148389968432135849" datatype="html">
<source>File not found.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">31</context>
<context context-type="linenumber">33</context>
</context-group>
</trans-unit>
<trans-unit id="1520671543092565667" datatype="html">
<source>Pre-consume script does not exist.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">32</context>
<context context-type="linenumber">34</context>
</context-group>
<note priority="1" from="description">Pre-Consume is a term that appears like that in the documentation as well and does not need a specific translation</note>
</trans-unit>
@@ -13412,7 +13447,7 @@
<source>Error while executing pre-consume script.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">33</context>
<context context-type="linenumber">35</context>
</context-group>
<note priority="1" from="description">Pre-Consume is a term that appears like that in the documentation as well and does not need a specific translation</note>
</trans-unit>
@@ -13420,7 +13455,7 @@
<source>Post-consume script does not exist.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">34</context>
<context context-type="linenumber">36</context>
</context-group>
<note priority="1" from="description">Post-Consume is a term that appears like that in the documentation as well and does not need a specific translation</note>
</trans-unit>
@@ -13428,7 +13463,7 @@
<source>Error while executing post-consume script.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">35</context>
<context context-type="linenumber">37</context>
</context-group>
<note priority="1" from="description">Post-Consume is a term that appears like that in the documentation as well and does not need a specific translation</note>
</trans-unit>
@@ -13436,49 +13471,49 @@
<source>Received new file.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">36</context>
<context context-type="linenumber">38</context>
</context-group>
</trans-unit>
<trans-unit id="7337565919209746135" datatype="html">
<source>File type not supported.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">37</context>
<context context-type="linenumber">39</context>
</context-group>
</trans-unit>
<trans-unit id="5002399167376099234" datatype="html">
<source>Processing document...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">38</context>
<context context-type="linenumber">40</context>
</context-group>
</trans-unit>
<trans-unit id="1085975194762600381" datatype="html">
<source>Generating thumbnail...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">39</context>
<context context-type="linenumber">41</context>
</context-group>
</trans-unit>
<trans-unit id="3280851677698431426" datatype="html">
<source>Retrieving date from document...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">40</context>
<context context-type="linenumber">42</context>
</context-group>
</trans-unit>
<trans-unit id="7162102384876037296" datatype="html">
<source>Saving document...</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">41</context>
<context context-type="linenumber">43</context>
</context-group>
</trans-unit>
<trans-unit id="4550450765009165976" datatype="html">
<source>Finished.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/services/websocket-status.service.ts</context>
<context context-type="linenumber">42</context>
<context context-type="linenumber">44</context>
</context-group>
</trans-unit>
<trans-unit id="2501522447884928778" datatype="html">
@@ -7,10 +7,15 @@ import {
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { Router } from '@angular/router'
import {
ActivatedRoute,
convertToParamMap,
ParamMap,
Router,
} from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { of, throwError } from 'rxjs'
import { BehaviorSubject, of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import {
PaperlessTask,
@@ -159,6 +164,7 @@ const sectionCountResponse = {
}
describe('TasksComponent', () => {
let queryParams: BehaviorSubject<ParamMap>
let component: TasksComponent
let fixture: ComponentFixture<TasksComponent>
let tasksService: TasksService
@@ -169,6 +175,7 @@ describe('TasksComponent', () => {
let toastService: ToastService
beforeEach(async () => {
queryParams = new BehaviorSubject(convertToParamMap({}))
TestBed.configureTestingModule({
imports: [
NgbModule,
@@ -181,6 +188,17 @@ describe('TasksComponent', () => {
ConfirmDialogComponent,
],
providers: [
{
provide: ActivatedRoute,
useValue: {
queryParamMap: queryParams.asObservable(),
snapshot: {
get queryParamMap() {
return queryParams.value
},
},
},
},
{
provide: PermissionsService,
useValue: {
@@ -219,7 +237,11 @@ describe('TasksComponent', () => {
})
// answers task list requests with the fixture tasks matching their status filter
function flushTaskLists(pageSize: number, count?: number) {
function flushTaskLists(
pageSize: number,
count?: number,
filteredTasks: PaperlessTask[] = tasks
) {
httpTestingController
.match(
(req) =>
@@ -228,7 +250,9 @@ describe('TasksComponent', () => {
)
.forEach((req) => {
const statuses = req.request.params.getAll('status')
const results = tasks.filter((task) => statuses.includes(task.status))
const results = filteredTasks.filter((task) =>
statuses.includes(task.status)
)
req.flush({ count: count ?? results.length, results })
})
}
@@ -238,6 +262,71 @@ describe('TasksComponent', () => {
flushTaskLists(25, count)
}
it('consumes a task link on load into the normal resettable Task ID filter', () => {
jest
.spyOn(tasksService, 'list')
.mockReturnValue(of({ count: 0, results: [] }))
jest
.spyOn(tasksService, 'statusCounts')
.mockReturnValue(of(sectionCountResponse))
jest.spyOn(router, 'navigate').mockResolvedValue(true)
queryParams.next(convertToParamMap({ task_id: 'linked-task' }))
fixture.destroy()
fixture = TestBed.createComponent(TasksComponent)
component = fixture.componentInstance
fixture.detectChanges()
expect(router.navigate).toHaveBeenCalledTimes(1)
expect(router.navigate).toHaveBeenCalledWith(
[],
expect.objectContaining({
queryParams: { task_id: null },
replaceUrl: true,
})
)
expect(component.filterTargetName).toBe('Task ID')
expect(component.filterText).toBe('linked-task')
expect(component.isFiltered).toBe(true)
component.setSection(TaskSection.InProgress)
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
task_id: 'linked-task',
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
})
component.resetFilters()
expect(component.filterText).toBe('')
expect(component.isFiltered).toBe(false)
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
acknowledged: false,
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
})
expect(router.navigate).toHaveBeenCalledTimes(1)
})
it('supports manually filtering by Task ID and clearing the text filter', () => {
jest
.spyOn(tasksService, 'list')
.mockReturnValue(of({ count: 0, results: [] }))
jest
.spyOn(tasksService, 'statusCounts')
.mockReturnValue(of(sectionCountResponse))
component.setSection(TaskSection.InProgress)
component.setFilterTarget(TaskFilterTargetID.TaskId)
component.filterText = tasks[0].task_id
jest.advanceTimersByTime(150)
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
task_id: tasks[0].task_id,
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
})
component.pagedTasks.set(tasks)
expect(component.tasksForSection(TaskSection.NeedsAttention)).toEqual([
tasks[0],
])
component.resetFilter()
expect(tasksService.list).toHaveBeenLastCalledWith(1, 25, {
acknowledged: false,
status: [PaperlessTaskStatus.Pending, PaperlessTaskStatus.Started],
})
})
it('should display task sections with counts', () => {
expect(component.selectedSection).toBe(TaskSection.All)
expect(component.selectedTaskType).toBeNull()
@@ -771,6 +860,7 @@ describe('TasksComponent', () => {
input.nativeElement.value = '191092'
input.nativeElement.dispatchEvent(new Event('input'))
jest.advanceTimersByTime(150)
flushTaskLists(10, undefined, [tasks[1]])
fixture.detectChanges()
expect(component.filterText).toEqual('191092')
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
@@ -782,6 +872,7 @@ describe('TasksComponent', () => {
component.setSection(TaskSection.InProgress)
component.filterText = 'system'
jest.advanceTimersByTime(150)
flushTaskLists(25, undefined, [tasks[6]])
expect(component.tasksForSection(TaskSection.InProgress)).toHaveLength(1)
expect(component.tasksForSection(TaskSection.InProgress)[0].task_type).toBe(
@@ -815,6 +906,7 @@ describe('TasksComponent', () => {
input.nativeElement.value = 'duplicate'
input.nativeElement.dispatchEvent(new Event('input'))
jest.advanceTimersByTime(150)
flushTaskLists(25, undefined, [tasks[0], tasks[1]])
fixture.detectChanges()
expect(component.filterText).toEqual('duplicate')
expect(component.tasksForSection(TaskSection.NeedsAttention)).toHaveLength(
@@ -822,6 +914,53 @@ describe('TasksComponent', () => {
)
})
it('should show server-matched names when the displayed source is translated', () => {
component.setSection(TaskSection.InProgress)
flushTaskLists(25)
component.filterText = 'email'
jest.advanceTimersByTime(150)
const request = httpTestingController.expectOne(
(req) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('name') === 'email' &&
req.params.get('page_size') === '25'
)
const translatedTask = {
...tasks[5],
trigger_source_display: 'E-Mail',
}
request.flush({ count: 1, results: [translatedTask] })
fixture.detectChanges()
expect(component.totalTasks()).toBe(1)
expect(component.visibleTasks).toEqual([translatedTask])
})
it('should show server-matched duplicates when the displayed result is translated', () => {
component.setSection(TaskSection.NeedsAttention)
flushTaskLists(25)
jest
.spyOn(component, 'taskResultMessage')
.mockReturnValue('Duplikat von Document #311')
component.setFilterTarget(TaskFilterTargetID.Result)
component.filterText = 'duplicate'
jest.advanceTimersByTime(150)
const request = httpTestingController.expectOne(
(req) =>
req.url === `${environment.apiBaseUrl}tasks/` &&
req.params.get('result') === 'duplicate' &&
req.params.get('page_size') === '25'
)
request.flush({ count: 1, results: [tasks[1]] })
fixture.detectChanges()
expect(component.totalTasks()).toBe(1)
expect(component.visibleTasks).toEqual([tasks[1]])
expect(fixture.nativeElement.textContent).toContain('191092.pdf')
})
it('should prefer explicit reason in the result message', () => {
expect(
component.taskResultMessage({
@@ -1,7 +1,7 @@
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router, RouterLink } from '@angular/router'
import { ActivatedRoute, Router, RouterLink } from '@angular/router'
import {
NgbCollapseModule,
NgbDropdownModule,
@@ -47,11 +47,13 @@ export enum TaskSection {
export enum TaskFilterTargetID {
Name,
Result,
TaskId,
}
const FILTER_TARGETS = [
{ id: TaskFilterTargetID.Name, name: $localize`Name` },
{ id: TaskFilterTargetID.Result, name: $localize`Result` },
{ id: TaskFilterTargetID.TaskId, name: $localize`Task ID` },
]
const SECTION_LABELS = {
@@ -161,6 +163,7 @@ export class TasksComponent
tasksService = inject(TasksService)
private modalService = inject(NgbModal)
private readonly router = inject(Router)
private readonly route = inject(ActivatedRoute)
private readonly toastService = inject(ToastService)
readonly TaskSection = TaskSection
@@ -275,6 +278,17 @@ export class TasksComponent
}
ngOnInit() {
const taskId = this.route.snapshot.queryParamMap.get('task_id')
if (taskId) {
this.filterTargetID = TaskFilterTargetID.TaskId
this._filterText = taskId
this.router.navigate([], {
relativeTo: this.route,
queryParams: { task_id: null },
queryParamsHandling: 'merge',
replaceUrl: true,
})
}
this.tasksService.reload()
this.reloadPage()
timer(5000, 5000)
@@ -636,17 +650,12 @@ export class TasksComponent
const query = this._filterText.toLowerCase()
if (this.filterTargetID == TaskFilterTargetID.Name) {
return [
this.taskDisplayName(task),
task.task_type_display,
task.trigger_source_display,
]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query))
if (this.filterTargetID === TaskFilterTargetID.TaskId) {
return task.task_id.toLowerCase() === query
}
return this.taskResultMessage(task)?.toLowerCase().includes(query) ?? false
// Text filtering now handled API-side
return true
}
private tasksForOptionCounts({
@@ -706,9 +715,15 @@ export class TasksComponent
}
if (this._filterText.length) {
params[
this.filterTargetID === TaskFilterTargetID.Name ? 'name' : 'result'
] = this._filterText
const parameter = {
[TaskFilterTargetID.Name]: 'name',
[TaskFilterTargetID.Result]: 'result',
[TaskFilterTargetID.TaskId]: 'task_id',
}[this.filterTargetID]
params[parameter] = this._filterText
if (this.filterTargetID === TaskFilterTargetID.TaskId) {
delete params.acknowledged
}
}
return params
@@ -11,8 +11,10 @@ import { ActivatedRoute, Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
import { of, throwError } from 'rxjs'
import { of, Subject, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { BulkOperationStatus } from 'src/app/data/bulk-operation'
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
import { SavedView } from 'src/app/data/saved-view'
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -21,10 +23,11 @@ import {
DjangoMessageLevel,
DjangoMessagesService,
} from 'src/app/services/django-messages.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import {
PermissionType,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
@@ -95,6 +98,7 @@ describe('AppFrameComponent', () => {
let savedViewSpy
let modalService: NgbModal
let maybeRefreshSpy
let bulkEvents: Subject<BulkOperationStatus>
let tasksService: TasksService
beforeEach(async () => {
@@ -182,6 +186,10 @@ describe('AppFrameComponent', () => {
savedViewSpy = jest.spyOn(savedViewService, 'reload')
maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts')
bulkEvents = new Subject<BulkOperationStatus>()
jest
.spyOn(TestBed.inject(WebsocketStatusService), 'onBulkOperationStatus')
.mockReturnValue(bulkEvents.asObservable())
fixture = TestBed.createComponent(AppFrameComponent)
component = fixture.componentInstance
@@ -190,6 +198,78 @@ describe('AppFrameComponent', () => {
fixture.detectChanges()
})
it('refreshes and notifies on completion without client-side task tracking', () => {
const list = TestBed.inject(DocumentListViewService)
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
const tasksReload = jest
.spyOn(tasksService, 'reload')
.mockImplementation(() => {})
const success = jest.spyOn(toastService, 'showSuccess')
const refresh = jest
.spyOn(openDocumentsService, 'refreshDocument')
.mockImplementation(() => {})
jest
.spyOn(openDocumentsService, 'getOpenDocuments')
.mockReturnValue([document])
tasksReload.mockClear()
maybeRefreshSpy.mockClear()
bulkEvents.next({
task_id: 'bulk-task',
operation: 'set_permissions',
status: PaperlessTaskStatus.Started,
})
expect(reload).not.toHaveBeenCalled()
bulkEvents.next({
task_id: 'bulk-task',
operation: 'set_permissions',
status: PaperlessTaskStatus.Success,
})
expect(reload).toHaveBeenCalledTimes(1)
expect(reload).toHaveBeenCalledWith(null, false)
expect(refresh).toHaveBeenCalledWith(document.id)
expect(maybeRefreshSpy).toHaveBeenCalledTimes(1)
expect(tasksReload).toHaveBeenCalledTimes(1)
expect(success).toHaveBeenCalledWith('Bulk operation completed.')
fixture.destroy()
bulkEvents.next({
task_id: 'bulk-task',
operation: 'set_permissions',
status: PaperlessTaskStatus.Success,
})
expect(reload).toHaveBeenCalledTimes(1)
})
it('refreshes and reports failed or revoked bulk operations', () => {
const list = TestBed.inject(DocumentListViewService)
const reload = jest.spyOn(list, 'reload').mockImplementation(() => {})
jest.spyOn(list, 'reduceSelectionToFilter').mockImplementation(() => {})
jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
const error = jest.spyOn(toastService, 'showError')
const success = jest.spyOn(toastService, 'showSuccess')
for (const status of [
PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked,
]) {
bulkEvents.next({
task_id: 'bulk-task',
operation: 'set_permissions',
status,
error:
status === PaperlessTaskStatus.Revoked
? 'Task expired before execution.'
: 'Could not update',
})
}
expect(reload).toHaveBeenCalledTimes(2)
expect(error).toHaveBeenCalledWith(expect.any(String), 'Could not update')
expect(error).toHaveBeenCalledWith(
'Bulk operation revoked.',
'Task expired before execution.'
)
expect(success).not.toHaveBeenCalled()
})
it('should initialize the saved view service', () => {
expect(savedViewSpy).toHaveBeenCalled()
})
@@ -8,12 +8,14 @@ import {
import { NgClass } from '@angular/common'
import {
Component,
DestroyRef,
HostListener,
inject,
OnInit,
signal,
viewChild,
} from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import {
@@ -24,10 +26,11 @@ import {
NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { Observable } from 'rxjs'
import { filter, Observable } from 'rxjs'
import { first } from 'rxjs/operators'
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
import { Document } from 'src/app/data/document'
import { PaperlessTaskStatus } from 'src/app/data/paperless-task'
import { SavedView } from 'src/app/data/saved-view'
import {
CollapsibleSection,
@@ -41,6 +44,7 @@ import {
DjangoMessageLevel,
DjangoMessagesService,
} from 'src/app/services/django-messages.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import {
PermissionAction,
@@ -101,6 +105,8 @@ export class AppFrameComponent
extends ComponentWithPermissions
implements OnInit, ComponentCanDeactivate
{
private destroyRef = inject(DestroyRef)
private documentList = inject(DocumentListViewService)
router = inject(Router)
private activatedRoute = inject(ActivatedRoute)
private openDocumentsService = inject(OpenDocumentsService)
@@ -164,6 +170,46 @@ export class AppFrameComponent
}
ngOnInit(): void {
this.websocketStatusService
.onBulkOperationStatus()
.pipe(
filter(({ status }) =>
[
PaperlessTaskStatus.Success,
PaperlessTaskStatus.Failure,
PaperlessTaskStatus.Revoked,
].includes(status)
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ status, error }) => {
this.documentList.reload(null, false)
this.documentList.reduceSelectionToFilter()
// maybe someday we can optimize to only refresh affected documents
this.openDocumentsService.getOpenDocuments().forEach((doc) => {
this.openDocumentsService.refreshDocument(doc.id)
})
this.savedViewService.maybeRefreshDocumentCounts()
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.PaperlessTask
)
) {
this.tasksService.reload()
}
if (status === PaperlessTaskStatus.Success) {
this.toastService.showSuccess($localize`Bulk operation completed.`)
} else if (status === PaperlessTaskStatus.Revoked) {
this.toastService.showError($localize`Bulk operation revoked.`, error)
} else {
this.toastService.showError(
$localize`Bulk operation failed. Some changes may already have been applied.`,
error
)
}
})
this.lastScrollY = window.scrollY
this.detectClassicScrollbars()
@@ -559,18 +559,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4],
method: 'modify_tags',
parameters: { add_tags: [101], remove_tags: [] },
})
httpTestingController.match(
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should execute modify tags bulk operation for all filtered documents', () => {
@@ -607,17 +608,18 @@ describe('BulkEditorComponent', () => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
all: true,
filters: { title_search: 'apple' },
excluded_documents: [4],
method: 'modify_tags',
parameters: { add_tags: [101], remove_tags: [] },
})
httpTestingController.match(
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
)
})
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
@@ -644,12 +646,12 @@ describe('BulkEditorComponent', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should set modal dialog text accordingly for tag edit confirmation', () => {
@@ -729,18 +731,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4],
method: 'set_correspondent',
parameters: { correspondent: 101 },
})
httpTestingController.match(
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should execute modify correspondent bulk operation with confirmation dialog if enabled', () => {
@@ -767,12 +770,12 @@ describe('BulkEditorComponent', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should set modal dialog text accordingly for correspondent edit confirmation', () => {
@@ -827,18 +830,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4],
method: 'set_document_type',
parameters: { document_type: 101 },
})
httpTestingController.match(
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should execute modify document type bulk operation with confirmation dialog if enabled', () => {
@@ -865,12 +869,12 @@ describe('BulkEditorComponent', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should set modal dialog text accordingly for document type edit confirmation', () => {
@@ -925,18 +929,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4],
method: 'set_storage_path',
parameters: { storage_path: 101 },
})
httpTestingController.match(
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should execute modify storage path bulk operation with confirmation dialog if enabled', () => {
@@ -963,12 +968,12 @@ describe('BulkEditorComponent', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should set modal dialog text accordingly for storage path edit confirmation', () => {
@@ -1023,18 +1028,19 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4],
method: 'modify_custom_fields',
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
})
httpTestingController.match(
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should execute modify custom fields bulk operation with confirmation dialog if enabled', () => {
@@ -1061,12 +1067,12 @@ describe('BulkEditorComponent', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
// coverage for modal messages
component.setCustomFields({
@@ -1540,6 +1546,47 @@ describe('BulkEditorComponent', () => {
)
})
it.each([true, false])(
'shows queued feedback and gates the task link on permission (%s)',
(canViewTasks) => {
jest
.spyOn(permissionsService, 'currentUserCan')
.mockReturnValue(canViewTasks)
const queued = jest.spyOn(toastService, 'show')
let modal: NgbModalRef
modalService.activeInstances.subscribe((instances) => {
if (instances.length) modal = instances[0]
})
const reload = jest.spyOn(documentListViewService, 'reload')
component.setPermissions()
const close = jest.spyOn(modal, 'close')
modal.componentInstance.confirmClicked.emit({
permissions: {},
merge: true,
})
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush({ result: 'OK', task_id: 'queued-operation' })
expect(close).toHaveBeenCalledTimes(1)
const notification = queued.mock.calls.at(-1)[0]
expect(notification.content).toBe('Bulk operation queued.')
if (canViewTasks) {
expect(notification.actionName).toBe('View task')
notification.action()
expect(router.navigate).toHaveBeenCalledWith(['/tasks'], {
queryParams: { task_id: 'queued-operation' },
})
} else {
expect(notification.action).toBeUndefined()
expect(notification.actionName).toBeUndefined()
}
expect(reload).not.toHaveBeenCalled()
expect(documentListViewService.selected.size).toBe(0)
expect(documentListViewService.allSelected).toBe(false)
}
)
it('should support bulk permissions update', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
@@ -1572,8 +1619,9 @@ describe('BulkEditorComponent', () => {
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
req.flush({ result: 'OK', task_id: 'queued-task' })
expect(req.request.body).toEqual({
from_webui: true,
documents: [3, 4],
method: 'set_permissions',
parameters: {
@@ -1581,12 +1629,12 @@ describe('BulkEditorComponent', () => {
merge: true,
},
})
httpTestingController.match(
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should not attempt to retrieve objects if user does not have permissions', () => {
@@ -1887,15 +1935,18 @@ describe('BulkEditorComponent', () => {
expect(toastServiceShowErrorSpy).toHaveBeenCalled()
expect(listReloadSpy).not.toHaveBeenCalled()
modal.componentInstance.succeeded.emit()
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
expect(listReloadSpy).toHaveBeenCalled()
httpTestingController.match(
modal.componentInstance.succeeded.emit({
result: 'OK',
task_id: 'custom-fields-task',
})
expect(toastServiceShowInfoSpy).not.toHaveBeenCalled()
expect(listReloadSpy).not.toHaveBeenCalled()
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
) // list reload
httpTestingController.match(
)
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
)
})
it('should create share link bundle and enable manage callback', () => {
@@ -104,11 +104,11 @@ export class BulkEditorComponent
private correspondentService = inject(CorrespondentService)
list = inject(DocumentListViewService)
private documentService = inject(DocumentService)
private trashService = inject(TrashService)
private modalService = inject(NgbModal)
private openDocumentService = inject(OpenDocumentsService)
private settings = inject(SettingsService)
private toastService = inject(ToastService)
private trashService = inject(TrashService)
private storagePathService = inject(StoragePathService)
private customFieldService = inject(CustomFieldsService)
public readonly permissionService = inject(PermissionsService)
@@ -299,11 +299,33 @@ export class BulkEditorComponent
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
.pipe(first())
.subscribe({
next: () => this.handleOperationSuccess(modal),
next: ({ task_id }) => this.handleOperationQueued(modal, task_id),
error: (error) => this.handleOperationError(modal, error),
})
}
private handleOperationQueued(modal: NgbModalRef, taskId: string) {
modal?.close()
this.list.selectNone()
const canViewTasks = this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.PaperlessTask
)
this.toastService.show({
content: $localize`Bulk operation queued.`,
delay: 5000,
...(canViewTasks
? {
actionName: $localize`View task`,
action: () =>
this.router.navigate(['/tasks'], {
queryParams: { task_id: taskId },
}),
}
: {}),
})
}
private executeDocumentAction(
modal: NgbModalRef,
request: Observable<any>,
@@ -1088,14 +1110,9 @@ export class BulkEditorComponent
dialog.selection = this.getSelectionQuery()
dialog.selectionCount = this.getSelectionSize()
dialog.succeeded.subscribe((result) => {
this.toastService.showSuccess($localize`Custom fields updated.`)
this.list.reload()
this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => {
this.openDocumentService.refreshDocument(id)
})
})
dialog.succeeded.subscribe(({ task_id }) =>
this.handleOperationQueued(null, task_id)
)
dialog.failed.subscribe((error) => {
this.toastService.showError(
$localize`Error updating custom fields.`,
@@ -59,7 +59,7 @@ describe('CustomFieldsBulkEditDialogComponent', () => {
it('should emit succeeded event and close modal on successful save', () => {
const editSpy = jest
.spyOn(documentService, 'bulkEdit')
.mockReturnValue(of('Success'))
.mockReturnValue(of({ result: 'OK', task_id: 'custom-fields-task' }))
const successSpy = jest.spyOn(component.succeeded, 'emit')
component.selection = [1, 2]
@@ -16,6 +16,7 @@ import { NumberComponent } from 'src/app/components/common/input/number/number.c
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
import { TextComponent } from 'src/app/components/common/input/text/text.component'
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
import { BulkOperationResponse } from 'src/app/data/bulk-operation'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import {
DocumentSelectionQuery,
@@ -49,7 +50,7 @@ export class CustomFieldsBulkEditDialogComponent {
CustomFieldDataType = CustomFieldDataType
@Output()
succeeded = new EventEmitter()
succeeded = new EventEmitter<BulkOperationResponse>()
@Output()
failed = new EventEmitter()
@@ -103,6 +104,8 @@ export class CustomFieldsBulkEditDialogComponent {
}
public save() {
if (this.networkActive) return
this.networkActive = true
this.documentService
.bulkEdit(this.selection, 'modify_custom_fields', {
add_custom_fields: this.form.value,
@@ -110,11 +113,12 @@ export class CustomFieldsBulkEditDialogComponent {
})
.pipe(first())
.subscribe({
next: () => {
next: (response) => {
this.activeModal.close()
this.succeeded.emit()
this.succeeded.emit(response)
},
error: (error) => {
this.networkActive = false
this.failed.emit(error)
},
})
+13
View File
@@ -0,0 +1,13 @@
import { PaperlessTaskStatus } from './paperless-task'
export interface BulkOperationResponse {
result: string
task_id: string
}
export interface BulkOperationStatus {
task_id: string
operation: string
status: PaperlessTaskStatus
error?: string
}
@@ -124,6 +124,42 @@ describe('DocumentListViewService', () => {
})
})
it.each([false, true])(
'preserves the current route on a background refresh (saved view: %s)',
(savedView) => {
if (savedView) documentListViewService.activateSavedView(view)
const navigate = jest.mocked(router.navigate)
navigate.mockClear()
documentListViewService.reload(null, false)
httpTestingController
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
.flush(full_results)
expect(documentListViewService.documents).toEqual(full_results.results)
expect(navigate).not.toHaveBeenCalled()
}
)
it('preserves the route when a background refresh falls back to the first page', () => {
jest
.spyOn(documentListViewService, 'saveDocumentListView')
.mockImplementation(() => {})
documentListViewService.currentPage = 2
httpTestingController
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
.flush(full_results)
const navigate = jest.mocked(router.navigate)
navigate.mockClear()
documentListViewService.reload(null, false)
httpTestingController
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
.flush('Not found', { status: 404, statusText: 'Not found' })
httpTestingController
.expectOne((req) => req.url === `${environment.apiBaseUrl}documents/`)
.flush(full_results)
expect(documentListViewService.currentPage).toBe(1)
expect(navigate).not.toHaveBeenCalled()
})
it('should reload the list', () => {
expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.reload()
@@ -380,7 +380,7 @@ export class DocumentListViewService {
queryParams: paramsFromViewState(activeListViewState),
replaceUrl: !this.router.routerState.snapshot.url.includes('?'), // in case navigating from params-less /documents
})
} else if (this._activeSavedViewId) {
} else if (updateQueryParams && this._activeSavedViewId) {
this.router.navigate([], {
queryParams: paramsFromViewState(activeListViewState, true),
queryParamsHandling: 'merge',
@@ -399,7 +399,7 @@ export class DocumentListViewService {
// this happens when applying a filter: the current page might not be available anymore due to the reduced result set.
activeListViewState.currentPage = 1
this.markChanged()
this.reload()
this.reload(onFinish, updateQueryParams)
} else if (
activeListViewState.sortField.indexOf('custom_field') === 0 &&
!this.settings
@@ -248,6 +248,7 @@ describe(`DocumentService`, () => {
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
from_webui: true,
documents: ids,
method,
parameters,
@@ -271,6 +272,7 @@ describe(`DocumentService`, () => {
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
from_webui: true,
all: true,
filters: { title__icontains: 'apple' },
excluded_documents: [2, 3],
@@ -14,6 +14,7 @@ import { DocumentSuggestions } from 'src/app/data/document-suggestions'
import { FilterRule } from 'src/app/data/filter-rule'
import { Results, SelectionData } from 'src/app/data/results'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { BulkOperationResponse } from '../../data/bulk-operation'
import { queryParamsFromFilterRules } from '../../utils/query-params'
import {
PermissionAction,
@@ -337,11 +338,15 @@ export class DocumentService extends AbstractPaperlessService<Document> {
method: DocumentBulkEditMethod,
args: any
) {
return this.http.post(this.getResourceUrl(null, 'bulk_edit'), {
...selection,
method: method,
parameters: args,
})
return this.http.post<BulkOperationResponse>(
this.getResourceUrl(null, 'bulk_edit'),
{
...selection,
method: method,
from_webui: true,
parameters: args,
}
)
}
deleteDocuments(selection: DocumentSelectionQuery) {
@@ -438,6 +438,20 @@ describe('ConsumerStatusService', () => {
expect(updated).toBeTruthy()
})
it('forwards bulk operation completion events', () => {
const received = jest.fn()
websocketStatusService.onBulkOperationStatus().subscribe(received)
const data = {
task_id: 'bulk-task',
operation: 'set_permissions',
status: 'success',
}
websocketStatusService.connect()
server.send({ type: WebsocketStatusType.BULK_OPERATION_STATUS, data })
websocketStatusService.disconnect()
expect(received).toHaveBeenCalledWith(data)
})
it('should ignore keep-alive heartbeat messages from the server', () => {
let updated = false
let deleted = false
@@ -1,6 +1,7 @@
import { Injectable, inject, signal } from '@angular/core'
import { Subject } from 'rxjs'
import { environment } from 'src/environments/environment'
import { BulkOperationStatus } from '../data/bulk-operation'
import { User } from '../data/user'
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
@@ -12,6 +13,7 @@ export enum WebsocketStatusType {
DOCUMENTS_DELETED = 'documents_deleted',
DOCUMENT_UPDATED = 'document_updated',
HEARTBEAT = 'heartbeat',
BULK_OPERATION_STATUS = 'bulk_operation_status',
}
// see ProgressStatusOptions in src/documents/plugins/helpers.py
@@ -116,6 +118,13 @@ export class WebsocketStatusService {
private readonly documentDeletedSubject = new Subject<boolean>()
private readonly documentUpdatedSubject =
new Subject<WebsocketDocumentUpdatedMessage>()
private readonly bulkOperationStatusSubject =
new Subject<BulkOperationStatus>()
onBulkOperationStatus() {
return this.bulkOperationStatusSubject.asObservable()
}
private readonly connectionStatusSubject = new Subject<boolean>()
private get(taskId: string, filename?: string) {
@@ -192,6 +201,7 @@ export class WebsocketStatusService {
| WebsocketProgressMessage
| WebsocketDocumentsDeletedMessage
| WebsocketDocumentUpdatedMessage
| BulkOperationStatus
} = JSON.parse(ev.data)
switch (type) {
@@ -209,6 +219,12 @@ export class WebsocketStatusService {
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
break
case WebsocketStatusType.BULK_OPERATION_STATUS:
this.bulkOperationStatusSubject.next(
messageData as BulkOperationStatus
)
break
case WebsocketStatusType.HEARTBEAT:
// keep-alive from the server, see paperless.consumers.StatusConsumer
break
+53 -36
View File
@@ -48,6 +48,7 @@ from documents.versioning import get_root_document
from documents.versioning import versions_newest_first
if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Mapping
from django.contrib.auth.models import User
@@ -131,9 +132,24 @@ def _scratch_path(name: str) -> Path:
return Path(tempfile.mkdtemp(dir=settings.SCRATCH_DIR)) / name
def _finish_metadata_edit(
document_ids: list[int],
update_callback: Callable[[list[int]], None] | None,
) -> None:
if update_callback is not None:
update_callback(document_ids)
else:
bulk_update_documents.apply_async(
kwargs={"document_ids": document_ids},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
def set_correspondent(
doc_ids: list[int],
correspondent: Correspondent,
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]:
if correspondent:
correspondent = Correspondent.objects.only("pk").get(id=correspondent)
@@ -146,15 +162,17 @@ def set_correspondent(
affected_docs = list(qs.values_list("pk", flat=True))
qs.update(correspondent=correspondent)
bulk_update_documents.apply_async(
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
_finish_metadata_edit(affected_docs, update_callback)
return "OK"
def set_storage_path(doc_ids: list[int], storage_path: StoragePath) -> Literal["OK"]:
def set_storage_path(
doc_ids: list[int],
storage_path: StoragePath,
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]:
if storage_path:
storage_path = StoragePath.objects.only("pk").get(id=storage_path)
@@ -168,15 +186,17 @@ def set_storage_path(doc_ids: list[int], storage_path: StoragePath) -> Literal["
affected_docs = list(qs.values_list("pk", flat=True))
qs.update(storage_path=storage_path)
bulk_update_documents.apply_async(
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
_finish_metadata_edit(affected_docs, update_callback)
return "OK"
def set_document_type(doc_ids: list[int], document_type: DocumentType) -> Literal["OK"]:
def set_document_type(
doc_ids: list[int],
document_type: DocumentType,
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]:
if document_type:
document_type = DocumentType.objects.only("pk").get(id=document_type)
@@ -188,15 +208,17 @@ def set_document_type(doc_ids: list[int], document_type: DocumentType) -> Litera
affected_docs = list(qs.values_list("pk", flat=True))
qs.update(document_type=document_type)
bulk_update_documents.apply_async(
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
_finish_metadata_edit(affected_docs, update_callback)
return "OK"
def add_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
def add_tag(
doc_ids: list[int],
tag: int,
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]:
tag_obj = Tag.objects.get(pk=tag)
tags_to_add = [tag_obj, *tag_obj.get_ancestors()]
@@ -217,15 +239,17 @@ def add_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
DocumentTagRelationship.objects.bulk_create(to_create)
if affected_docs:
bulk_update_documents.apply_async(
kwargs={"document_ids": list(affected_docs)},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
_finish_metadata_edit(list(affected_docs), update_callback)
return "OK"
def remove_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
def remove_tag(
doc_ids: list[int],
tag: int,
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]:
tag_obj = Tag.objects.get(pk=tag)
tag_ids = [tag_obj.id, *tag_obj.get_descendants_pks()]
@@ -238,10 +262,7 @@ def remove_tag(doc_ids: list[int], tag: int) -> Literal["OK"]:
qs.delete()
if affected_docs:
bulk_update_documents.apply_async(
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
_finish_metadata_edit(affected_docs, update_callback)
return "OK"
@@ -250,6 +271,8 @@ def modify_tags(
doc_ids: list[int],
add_tags: list[int],
remove_tags: list[int],
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]:
qs = Document.objects.filter(id__in=doc_ids).only("pk")
affected_docs = list(qs.values_list("pk", flat=True))
@@ -293,10 +316,7 @@ def modify_tags(
)
if affected_docs:
bulk_update_documents.apply_async(
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
_finish_metadata_edit(affected_docs, update_callback)
return "OK"
@@ -305,6 +325,8 @@ def modify_custom_fields(
doc_ids: list[int],
add_custom_fields: list[int] | dict,
remove_custom_fields: list[int],
*,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]:
qs = Document.objects.filter(id__in=doc_ids).only("pk")
affected_docs = list(qs.values_list("pk", flat=True))
@@ -367,10 +389,7 @@ def modify_custom_fields(
field_id__in=remove_custom_fields,
).hard_delete()
bulk_update_documents.apply_async(
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
_finish_metadata_edit(affected_docs, update_callback)
return "OK"
@@ -451,6 +470,7 @@ def set_permissions(
*,
owner: User | None = None,
merge: bool = False,
update_callback: Callable[[list[int]], None] | None = None,
) -> Literal["OK"]:
qs = Document.objects.filter(id__in=doc_ids).select_related("owner")
@@ -468,10 +488,7 @@ def set_permissions(
merge=merge,
)
bulk_update_documents.apply_async(
kwargs={"document_ids": affected_docs},
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
)
_finish_metadata_edit(affected_docs, update_callback)
return "OK"
+107
View File
@@ -0,0 +1,107 @@
"""Queued document metadata edits."""
from typing import Any
from auditlog.context import set_actor
from celery import shared_task
from django.conf import settings
from django.contrib.auth.models import User
from django.db import transaction
from rest_framework.exceptions import PermissionDenied
from documents import bulk_edit
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.permissions import has_document_operation_permissions
from documents.tasks import bulk_update_documents
MODIFIED_FIELDS = {
"set_correspondent": "correspondent",
"set_document_type": "document_type",
"set_storage_path": "storage_path",
"add_tag": "tags",
"remove_tag": "tags",
"modify_tags": "tags",
"modify_custom_fields": "custom_fields",
"set_permissions": None,
}
QUEUED_OPERATIONS = frozenset(MODIFIED_FIELDS)
def snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
"""
Returns each document's current value of field, for the audit log.
Tags and custom fields are one row per value, so they are gathered
into a sorted list of pks per document (empty when there are none).
Reading them through Document.values() instead would join those rows
and return one arbitrary value per document.
"""
if field == "tags":
rows = (
Document.tags.through.objects.filter(document_id__in=doc_ids)
.order_by("tag_id")
.values_list("document_id", "tag_id")
)
elif field == "custom_fields":
rows = (
CustomFieldInstance.objects.filter(document_id__in=doc_ids)
.order_by("pk")
.values_list("document_id", "pk")
)
else:
return dict(
Document.objects.filter(pk__in=doc_ids).values_list("pk", field),
)
values: dict[int, list[int]] = {doc_id: [] for doc_id in doc_ids}
for doc_id, pk in rows:
values[doc_id].append(pk)
return values
@shared_task
def execute_bulk_operation(
operation: str,
document_ids: list[int],
parameters: dict,
user_id: int,
) -> dict:
if operation not in QUEUED_OPERATIONS:
raise ValueError(f"Unsupported bulk operation: {operation}")
method = getattr(bulk_edit, operation)
user = User.objects.get(pk=user_id)
if not has_document_operation_permissions(
user=user,
documents=document_ids,
method=method,
parameters=parameters,
):
raise PermissionDenied("Insufficient permissions to execute bulk operation")
affected_ids: list[int] = []
modified_field = MODIFIED_FIELDS[operation]
# Keep metadata writes and their audit entries together. Follow-up processing
# runs after commit, within this same tracked task.
with set_actor(user), transaction.atomic():
old_values = (
snapshot_field(document_ids, modified_field)
if settings.AUDIT_LOG_ENABLED and modified_field
else {}
)
method(document_ids, **parameters, update_callback=affected_ids.extend)
if settings.AUDIT_LOG_ENABLED and modified_field:
from auditlog.models import LogEntry
new_values = snapshot_field(document_ids, modified_field)
for doc in Document.objects.filter(pk__in=document_ids):
LogEntry.objects.log_create(
instance=doc,
changes={modified_field: [old_values[doc.pk], new_values[doc.pk]]},
action=LogEntry.Action.UPDATE,
actor=user,
additional_data={"reason": f"Bulk edit: {operation}"},
)
if affected_ids:
bulk_update_documents(list(dict.fromkeys(affected_ids)))
return {"operation": operation, "document_count": len(document_ids)}
+12 -25
View File
@@ -15,6 +15,7 @@ from django.db import transaction
from django.db.models import Max
from django.db.models import Q
from django.utils import timezone
from filelock import FileLock
from rest_framework.reverse import reverse
from documents.barcodes import read_barcode_values
@@ -26,10 +27,7 @@ from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_filename
from documents.file_handling import generate_unique_filename
from documents.file_handling import validate_path_in_root
from documents.file_moves import defer_file_moves
from documents.file_moves import flush_pending_file_moves
from documents.loggers import LoggingMixin
from documents.media_lock import media_lock
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
@@ -607,13 +605,7 @@ class ConsumerPlugin(
# now that everything is done, we can start to store the document
# in the system. This will be a transaction and reasonably fast.
try:
# File moves requested in here run after commit and after
# media.lock is released. media.lock is taken before the
# transaction: the version path locks the root row with
# select_for_update, and workflow email actions and file
# placement below re-enter media.lock instead of waiting
# on it while rows are locked.
with defer_file_moves(), media_lock(), transaction.atomic():
with transaction.atomic():
# store the document.
if self.input_doc.root_document_id:
# If this is a new version of an existing document, we need
@@ -639,9 +631,7 @@ class ConsumerPlugin(
pk=self.metadata.actor_id,
).first()
if actor is not None:
from auditlog.context import ( # type: ignore[import-untyped]
set_actor,
)
from auditlog.context import set_actor # type: ignore[import-untyped]
with set_actor(actor):
original_document.save()
@@ -659,9 +649,7 @@ class ConsumerPlugin(
# Create a log entry for the version addition, if enabled
if settings.AUDIT_LOG_ENABLED:
from auditlog.models import ( # type: ignore[import-untyped]
LogEntry,
)
from auditlog.models import LogEntry # type: ignore[import-untyped]
LogEntry.objects.log_create(
instance=root_doc,
@@ -703,7 +691,7 @@ class ConsumerPlugin(
# After everything is in the database, copy the files into
# place. If this fails, we'll also rollback the transaction.
with media_lock():
with FileLock(settings.MEDIA_LOCK):
generated_filename = generate_unique_filename(document)
if (
len(str(generated_filename))
@@ -767,9 +755,10 @@ class ConsumerPlugin(
document.archive_path,
)
# Any filename follow-up from this save is deferred by
# the scope above until after commit.
document.save(write_file_paths=True)
# Don't save with the lock active. Saving will cause the file
# renaming logic to acquire the lock as well.
# This triggers things like file renaming
document.save()
if document.root_document_id:
document_updated.send(
@@ -810,11 +799,6 @@ class ConsumerPlugin(
exception=e,
)
# The storage scope above nests under the consume task's scope, so its
# moves are still pending; place the file before the script reads its path.
flush_pending_file_moves()
document.refresh_from_db()
self.run_post_consume_script(document)
self.log.info(f"Document {document} consumption finished")
@@ -827,6 +811,9 @@ class ConsumerPlugin(
document.id,
)
# Return the most up to date fields
document.refresh_from_db()
return ConsumeFileSuccessResult(document_id=document.pk)
def _parse_title_placeholders(self, title: str) -> str:
-464
View File
@@ -1,464 +0,0 @@
"""
Document file moves, coalesced to once per unit of work.
Every save that can change a document's generated filename asks for a file
move through request_file_move(). Inside a scope opened by defer_file_moves()
(a request, a Celery task, the consumer, or a per-item command step) the
request is only recorded, and the outermost scope moves each recorded document
once, after its own writes have committed. Outside any scope the move happens
immediately.
The mover always takes media.lock first and then reads the document fresh, so
it starts from the latest committed state and never waits on media.lock while
its own transaction holds document rows.
"""
from __future__ import annotations
import logging
import shutil
import threading
from contextlib import asynccontextmanager
from contextlib import contextmanager
from contextvars import ContextVar
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Final
from asgiref.sync import sync_to_async
from django.conf import settings
from django.db import DatabaseError
from django.db import transaction
from django.utils import timezone
from filelock import Timeout
from documents.caching import clear_document_caches
from documents.file_handling import create_source_path_directory
from documents.file_handling import delete_empty_directories
from documents.file_handling import generate_filename
from documents.file_handling import generate_unique_filename
from documents.media_lock import media_lock
from documents.models import Document
from documents.utils import compute_checksum
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from collections.abc import Iterator
logger = logging.getLogger("paperless.handlers")
MEDIA_LOCK_TIMEOUT_SECONDS: Final[int] = 300
class CannotMoveFilesException(Exception):
pass
def in_user_transaction() -> bool:
"""
True when the default connection is inside an atomic block that was not
created by Django's test case machinery.
Django marks the atomic blocks TestCase (and pytest-django) wrap around
each test with _from_testcase. Ignoring them keeps tests on the same path
as production.
"""
connection = transaction.get_connection()
return any(not block._from_testcase for block in connection.atomic_blocks)
class FileMoveCollector:
"""Pending document pks for one file move scope."""
def __init__(self) -> None:
self._pending: set[int] = set()
self._lock = threading.Lock()
self.closed = False
def add(self, pk: int) -> bool:
"""Record a pending move. Returns False if the collector is closed."""
with self._lock:
if self.closed:
return False
self._pending.add(pk)
return True
def take_pending(self) -> list[int]:
"""Remove and return the pending pks, sorted, leaving the collector open."""
with self._lock:
pending = sorted(self._pending)
self._pending.clear()
return pending
def close(self) -> list[int]:
"""Close the collector and return what was still pending, sorted."""
with self._lock:
self.closed = True
pending = sorted(self._pending)
self._pending.clear()
return pending
# Only the scope context managers call set() / reset(). Everything else reads
# it and mutates the collector, so adds made in a copied context (thread or
# task hops) are visible to the scope that flushes.
_active_collector: ContextVar[FileMoveCollector | None] = ContextVar(
"paperless_file_move_collector",
default=None,
)
def _path_matches_checksum(path: Path, checksum: str | None) -> bool:
if checksum is None or not path.is_file():
return False
return compute_checksum(path) == checksum
def move_files_for_document(pk: int) -> Document | None:
"""
Move the document's original and archive to the paths its current
committed state generates, then do the same for its versions.
Returns the fresh instance with its final filename columns, or None when
the document does not exist, has no filename yet, or the lock timed out.
"""
# Cheap check without the lock: most saves during consumption happen
# before the file is placed. filename is never cleared once set, so this
# stays true under the lock. values_list() keeps the check off
# Document.global_objects.filter(), which tests patch to inject database
# errors into and observe the mover's own writes.
filename = (
Document.global_objects.values_list("filename", flat=True).filter(pk=pk).first()
)
if not filename:
return None
try:
with media_lock(timeout=MEDIA_LOCK_TIMEOUT_SECONDS):
try:
document = Document.global_objects.get(pk=pk)
except Document.DoesNotExist:
logger.debug(f"Document {pk} no longer exists, skipping file move")
return None
_move_document_files(document)
except Timeout:
logger.error(
f"Timed out after {MEDIA_LOCK_TIMEOUT_SECONDS}s waiting for "
f"{settings.MEDIA_LOCK} to move files of document {pk}; the file "
f"keeps its current name and will be renamed on its next save",
)
return None
# Keep version files in sync with root
if document.root_document_id is None:
version_pks = list(
Document.global_objects.values_list("pk", flat=True).filter(
root_document_id=pk,
),
)
for version_pk in version_pks:
move_files_for_document(version_pk)
return document
def _flush(pks: list[int]) -> None:
"""Move each document; one failure never stops the rest or raises."""
for pk in pks:
try:
move_files_for_document(pk)
except Exception:
logger.exception(f"Unexpected error moving files of document {pk}")
def _flush_after_commit(pks: list[int]) -> None:
if not pks:
return
if in_user_transaction():
transaction.on_commit(lambda: _flush(pks))
else:
_flush(pks)
def _close_and_flush(collector: FileMoveCollector) -> None:
_flush_after_commit(collector.close())
@contextmanager
def defer_file_moves() -> Iterator[None]:
"""
Collect file move requests and perform each once when the outermost scope
exits, after its own writes have committed.
"""
current = _active_collector.get()
if current is not None and not current.closed:
yield
return
collector = FileMoveCollector()
token = _active_collector.set(collector)
try:
yield
finally:
_active_collector.reset(token)
_close_and_flush(collector)
def flush_pending_file_moves() -> None:
"""
Perform the current scope's pending moves now, keeping the scope open.
From an eager task inside a request this flushes the request's collector
early, which is harmless: moves still run after commit, on fresh state.
"""
collector = _active_collector.get()
if collector is None:
return
_flush_after_commit(collector.take_pending())
def _validate_move(
document: Document,
old_path: Path,
new_path: Path,
root: Path,
) -> None:
if not new_path.is_relative_to(root):
msg = (
f"Document {document!s}: Refusing to move file outside root {root}: "
f"{new_path}."
)
logger.warning(msg)
raise CannotMoveFilesException(msg)
if not old_path.is_file():
# Can't do anything if the old file does not exist anymore.
msg = f"Document {document!s}: File {old_path} doesn't exist."
logger.fatal(msg)
raise CannotMoveFilesException(msg)
if new_path.is_file():
# Can't do anything if the new file already exists. Skip updating file.
msg = f"Document {document!s}: Cannot rename file since target path {new_path} already exists."
logger.warning(msg)
raise CannotMoveFilesException(msg)
def _move_document_files(document: Document) -> None:
try:
old_filename = document.filename
old_source_path = document.source_path
move_original = False
original_already_moved = False
old_archive_filename = document.archive_filename
old_archive_path = document.archive_path
move_archive = False
archive_already_moved = False
candidate_filename = generate_filename(document)
if len(str(candidate_filename)) > Document.MAX_STORED_FILENAME_LENGTH:
msg = (
f"Document {document!s}: Generated filename exceeds db path "
f"limit ({len(str(candidate_filename))} > "
f"{Document.MAX_STORED_FILENAME_LENGTH}): {candidate_filename!s}"
)
logger.warning(msg)
raise CannotMoveFilesException(msg)
candidate_source_path = (settings.ORIGINALS_DIR / candidate_filename).resolve()
if candidate_filename == Path(old_filename):
new_filename = Path(old_filename)
elif (
candidate_source_path.exists() and candidate_source_path != old_source_path
):
if not old_source_path.is_file() and _path_matches_checksum(
candidate_source_path,
document.checksum,
):
new_filename = candidate_filename
original_already_moved = True
else:
# Only fall back to unique search when there is an actual conflict
new_filename = generate_unique_filename(document)
else:
new_filename = candidate_filename
# Need to convert to string to be able to save it to the db
document.filename = str(new_filename)
move_original = old_filename != document.filename and not original_already_moved
if document.has_archive_version:
archive_candidate = generate_filename(document, archive_filename=True)
if len(str(archive_candidate)) > Document.MAX_STORED_FILENAME_LENGTH:
msg = (
f"Document {document!s}: Generated archive filename exceeds "
f"db path limit ({len(str(archive_candidate))} > "
f"{Document.MAX_STORED_FILENAME_LENGTH}): {archive_candidate!s}"
)
logger.warning(msg)
raise CannotMoveFilesException(msg)
archive_candidate_path = (
settings.ARCHIVE_DIR / archive_candidate
).resolve()
if archive_candidate == Path(old_archive_filename):
new_archive_filename = Path(old_archive_filename)
elif (
archive_candidate_path.exists()
and archive_candidate_path != old_archive_path
):
if not old_archive_path.is_file() and _path_matches_checksum(
archive_candidate_path,
document.archive_checksum,
):
new_archive_filename = archive_candidate
archive_already_moved = True
else:
new_archive_filename = generate_unique_filename(
document,
archive_filename=True,
)
else:
new_archive_filename = archive_candidate
document.archive_filename = str(new_archive_filename)
move_archive = (
old_archive_filename != document.archive_filename
and not archive_already_moved
)
else:
move_archive = False
if not move_original and not move_archive:
updates: dict[str, object] = {}
if old_filename != document.filename:
updates["filename"] = document.filename
if old_archive_filename != document.archive_filename:
updates["archive_filename"] = document.archive_filename
if updates:
# global_objects so a trashed document's path is fixed too.
updates["modified"] = timezone.now()
# Don't save() here to prevent infinite recursion.
Document.global_objects.filter(pk=document.pk).update(**updates)
return
if move_original:
_validate_move(
document,
old_source_path,
document.source_path,
settings.ORIGINALS_DIR,
)
create_source_path_directory(document.source_path)
shutil.move(old_source_path, document.source_path)
if move_archive:
_validate_move(
document,
old_archive_path,
document.archive_path,
settings.ARCHIVE_DIR,
)
create_source_path_directory(document.archive_path)
shutil.move(old_archive_path, document.archive_path)
# Don't save() here to prevent infinite recursion.
Document.global_objects.filter(pk=document.pk).update(
filename=document.filename,
archive_filename=document.archive_filename,
modified=timezone.now(),
)
# Clear any caching for this document. Slightly overkill, but not terrible
clear_document_caches(document.pk)
except (OSError, DatabaseError, CannotMoveFilesException) as e:
logger.warning(f"Exception during file handling: {e}")
# This happens when either:
# - moving the files failed due to file system errors
# - saving to the database failed due to database errors
# In both cases, we need to revert to the original state.
# Try to move files to their original location.
try:
if move_original and document.source_path.is_file():
logger.info("Restoring previous original path")
shutil.move(document.source_path, old_source_path)
if move_archive and document.archive_path.is_file():
logger.info("Restoring previous archive path")
shutil.move(document.archive_path, old_archive_path)
except Exception:
# This is fine, since:
# A: if we managed to move source from A to B, we will also
# manage to move it from B to A. If not, we have a serious
# issue that's going to get caught by the santiy checker.
# All files remain in place and will never be overwritten,
# so this is not the end of the world.
# B: if moving the original file failed, nothing has changed
# anyway.
pass
# restore old values on the document
document.filename = old_filename
document.archive_filename = old_archive_filename
# finally, remove any empty sub folders. This will do nothing if
# something has failed above.
if not old_source_path.is_file():
delete_empty_directories(
Path(old_source_path).parent,
root=settings.ORIGINALS_DIR,
)
if document.has_archive_version and not old_archive_path.is_file():
delete_empty_directories(
Path(old_archive_path).parent,
root=settings.ARCHIVE_DIR,
)
def request_file_move(document: Document) -> None:
"""
Ask for the document's files to follow its committed state.
Inside an open scope the request is recorded and performed when the scope
ends. Otherwise the move happens now and only the final filename columns
are copied onto the caller's instance, so source_path is current after
save() while other in-memory fields are left alone.
"""
collector = _active_collector.get()
if collector is not None and collector.add(document.pk):
return
moved = move_files_for_document(document.pk)
if moved is not None:
document.filename = moved.filename
document.archive_filename = moved.archive_filename
@asynccontextmanager
async def adefer_file_moves() -> AsyncIterator[None]:
"""
Async twin of defer_file_moves().
When something was collected, the flush runs through sync_to_async: its
transaction check needs the thread owning the DB connection, and the
blocking media.lock must never run on the event loop.
"""
current = _active_collector.get()
if current is not None and not current.closed:
yield
return
collector = FileMoveCollector()
token = _active_collector.set(collector)
try:
yield
finally:
_active_collector.reset(token)
pks = collector.close()
if pks:
await sync_to_async(_flush_after_commit, thread_sensitive=True)(pks)
+3 -3
View File
@@ -4,9 +4,9 @@ from dataclasses import dataclass
from email import message_from_bytes
from pathlib import Path
from django.conf import settings
from django.core.mail import EmailMessage
from documents.media_lock import media_lock
from filelock import FileLock
@dataclass(frozen=True)
@@ -48,7 +48,7 @@ def send_email(
used_filenames: set[str] = set()
# Something could be renaming the file concurrently so it can't be attached
with media_lock():
with FileLock(settings.MEDIA_LOCK):
for attachment in attachments:
filename = _get_unique_filename(
attachment.friendly_name,
@@ -18,11 +18,10 @@ from django.core import serializers
from django.core.management.base import CommandError
from django.db import transaction
from django.utils import timezone
from filelock import FileLock
from guardian.models import GroupObjectPermission
from guardian.models import UserObjectPermission
from documents.media_lock import media_lock
if TYPE_CHECKING:
from collections.abc import Generator
@@ -329,7 +328,7 @@ class Command(CryptMixin, PaperlessCommand):
)
# Prevent any ongoing changes in the documents while exporting
with media_lock(), sink:
with FileLock(settings.MEDIA_LOCK), sink:
self.dump(sink)
def dump(self, sink: ExportSink) -> None:
@@ -29,6 +29,7 @@ from django.db.models import GeneratedField
from django.db.models import Model
from django.db.models.signals import m2m_changed
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
@@ -36,7 +37,6 @@ 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
from documents.media_lock import media_lock
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
@@ -662,7 +662,7 @@ class Command(CryptMixin, PaperlessCommand):
else:
archive_path = None
with media_lock():
with FileLock(settings.MEDIA_LOCK):
if Path(document.source_path).is_file():
raise FileExistsError(document.source_path)
@@ -696,7 +696,7 @@ class Command(CryptMixin, PaperlessCommand):
f"Share link bundle {bundle.pk} does not have a valid file path.",
)
with media_lock():
with FileLock(settings.MEDIA_LOCK):
bundle_target_path.parent.mkdir(parents=True, exist_ok=True)
copy_file_with_basic_stats(
bundle_source_path,
@@ -1,4 +1,5 @@
from documents.file_moves import move_files_for_document
from django.db.models.signals import post_save
from documents.management.commands.base import PaperlessCommand
from documents.models import Document
@@ -10,9 +11,5 @@ class Command(PaperlessCommand):
supports_multiprocessing = False
def handle(self, *args, **options):
for document in self.track(
# move_files_for_document handles a root's versions itself
Document.objects.filter(root_document__isnull=True).only("pk"),
description="Renaming...",
):
move_files_for_document(document.pk)
for document in self.track(Document.objects.all(), description="Renaming..."):
post_save.send(Document, instance=document, created=False)
@@ -9,7 +9,6 @@ from rich.table import Table
from rich.text import Text
from documents.classifier import load_classifier
from documents.file_moves import defer_file_moves
from documents.management.commands.base import PaperlessCommand
from documents.models import Document
from documents.signals.handlers import set_correspondent
@@ -274,63 +273,61 @@ class Command(PaperlessCommand):
description="Retagging...",
stats_renderer=render_stats,
):
# One file move per document, after all of its assignments.
with defer_file_moves():
suggestion = DocumentSuggestion(document=document)
suggestion = DocumentSuggestion(document=document)
if do_correspondent:
correspondent = set_correspondent(
None,
document,
classifier=classifier,
replace=overwrite,
use_first=use_first,
dry_run=suggest,
)
if correspondent is not None:
stats.correspondents += 1
suggestion.correspondent = correspondent
if do_correspondent:
correspondent = set_correspondent(
None,
document,
classifier=classifier,
replace=overwrite,
use_first=use_first,
dry_run=suggest,
)
if correspondent is not None:
stats.correspondents += 1
suggestion.correspondent = correspondent
if do_document_type:
document_type = set_document_type(
None,
document,
classifier=classifier,
replace=overwrite,
use_first=use_first,
dry_run=suggest,
)
if document_type is not None:
stats.document_types += 1
suggestion.document_type = document_type
if do_document_type:
document_type = set_document_type(
None,
document,
classifier=classifier,
replace=overwrite,
use_first=use_first,
dry_run=suggest,
)
if document_type is not None:
stats.document_types += 1
suggestion.document_type = document_type
if do_tags:
tags_to_add, tags_to_remove = set_tags(
None,
document,
classifier=classifier,
replace=overwrite,
dry_run=suggest,
)
stats.tags_added += len(tags_to_add)
stats.tags_removed += len(tags_to_remove)
suggestion.tags_to_add = frozenset(tags_to_add)
suggestion.tags_to_remove = frozenset(tags_to_remove)
if do_tags:
tags_to_add, tags_to_remove = set_tags(
None,
document,
classifier=classifier,
replace=overwrite,
dry_run=suggest,
)
stats.tags_added += len(tags_to_add)
stats.tags_removed += len(tags_to_remove)
suggestion.tags_to_add = frozenset(tags_to_add)
suggestion.tags_to_remove = frozenset(tags_to_remove)
if do_storage_path:
storage_path = set_storage_path(
None,
document,
classifier=classifier,
replace=overwrite,
use_first=use_first,
dry_run=suggest,
)
if storage_path is not None:
stats.storage_paths += 1
suggestion.storage_path = storage_path
if do_storage_path:
storage_path = set_storage_path(
None,
document,
classifier=classifier,
replace=overwrite,
use_first=use_first,
dry_run=suggest,
)
if storage_path is not None:
stats.storage_paths += 1
suggestion.storage_path = storage_path
stats.documents_processed += 1
stats.documents_processed += 1
if suggest:
suggestions.append(suggestion)
-36
View File
@@ -1,36 +0,0 @@
"""
The global media lock, reentrant within a thread.
Code that both needs media.lock and opens a transaction that locks document
rows must take media.lock first, so no transaction ever waits on it while
holding rows. Reentrancy lets code already holding the lock (for example the
consumer) call into code that takes it again (for example a workflow email
action attaching files) without blocking itself.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import TYPE_CHECKING
from django.conf import settings
from filelock import FileLock
if TYPE_CHECKING:
from collections.abc import Iterator
@contextmanager
def media_lock(timeout: float = -1) -> Iterator[None]:
"""
Hold settings.MEDIA_LOCK for the block.
is_singleton returns one FileLock instance per path in this process, and
the default thread_local=True keeps the hold count per thread, so the
same thread re-enters while other threads and processes are excluded.
The timeout goes to acquire() because a singleton rejects construction
with different parameters. A negative timeout waits forever.
"""
lock = FileLock(settings.MEDIA_LOCK, is_singleton=True)
with lock.acquire(timeout=timeout):
yield
-32
View File
@@ -2,7 +2,6 @@ import datetime
import uuid
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from typing import Final
import pathvalidate
@@ -216,12 +215,6 @@ class StoragePath(MatchingModel):
class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-missing]
MAX_STORED_FILENAME_LENGTH: Final[int] = 1024
# Written only by the file mover, the consumer's file placement and the
# archiver. A full save() of a stale instance must never write them, or
# the DB can point at a path the file has already been moved away from.
FILE_PATH_FIELDS: Final[frozenset[str]] = frozenset(
{"filename", "archive_filename"},
)
# Per-component limit of common filesystems (ext4, XFS, btrfs, ZFS, APFS)
MAX_PUBLIC_FILENAME_BYTES: Final[int] = 255
@@ -433,31 +426,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
res += f" {self.title}"
return res
def save(self, *, write_file_paths: bool = False, **kwargs: Any) -> None:
"""
Save the document without writing filename / archive_filename on an
existing row, unless write_file_paths=True or update_fields names them.
This mirrors how Django narrows a save on a deferred instance: only the
loaded, concrete, non-generated, non-primary-key fields are written.
"""
if (
not write_file_paths
and not self._state.adding
and kwargs.get("update_fields") is None
and not kwargs.get("force_insert", False)
):
deferred = self.get_deferred_fields()
kwargs["update_fields"] = [
field.attname
for field in self._meta.concrete_fields
if not field.primary_key
and not field.generated
and field.name not in self.FILE_PATH_FIELDS
and field.attname not in deferred
]
super().save(**kwargs)
def get_effective_barcodes(self) -> list["DocumentBarcode"]:
"""
Returns the stored barcodes for the document, like
+97
View File
@@ -739,3 +739,100 @@ class AcknowledgeTasksPermissions(GlobalPermissionsByMethod):
"OPTIONS": ["documents.change_paperlesstask"],
"POST": ["documents.change_paperlesstask"],
}
def has_document_operation_permissions(
*,
user: User,
documents: list[int],
method,
parameters: dict[str, Any],
) -> bool:
from documents import bulk_edit
if not user.is_active:
return False
if user.is_superuser:
return True
root_docs = {
get_root_document(doc)
for doc in Document.objects.select_related(
"owner",
"root_document__owner",
).filter(pk__in=documents)
}
user_is_owner_of_all_documents = all(
(doc.owner == user or doc.owner is None) for doc in root_docs
)
# check global and object permissions for all documents
has_perms = (
user.has_perm(
"documents.change_document",
)
and not Document.global_objects.filter(pk__in=documents)
.exclude(pk__in=permitted_document_ids(user, perm="change_document"))
.exists()
)
# check ownership for methods that change original document
if (
(
has_perms
and method
in [
bulk_edit.set_permissions,
bulk_edit.delete,
bulk_edit.rotate,
bulk_edit.delete_pages,
bulk_edit.edit_pdf,
bulk_edit.merge_as_versions,
bulk_edit.remove_password,
]
)
or (
method in [bulk_edit.merge, bulk_edit.split]
and parameters.get("delete_originals")
)
or (method == bulk_edit.edit_pdf and parameters.get("update_document"))
):
has_perms = has_perms and user_is_owner_of_all_documents
# check global add permissions for methods that create documents
if (
has_perms
and (
method in [bulk_edit.split, bulk_edit.merge]
or (
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
and not parameters.get("update_document")
)
)
and not user.has_perm("documents.add_document")
):
has_perms = False
# check global delete permissions for methods that delete documents
if (
has_perms
and (
method == bulk_edit.delete
# Sources stop being documents of their own, and removing one
# again afterwards needs delete_document
or method == bulk_edit.merge_as_versions
or (
method in [bulk_edit.merge, bulk_edit.split]
and parameters.get("delete_originals")
)
or (
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
and parameters.get("delete_original")
and not parameters.get("update_document")
)
)
and not user.has_perm("documents.delete_document")
):
has_perms = False
return has_perms
+292 -25
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from asgiref.sync import async_to_sync
from celery import shared_task
from celery.signals import before_task_publish
from celery.signals import task_failure
@@ -16,25 +17,29 @@ from celery.signals import task_prerun
from celery.signals import task_revoked
from celery.signals import worker_process_init
from celery.signals import worker_process_shutdown
from channels.layers import get_channel_layer
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.db import DatabaseError
from django.db import close_old_connections
from django.db import connections
from django.db import models
from django.db.models import Q
from django.dispatch import receiver
from django.utils import timezone
from filelock import FileLock
from rest_framework import serializers
from documents import matching
from documents.caching import clear_document_caches
from documents.caching import invalidate_llm_suggestions_cache
from documents.caching import invalidate_suggestions_cache
from documents.data_models import ConsumableDocument
from documents.file_handling import create_source_path_directory
from documents.file_handling import delete_empty_directories
from documents.file_moves import move_files_for_document
from documents.file_moves import request_file_move
from documents.media_lock import media_lock
from documents.file_handling import generate_filename
from documents.file_handling import generate_unique_filename
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
@@ -52,6 +57,7 @@ from documents.models import WorkflowTrigger
from documents.permissions import restrict_queryset_to_visible
from documents.plugins.helpers import DocumentsStatusManager
from documents.templating.utils import convert_format_str_to_template_format
from documents.utils import compute_checksum
from documents.utils import copy_file_with_basic_stats
from documents.workflows.actions import build_workflow_action_context
from documents.workflows.actions import execute_email_action
@@ -335,7 +341,7 @@ def set_storage_path(
# see empty_trash in documents/tasks.py for signal handling
def cleanup_document_deletion(sender, instance, **kwargs) -> None:
with media_lock():
with FileLock(settings.MEDIA_LOCK):
if settings.EMPTY_TRASH_DIR:
# Find a non-conflicting filename in case a document with the same
# name was moved to trash earlier
@@ -401,6 +407,17 @@ def cleanup_document_deletion(sender, instance, **kwargs) -> None:
)
class CannotMoveFilesException(Exception):
pass
def _path_matches_checksum(path: Path, checksum: str | None) -> bool:
if checksum is None or not path.is_file():
return False
return compute_checksum(path) == checksum
def _filename_template_uses_custom_fields(doc: Document) -> bool:
template = None
if doc.storage_path is not None:
@@ -423,24 +440,235 @@ def update_filename_and_move_files(
instance: Document | CustomFieldInstance,
**kwargs,
) -> None:
action = kwargs.get("action")
if action is not None and action not in {"post_add", "post_remove", "post_clear"}:
# m2m_changed fires pre_* and post_* actions; only post_* describe the
# relation state the filename should follow.
return
if kwargs.get("reverse"):
# tag.documents.add(doc): instance is the Tag, pk_set holds Document pks.
for document in Document.objects.filter(pk__in=kwargs.get("pk_set") or ()):
request_file_move(document)
return
if isinstance(instance, CustomFieldInstance):
if not _filename_template_uses_custom_fields(instance.document):
return
instance = instance.document
request_file_move(instance)
def validate_move(instance, old_path: Path, new_path: Path, root: Path) -> None:
if not new_path.is_relative_to(root):
msg = (
f"Document {instance!s}: Refusing to move file outside root {root}: "
f"{new_path}."
)
logger.warning(msg)
raise CannotMoveFilesException(msg)
if not old_path.is_file():
# Can't do anything if the old file does not exist anymore.
msg = f"Document {instance!s}: File {old_path} doesn't exist."
logger.fatal(msg)
raise CannotMoveFilesException(msg)
if new_path.is_file():
# Can't do anything if the new file already exists. Skip updating file.
msg = f"Document {instance!s}: Cannot rename file since target path {new_path} already exists."
logger.warning(msg)
raise CannotMoveFilesException(msg)
if not instance.filename:
# Can't update the filename if there is no filename to begin with
# This happens when the consumer creates a new document.
# The document is modified and saved multiple times, and only after
# everything is done (i.e., the generated filename is final),
# filename will be set to the location where the consumer has put
# the file.
#
# This will in turn cause this logic to move the file where it belongs.
return
with FileLock(settings.MEDIA_LOCK):
# If this was waiting for the lock, the filename or archive_filename
# of this document may have been updated. This happens if multiple updates
# get queued from the UI for the same document
# So freshen up the data before doing anything
instance.refresh_from_db()
try:
old_filename = instance.filename
old_source_path = instance.source_path
move_original = False
original_already_moved = False
old_archive_filename = instance.archive_filename
old_archive_path = instance.archive_path
move_archive = False
archive_already_moved = False
candidate_filename = generate_filename(instance)
if len(str(candidate_filename)) > Document.MAX_STORED_FILENAME_LENGTH:
msg = (
f"Document {instance!s}: Generated filename exceeds db path "
f"limit ({len(str(candidate_filename))} > "
f"{Document.MAX_STORED_FILENAME_LENGTH}): {candidate_filename!s}"
)
logger.warning(msg)
raise CannotMoveFilesException(msg)
candidate_source_path = (
settings.ORIGINALS_DIR / candidate_filename
).resolve()
if candidate_filename == Path(old_filename):
new_filename = Path(old_filename)
elif (
candidate_source_path.exists()
and candidate_source_path != old_source_path
):
if not old_source_path.is_file() and _path_matches_checksum(
candidate_source_path,
instance.checksum,
):
new_filename = candidate_filename
original_already_moved = True
else:
# Only fall back to unique search when there is an actual conflict
new_filename = generate_unique_filename(instance)
else:
new_filename = candidate_filename
# Need to convert to string to be able to save it to the db
instance.filename = str(new_filename)
move_original = (
old_filename != instance.filename and not original_already_moved
)
if instance.has_archive_version:
archive_candidate = generate_filename(instance, archive_filename=True)
if len(str(archive_candidate)) > Document.MAX_STORED_FILENAME_LENGTH:
msg = (
f"Document {instance!s}: Generated archive filename exceeds "
f"db path limit ({len(str(archive_candidate))} > "
f"{Document.MAX_STORED_FILENAME_LENGTH}): {archive_candidate!s}"
)
logger.warning(msg)
raise CannotMoveFilesException(msg)
archive_candidate_path = (
settings.ARCHIVE_DIR / archive_candidate
).resolve()
if archive_candidate == Path(old_archive_filename):
new_archive_filename = Path(old_archive_filename)
elif (
archive_candidate_path.exists()
and archive_candidate_path != old_archive_path
):
if not old_archive_path.is_file() and _path_matches_checksum(
archive_candidate_path,
instance.archive_checksum,
):
new_archive_filename = archive_candidate
archive_already_moved = True
else:
new_archive_filename = generate_unique_filename(
instance,
archive_filename=True,
)
else:
new_archive_filename = archive_candidate
instance.archive_filename = str(new_archive_filename)
move_archive = (
old_archive_filename != instance.archive_filename
and not archive_already_moved
)
else:
move_archive = False
if not move_original and not move_archive:
updates = {"modified": timezone.now()}
if old_filename != instance.filename:
updates["filename"] = instance.filename
if old_archive_filename != instance.archive_filename:
updates["archive_filename"] = instance.archive_filename
# Don't save() here to prevent infinite recursion.
Document.objects.filter(pk=instance.pk).update(**updates)
return
if move_original:
validate_move(
instance,
old_source_path,
instance.source_path,
settings.ORIGINALS_DIR,
)
create_source_path_directory(instance.source_path)
shutil.move(old_source_path, instance.source_path)
if move_archive:
validate_move(
instance,
old_archive_path,
instance.archive_path,
settings.ARCHIVE_DIR,
)
create_source_path_directory(instance.archive_path)
shutil.move(old_archive_path, instance.archive_path)
# Don't save() here to prevent infinite recursion.
Document.global_objects.filter(pk=instance.pk).update(
filename=instance.filename,
archive_filename=instance.archive_filename,
modified=timezone.now(),
)
# Clear any caching for this document. Slightly overkill, but not terrible
clear_document_caches(instance.pk)
except (OSError, DatabaseError, CannotMoveFilesException) as e:
logger.warning(f"Exception during file handling: {e}")
# This happens when either:
# - moving the files failed due to file system errors
# - saving to the database failed due to database errors
# In both cases, we need to revert to the original state.
# Try to move files to their original location.
try:
if move_original and instance.source_path.is_file():
logger.info("Restoring previous original path")
shutil.move(instance.source_path, old_source_path)
if move_archive and instance.archive_path.is_file():
logger.info("Restoring previous archive path")
shutil.move(instance.archive_path, old_archive_path)
except Exception:
# This is fine, since:
# A: if we managed to move source from A to B, we will also
# manage to move it from B to A. If not, we have a serious
# issue that's going to get caught by the santiy checker.
# All files remain in place and will never be overwritten,
# so this is not the end of the world.
# B: if moving the original file failed, nothing has changed
# anyway.
pass
# restore old values on the instance
instance.filename = old_filename
instance.archive_filename = old_archive_filename
# finally, remove any empty sub folders. This will do nothing if
# something has failed above.
if not old_source_path.is_file():
delete_empty_directories(
Path(old_source_path).parent,
root=settings.ORIGINALS_DIR,
)
if instance.has_archive_version and not old_archive_path.is_file():
delete_empty_directories(
Path(old_archive_path).parent,
root=settings.ARCHIVE_DIR,
)
# Keep version files in sync with root
if instance.root_document_id is None:
for version_doc in Document.objects.filter(root_document_id=instance.pk).only(
"pk",
):
update_filename_and_move_files(
Document,
version_doc,
)
@shared_task
@@ -465,8 +693,7 @@ def process_cf_select_update(custom_field: CustomField) -> None:
for cf_instance in custom_field.fields.select_related("document").iterator():
# Update the filename and move files if necessary
if _filename_template_uses_custom_fields(cf_instance.document):
move_files_for_document(cf_instance.document_id)
update_filename_and_move_files(CustomFieldInstance, cf_instance)
# should be disabled in /src/documents/management/commands/document_importer.py handle
@@ -826,6 +1053,8 @@ def run_workflows(
# Task tracking -- Celery signal handlers
# ---------------------------------------------------------------------------
BULK_OPERATION_TASK_NAME = "documents.bulk_operations.execute_bulk_operation"
TRACKED_TASKS: dict[str, PaperlessTask.TaskType] = {
"documents.tasks.consume_file": PaperlessTask.TaskType.CONSUME_FILE,
"documents.tasks.train_classifier": PaperlessTask.TaskType.TRAIN_CLASSIFIER,
@@ -839,6 +1068,7 @@ TRACKED_TASKS: dict[str, PaperlessTask.TaskType] = {
"documents.tasks.build_share_link_bundle": PaperlessTask.TaskType.BUILD_SHARE_LINK,
"documents.bulk_edit.delete": PaperlessTask.TaskType.BULK_DELETE,
"documents.tasks.apply_ai_suggestions": PaperlessTask.TaskType.APPLY_AI_SUGGESTIONS,
BULK_OPERATION_TASK_NAME: PaperlessTask.TaskType.BULK_UPDATE,
}
_CELERY_STATE_TO_STATUS: dict[str, PaperlessTask.Status] = {
@@ -856,8 +1086,7 @@ def _extract_input_data(
For consume_file tasks this includes the filename, MIME type, and any
non-null overrides from the DocumentMetadataOverrides object. For
mail_fetch tasks it captures the account_ids list. All other task
types store no input data and return {}.
mail_fetch tasks it captures the account_ids list.
"""
if task_type == PaperlessTask.TaskType.CONSUME_FILE:
input_doc = task_kwargs.get("input_doc")
@@ -922,7 +1151,7 @@ def _extract_owner_id(
task_type: PaperlessTask.TaskType,
task_kwargs: dict,
) -> int | None:
"""Return the owner_id from consume_file overrides, or None for all other task types."""
"""Return the document owner for consumption tasks."""
if task_type != PaperlessTask.TaskType.CONSUME_FILE:
return None
overrides = task_kwargs.get("overrides")
@@ -961,9 +1190,16 @@ def before_task_publish_handler(
_, task_kwargs, _ = body
task_id = headers["id"]
input_data = _extract_input_data(task_type, task_kwargs)
if task_name == BULK_OPERATION_TASK_NAME:
input_data = {
"operation": task_kwargs["operation"],
"document_count": len(task_kwargs["document_ids"]),
}
owner_id = task_kwargs["user_id"]
else:
input_data = _extract_input_data(task_type, task_kwargs)
owner_id = _extract_owner_id(task_type, task_kwargs)
trigger_source = _determine_trigger_source(headers)
owner_id = _extract_owner_id(task_type, task_kwargs)
# A retried task is republished with the same task_id, so this fires
# again for it; get_or_create keeps the original PENDING record
@@ -1003,6 +1239,30 @@ def task_prerun_handler(sender=None, task_id=None, task=None, **kwargs) -> None:
logger.exception("Setting PaperlessTask started failed")
def _send_bulk_operation_status(task: PaperlessTask | None) -> None:
"""Send the recorded outcome to the initiator."""
try:
if task is None or task.owner_id is None:
return
result_data = task.result_data or {}
async_to_sync(get_channel_layer().group_send)(
f"bulk_operations_{task.owner_id}",
{
"type": "bulk_operation_status",
"data": {
"task_id": task.task_id,
"status": task.status,
"operation": task.input_data["operation"],
"error": result_data.get("error_message")
or result_data.get("reason", ""),
},
},
)
except Exception:
# Just a notification failure
logger.exception("Sending bulk operation status failed")
@task_postrun.connect
def task_postrun_handler(
sender=None,
@@ -1060,6 +1320,8 @@ def task_postrun_handler(
changed_fields.append("status")
task_instance.save(update_fields=changed_fields)
if task and task.name == BULK_OPERATION_TASK_NAME:
_send_bulk_operation_status(task_instance)
except Exception: # pragma: no cover
logger.exception("Updating PaperlessTask failed")
@@ -1125,6 +1387,8 @@ def task_failure_handler(
date_started - date_created
).total_seconds()
task_qs.update(**update_fields)
if sender and sender.name == BULK_OPERATION_TASK_NAME:
_send_bulk_operation_status(task_qs.first())
except Exception: # pragma: no cover
logger.exception("Updating PaperlessTask on failure failed")
@@ -1161,13 +1425,16 @@ def task_revoked_handler(
reason = "Task expired before execution."
elif terminated:
reason = "Task terminated during execution."
PaperlessTask.objects.filter(task_id=task_id).update(
task_qs = PaperlessTask.objects.filter(task_id=task_id)
task_qs.update(
status=PaperlessTask.Status.REVOKED,
date_done=timezone.now(),
result_data={
"reason": (reason + " Check worker logs for details."),
},
)
if sender and sender.name == BULK_OPERATION_TASK_NAME:
_send_bulk_operation_status(task_qs.first())
except Exception: # pragma: no cover
logger.exception("Updating PaperlessTask on revocation failed")
+23 -26
View File
@@ -12,10 +12,12 @@ from celery import Task
from celery import shared_task
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.sessions.models import Session
from django.db import models
from django.db import transaction
from django.db.models.signals import post_save
from django.utils import timezone
from filelock import FileLock
from documents import sanity_checker
from documents.barcodes import BarcodePlugin
@@ -41,7 +43,6 @@ from documents.double_sided import CollatePlugin
from documents.file_handling import create_source_path_directory
from documents.file_handling import generate_unique_filename
from documents.matching import prefilter_documents_by_workflowtrigger
from documents.media_lock import media_lock
from documents.models import Correspondent
from documents.models import CustomFieldInstance
from documents.models import Document
@@ -310,7 +311,6 @@ def sanity_check(*, raise_on_error: bool = True) -> str:
@shared_task
def bulk_update_documents(document_ids) -> None:
from documents.file_moves import flush_pending_file_moves
from documents.search import get_backend
document_ids = list(document_ids)
@@ -331,9 +331,6 @@ def bulk_update_documents(document_ids) -> None:
skip_ai_index=True, # bulk path calls update_llm_index once below
)
post_save.send(Document, instance=doc, created=False)
# Move this document's files now rather than at the end of the task,
# so a long bulk edit makes steady progress.
flush_pending_file_moves()
with get_backend().batch_update() as batch:
batch.add_or_update_ids(document_ids)
@@ -421,13 +418,7 @@ def update_document_content_maybe_archive_file(
thumbnail = parser.get_thumbnail(document.source_path, mime_type)
# media.lock first, then the transaction: never wait on media.lock
# while this transaction holds the document row. Also keeps the
# unique archive filename generation under the lock.
with media_lock(), transaction.atomic():
# Loaded before the (long) parse; read current metadata now
# that the lock is held, for the archive filename.
document.refresh_from_db()
with transaction.atomic():
oldDocument = Document.objects.get(pk=document.pk)
if parser.get_archive_path():
checksum = compute_checksum(parser.get_archive_path())
@@ -492,10 +483,11 @@ def update_document_content_maybe_archive_file(
modified=timezone.now(),
)
if parser.get_archive_path():
create_source_path_directory(document.archive_path)
shutil.move(parser.get_archive_path(), document.archive_path)
shutil.move(thumbnail, document.thumbnail_path)
with FileLock(settings.MEDIA_LOCK):
if parser.get_archive_path():
create_source_path_directory(document.archive_path)
shutil.move(parser.get_archive_path(), document.archive_path)
shutil.move(thumbnail, document.thumbnail_path)
document.refresh_from_db()
logger.info(
@@ -540,11 +532,7 @@ def empty_trash(doc_ids=None) -> None:
deleted_document_ids = list(documents.values_list("id", flat=True))
# Temporarily connect the cleanup handler
models.signals.post_delete.connect(cleanup_document_deletion, sender=Document)
# media.lock before Django's delete transaction: post_delete runs
# cleanup_document_deletion inside it while the rows are locked, and
# that handler re-enters the lock instead of waiting.
with media_lock():
documents.delete() # this is effectively a hard delete
documents.delete() # this is effectively a hard delete
logger.info(f"Deleted {len(deleted_document_ids)} documents from trash")
if settings.AUDIT_LOG_ENABLED:
@@ -574,8 +562,6 @@ def check_scheduled_workflows() -> None:
Once a document satisfies this condition, and recurring/non-recurring constraints are met, the workflow is run.
"""
from documents.file_moves import flush_pending_file_moves
scheduled_workflows = get_workflows_for_trigger(
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
)
@@ -703,9 +689,6 @@ def check_scheduled_workflows() -> None:
sender=None,
document=document,
)
# Move this document's files now rather than at the end
# of the task, so a long run makes steady progress.
flush_pending_file_moves()
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:
@@ -943,3 +926,17 @@ def cleanup_expired_share_link_bundles() -> None:
)
if count:
logger.info("Deleted %s expired share link bundle(s)", count)
@shared_task
def clear_expired_sessions() -> None:
"""
Remove expired database sessions in batches to limit the size of each delete.
"""
expired = Session.objects.filter(expire_date__lt=timezone.now())
while session_keys := list(
expired.order_by("expire_date").values_list("pk", flat=True)[:500],
):
expired.filter(pk__in=session_keys).delete()
logger.info("Cleared expired sessions")
@@ -287,6 +287,28 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
self.assertEqual(config.user_args, user_args)
self.assertEqual(config.barcode_tag_mapping, barcode_tag_mapping)
def test_api_update_config_rejects_reserved_user_args(self) -> None:
"""
GIVEN:
- API request to update app config with user_args which set an
argument the parser manages itself
WHEN:
- API is called
THEN:
- Request is rejected with a 400 naming the argument
- Config is not updated
"""
response = self.client.patch(
f"{self.ENDPOINT}1/",
json.dumps({"user_args": json.dumps({"sidecar": "/tmp/x.py"})}),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("sidecar", str(response.data["user_args"]))
config = ApplicationConfiguration.objects.first()
assert config is not None
self.assertIsNone(config.user_args)
def test_api_update_config_rejects_invalid_json_objects(self) -> None:
"""
GIVEN:
+85 -11
View File
@@ -7,6 +7,7 @@ from django.test import override_settings
from rest_framework import status
from rest_framework.test import APITestCase
from documents.bulk_operations import execute_bulk_operation
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
@@ -29,9 +30,18 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.user = user
self.client.force_authenticate(user=user)
patcher = mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
patcher = mock.patch("documents.bulk_operations.bulk_update_documents")
self.async_task = patcher.start()
self.addCleanup(patcher.stop)
# Exercise accepted requests and worker execution together in these
# operation/validation tests. Queue-only behavior is tested separately.
enqueue = mock.patch(
"documents.views.execute_bulk_operation.apply_async",
side_effect=lambda *, kwargs, headers: self.run_operation(kwargs),
)
enqueue.start()
self.addCleanup(enqueue.stop)
self.c1 = Correspondent.objects.create(name="c1")
self.c2 = Correspondent.objects.create(name="c2")
self.dt1 = DocumentType.objects.create(name="dt1")
@@ -61,11 +71,16 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.cf1 = CustomField.objects.create(name="cf1", data_type="string")
self.cf2 = CustomField.objects.create(name="cf2", data_type="string")
@staticmethod
def run_operation(kwargs):
execute_bulk_operation(**kwargs)
return mock.Mock(id="test-bulk-operation")
def setup_mock(self, m, method_name, return_value="OK") -> None:
m.return_value = return_value
m.__name__ = method_name
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.bulk_operations.bulk_update_documents")
def test_api_set_correspondent(self, bulk_update_task_mock) -> None:
self.assertNotEqual(self.doc1.correspondent, self.c1)
response = self.client.post(
@@ -84,11 +99,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(self.doc1.correspondent, self.c1)
bulk_update_task_mock.assert_called_once()
self.assertCountEqual(
bulk_update_task_mock.call_args.kwargs["kwargs"]["document_ids"],
bulk_update_task_mock.call_args.args[0],
[self.doc1.pk],
)
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.bulk_operations.bulk_update_documents")
def test_api_unset_correspondent(self, bulk_update_task_mock) -> None:
self.doc1.correspondent = self.c1
self.doc1.save()
@@ -110,7 +125,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.doc1.refresh_from_db()
self.assertIsNone(self.doc1.correspondent)
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.bulk_operations.bulk_update_documents")
def test_api_set_type(self, bulk_update_task_mock) -> None:
self.assertNotEqual(self.doc1.document_type, self.dt1)
response = self.client.post(
@@ -129,11 +144,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(self.doc1.document_type, self.dt1)
bulk_update_task_mock.assert_called_once()
self.assertCountEqual(
bulk_update_task_mock.call_args.kwargs["kwargs"]["document_ids"],
bulk_update_task_mock.call_args.args[0],
[self.doc1.pk],
)
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.bulk_operations.bulk_update_documents")
def test_api_unset_type(self, bulk_update_task_mock) -> None:
self.doc1.document_type = self.dt1
self.doc1.save()
@@ -154,11 +169,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertIsNone(self.doc1.document_type)
bulk_update_task_mock.assert_called_once()
self.assertCountEqual(
bulk_update_task_mock.call_args.kwargs["kwargs"]["document_ids"],
bulk_update_task_mock.call_args.args[0],
[self.doc1.pk],
)
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.bulk_operations.bulk_update_documents")
def test_api_add_tag(self, bulk_update_task_mock) -> None:
self.assertFalse(self.doc1.tags.filter(pk=self.t1.pk).exists())
@@ -180,11 +195,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
bulk_update_task_mock.assert_called_once()
self.assertCountEqual(
bulk_update_task_mock.call_args.kwargs["kwargs"]["document_ids"],
bulk_update_task_mock.call_args.args[0],
[self.doc1.pk],
)
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.bulk_operations.bulk_update_documents")
def test_api_remove_tag(self, bulk_update_task_mock) -> None:
self.doc1.tags.add(self.t1)
@@ -223,6 +238,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertListEqual(args[0], [self.doc1.id, self.doc3.id])
self.assertEqual(kwargs["add_tags"], [self.t1.id])
self.assertEqual(kwargs["remove_tags"], [self.t2.id])
@@ -277,6 +295,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertListEqual(args[0], [self.doc1.id, self.doc3.id])
self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id])
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
@@ -345,6 +366,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertListEqual(args[0], [self.doc1.id, self.doc3.id])
self.assertEqual(kwargs["add_custom_fields"], {self.cf1.id: "foo"})
self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
@@ -506,6 +530,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(len(kwargs), 0)
@@ -520,6 +547,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(len(kwargs), 0)
@@ -544,6 +574,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(len(kwargs), 0)
@@ -558,6 +591,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(kwargs, {"remote_ocr": False})
@@ -580,6 +616,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertEqual(args[0], [self.doc1.id])
self.assertEqual(kwargs, {"remote_ocr": True})
@@ -609,6 +648,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertListEqual(args[0], [self.doc1.id])
self.assertEqual(kwargs["storage_path"], self.sp1.id)
@@ -639,6 +681,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertListEqual(args[0], [self.doc1.id])
self.assertEqual(kwargs["storage_path"], None)
@@ -761,6 +806,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertEqual(args[0], [self.doc2.id])
self.assertEqual(kwargs["storage_path"], self.sp1.id)
@@ -784,6 +832,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertCountEqual(args[0], [self.doc1.id, self.doc3.id, self.doc5.id])
self.assertEqual(kwargs["storage_path"], self.sp1.id)
@@ -820,6 +871,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertCountEqual(args[0], [first_duplicate.id, second_duplicate.id])
self.assertEqual(kwargs["storage_path"], self.sp1.id)
@@ -856,6 +910,11 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
get_backend.return_value.search_ids.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value
for key, value in kwargs.items()
if key != "update_callback"
}
self.assertEqual(args[0], [self.doc2.id])
self.assertEqual(kwargs["storage_path"], self.sp1.id)
@@ -1232,6 +1291,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2)
@@ -1644,6 +1706,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
self.assertEqual(kwargs["degrees"], 90)
self.assertEqual(kwargs["source_mode"], "latest_version")
@@ -1930,6 +1995,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id])
self.assertEqual(kwargs["metadata_document_id"], self.doc3.id)
self.assertEqual(kwargs["source_mode"], "latest_version")
@@ -2137,6 +2205,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertCountEqual(args[0], [self.doc2.id])
self.assertEqual(kwargs["operations"], [{"page": 1}])
self.assertEqual(kwargs["source_mode"], "explicit_selection")
@@ -2482,6 +2553,9 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
m.assert_called_once()
args, kwargs = m.call_args
kwargs = {
key: value for key, value in kwargs.items() if key != "update_callback"
}
self.assertCountEqual(args[0], [self.doc2.id])
self.assertEqual(kwargs["password"], "secret")
self.assertTrue(kwargs["update_document"])
@@ -149,10 +149,10 @@ class TestPostSelectionPathsEnforceTheCap:
assert str(_MAX_QUERY_LENGTH + 1) in message
parse_spy.assert_not_called()
@mock.patch("documents.bulk_edit.bulk_update_documents.apply_async")
@mock.patch("documents.views.execute_bulk_operation.apply_async")
def test_bulk_edit_query_at_exactly_the_cap_is_accepted(
self,
bulk_update_task_mock: mock.MagicMock,
enqueue_mock: mock.MagicMock,
admin_client: APIClient,
searchable_document: Document,
) -> None:
@@ -163,12 +163,12 @@ class TestPostSelectionPathsEnforceTheCap:
- Its `filters.query` is exactly `_MAX_QUERY_LENGTH` characters
long
THEN:
- The cap check accepts it and the request reaches the real
bulk-edit method (its Celery dispatch is mocked out here,
same as every other bulk-edit test, since nothing here is
testing that method itself)
- The cap check accepts it and queues the metadata operation.
Mock the executor dispatch so this exercises query validation
without requiring a broker.
"""
query = "a" * _MAX_QUERY_LENGTH
enqueue_mock.return_value.id = "query-cap-task"
response = admin_client.post(
"/api/documents/bulk_edit/",
@@ -183,6 +183,11 @@ class TestPostSelectionPathsEnforceTheCap:
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"result": "OK", "task_id": "query-cap-task"}
enqueue_mock.assert_called_once()
assert (
enqueue_mock.call_args.kwargs["kwargs"]["operation"] == "set_document_type"
)
def test_bulk_download_query_one_over_the_cap_is_a_400(
self,
+3 -3
View File
@@ -739,7 +739,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
mime_type="application/pdf",
)
self.doc1.archive_filename = sample1_archive
self.doc1.save(write_file_paths=True)
self.doc1.save()
self.doc2 = Document.objects.create(
checksum="B",
title="B",
@@ -749,7 +749,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
created=date(2023, 1, 2),
)
self.doc2.archive_filename = sample2_archive
self.doc2.save(write_file_paths=True)
self.doc2.save()
self.doc3 = Document.objects.create(
checksum="C",
title="C",
@@ -774,7 +774,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
created=date(2023, 1, 3),
)
self.img_doc.archive_filename = img_doc_archive
self.img_doc.save(write_file_paths=True)
self.img_doc.save()
@staticmethod
def fake_decrypt(
+307
View File
@@ -0,0 +1,307 @@
"""Request acceptance, worker execution, and task tracking."""
import uuid
from unittest import mock
import pytest
from auditlog.models import LogEntry
from django.contrib.auth.models import User
from rest_framework.exceptions import PermissionDenied
from rest_framework.test import APIClient
from documents.bulk_operations import execute_bulk_operation
from documents.models import Correspondent
from documents.models import Document
from documents.models import PaperlessTask
from documents.signals.handlers import before_task_publish_handler
from documents.signals.handlers import task_failure_handler
from documents.signals.handlers import task_postrun_handler
from documents.signals.handlers import task_revoked_handler
from paperless_testing.factories import UserFactory
@pytest.fixture
def bulk_setup(paperless_dirs):
user = UserFactory(superuser=True)
client = APIClient()
client.force_authenticate(user)
doc = Document.objects.create(title="Example", checksum="bulk-op-example")
correspondent = Correspondent.objects.create(name="Example correspondent")
return user, client, doc, correspondent
@pytest.mark.django_db
@pytest.mark.parametrize(
"endpoint,operation,parameters",
[
("bulk_edit", "set_correspondent", {"correspondent": None}),
],
)
def test_request_only_queues(bulk_setup, endpoint, operation, parameters):
user, client, doc, _ = bulk_setup
task_id = str(uuid.uuid4())
data = {"documents": [doc.pk], "from_webui": True}
if endpoint == "bulk_edit":
data.update(method=operation, parameters=parameters)
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
enqueue.return_value.id = task_id
response = client.post(f"/api/documents/{endpoint}/", data, format="json")
assert response.status_code == 200
assert response.data == {"result": "OK", "task_id": task_id}
assert Document.objects.filter(pk=doc.pk).exists()
assert enqueue.call_args.kwargs["kwargs"] == {
"operation": operation,
"document_ids": [doc.pk],
"parameters": parameters,
"user_id": user.pk,
}
assert enqueue.call_args.kwargs["headers"]["trigger_source"] == "web_ui"
@pytest.mark.django_db
def test_metadata_task_includes_audit_and_followup(bulk_setup, settings):
user, _, doc, correspondent = bulk_setup
settings.AUDIT_LOG_ENABLED = True
with (
mock.patch("documents.bulk_operations.bulk_update_documents") as followup,
mock.patch(
"documents.bulk_edit.bulk_update_documents.apply_async",
) as extra_task,
):
def check_followup(ids):
doc.refresh_from_db()
assert doc.correspondent_id == correspondent.pk
assert LogEntry.objects.filter(object_pk=str(doc.pk), actor=user).exists()
assert ids == [doc.pk]
followup.side_effect = check_followup
result = execute_bulk_operation(
"set_correspondent",
[doc.pk],
{"correspondent": correspondent.pk},
user.pk,
)
followup.assert_called_once()
extra_task.assert_not_called()
assert result["document_count"] == 1
@pytest.mark.django_db
def test_permissions_rechecked_on_worker(bulk_setup):
user, _, doc, correspondent = bulk_setup
User.objects.filter(pk=user.pk).update(is_superuser=False)
with pytest.raises(PermissionDenied):
execute_bulk_operation(
"set_correspondent",
[doc.pk],
{"correspondent": correspondent.pk},
user.pk,
)
doc.refresh_from_db()
assert doc.correspondent_id is None
@pytest.mark.django_db
def test_queue_failure_does_not_change_documents(bulk_setup):
_, client, doc, correspondent = bulk_setup
with mock.patch(
"documents.views.execute_bulk_operation.apply_async",
side_effect=RuntimeError("broker unavailable"),
):
response = client.post(
"/api/documents/bulk_edit/",
{
"documents": [doc.pk],
"method": "set_correspondent",
"parameters": {"correspondent": correspondent.pk},
},
format="json",
)
assert response.status_code == 400
doc.refresh_from_db()
assert doc.correspondent_id is None
@pytest.mark.django_db
@pytest.mark.parametrize("failed", [False, True])
def test_tracked_outcome_records_initiator_and_result(bulk_setup, failed):
user, _, doc, _ = bulk_setup
task_id = str(uuid.uuid4())
before_task_publish_handler(
headers={
"task": "documents.bulk_operations.execute_bulk_operation",
"id": task_id,
},
body=(
(),
{
"operation": "set_permissions",
"document_ids": [doc.pk],
"user_id": user.pk,
},
{},
),
)
task = PaperlessTask.objects.get(task_id=task_id)
assert task.owner_id == user.pk
assert task.task_type == PaperlessTask.TaskType.BULK_UPDATE
if failed:
task_failure_handler(task_id=task_id, exception=ValueError("Could not update"))
else:
task_postrun_handler(
task_id=task_id,
state="SUCCESS",
retval={"document_count": 1},
)
task.refresh_from_db()
assert task.status == ("failure" if failed else "success")
if failed:
assert task.result_data["error_message"] == "Could not update"
else:
assert task.result_data == {"document_count": 1}
@pytest.mark.django_db
def test_large_filtered_selection_is_one_task(bulk_setup):
_, client, _, _ = bulk_setup
Document.objects.bulk_create(
[
Document(title="bulk-large-selection", checksum=f"large-{i}")
for i in range(10_000)
],
)
with mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue:
enqueue.return_value.id = str(uuid.uuid4())
response = client.post(
"/api/documents/bulk_edit/",
{
"all": True,
"filters": {"title__icontains": "bulk-large-selection"},
"method": "set_correspondent",
"parameters": {"correspondent": None},
},
format="json",
)
assert response.status_code == 200
enqueue.assert_called_once()
assert len(enqueue.call_args.kwargs["kwargs"]["document_ids"]) == 10_000
@pytest.mark.django_db
@pytest.mark.parametrize(
("outcome", "expected_status", "expected_error"),
[
("success", "success", ""),
("failure", "failure", "Could not update"),
(
"expired",
"revoked",
"Task expired before execution. Check worker logs for details.",
),
],
)
def test_tracked_outcome_notifies_only_initiator(
bulk_setup,
outcome,
expected_status,
expected_error,
):
user, _, doc, _ = bulk_setup
task_id = str(uuid.uuid4())
before_task_publish_handler(
headers={
"task": execute_bulk_operation.name,
"id": task_id,
},
body=(
(),
{
"operation": "set_permissions",
"document_ids": [doc.pk],
"user_id": user.pk,
},
{},
),
)
task = PaperlessTask.objects.get(task_id=task_id)
assert task.owner_id == user.pk
assert task.task_type == PaperlessTask.TaskType.BULK_UPDATE
with mock.patch("documents.signals.handlers.async_to_sync") as send:
if outcome == "expired":
task_revoked_handler(
sender=execute_bulk_operation,
request=mock.Mock(id=task_id),
expired=True,
)
elif outcome == "failure":
task_failure_handler(
sender=execute_bulk_operation,
task_id=task_id,
exception=ValueError("Could not update"),
)
else:
task_postrun_handler(
task=execute_bulk_operation,
task_id=task_id,
state="SUCCESS",
retval={"document_count": 1},
)
group, event = send.return_value.call_args.args
assert group == f"bulk_operations_{user.pk}"
assert event["data"]["status"] == expected_status
assert event["data"]["error"] == expected_error
assert event["data"]["task_id"] == task_id
@pytest.mark.anyio
@pytest.mark.parametrize("anyio_backend", ["asyncio"])
async def test_completion_socket_is_private(anyio_backend):
from types import SimpleNamespace
from channels.layers import get_channel_layer
from channels.testing import WebsocketCommunicator
from paperless.consumers import StatusConsumer
own = WebsocketCommunicator(StatusConsumer.as_asgi(), "/ws/status/")
other = WebsocketCommunicator(StatusConsumer.as_asgi(), "/ws/status/")
own.scope["user"] = SimpleNamespace(id=1, is_authenticated=True)
other.scope["user"] = SimpleNamespace(id=2, is_authenticated=True)
assert (await own.connect())[0]
assert (await other.connect())[0]
try:
event = {
"type": "bulk_operation_status",
"data": {"task_id": "example", "status": "success"},
}
await get_channel_layer().group_send("bulk_operations_1", event)
assert await own.receive_json_from() == event
assert await other.receive_nothing(timeout=0.02)
finally:
await own.disconnect()
await other.disconnect()
@pytest.mark.django_db
@pytest.mark.parametrize("endpoint", ["delete", "bulk_edit"])
def test_trash_remains_synchronous(bulk_setup, endpoint):
_, client, doc, _ = bulk_setup
data = {"documents": [doc.pk]}
if endpoint == "bulk_edit":
data.update(method="delete", parameters={})
with (
mock.patch("documents.views.execute_bulk_operation.apply_async") as enqueue,
mock.patch("documents.search.get_backend"),
):
response = client.post(
f"/api/documents/{endpoint}/",
data,
format="json",
HTTP_ACCEPT="application/json; version=9",
)
assert response.status_code == 200
assert response.data == {"result": "OK"}
assert not Document.objects.filter(pk=doc.pk).exists()
assert Document.deleted_objects.filter(pk=doc.pk).exists()
enqueue.assert_not_called()
+1 -160
View File
@@ -27,10 +27,6 @@ from documents.models import Document
from documents.models import DocumentType
from documents.models import StoragePath
from documents.models import Tag
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowActionEmail
from documents.models import WorkflowTrigger
from documents.parsers import ParseError
from documents.plugins.helpers import ProgressStatusOptions
from documents.tasks import sanity_check
@@ -42,7 +38,6 @@ from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import TagFactory
from paperless_testing.factories import UserFactory
from paperless_testing.fakes.progress import FakeProgressManager
from paperless_testing.locks import record_media_lock_acquisitions
from paperless_testing.permissions import grant_object
from paperless_testing.samples import MULTI_PAGE_DIGITAL_PDF
from paperless_testing.samples import MULTI_PAGE_IMAGES_PDF
@@ -226,160 +221,6 @@ class TestConsumer(
shutil.copy(src, dst)
return dst
@mock.patch("documents.consumer.load_classifier")
def test_consume_takes_media_lock_outside_transaction(self, m) -> None:
"""
GIVEN:
- A new document to consume
WHEN:
- It is consumed
THEN:
- media.lock is acquired
- media.lock is never first acquired inside a transaction
- The document's file is in place
"""
m.return_value = MagicMock()
with record_media_lock_acquisitions() as acquisitions:
with self.get_consumer(self.get_test_file()) as consumer:
consumer.run()
self.assertTrue(acquisitions)
self.assertFalse(any(a.in_user_transaction for a in acquisitions))
document = Document.objects.first()
assert document is not None
self.assertIsFile(document.source_path)
@mock.patch("documents.consumer.load_classifier")
def test_consume_places_file_before_post_consume_script(self, m) -> None:
"""
GIVEN:
- A new document to consume
WHEN:
- It is consumed
THEN:
- Pending file moves are flushed before the post-consume script runs
"""
m.return_value = MagicMock()
order = MagicMock()
with (
mock.patch(
"documents.consumer.flush_pending_file_moves",
order.flush,
),
mock.patch.object(
ConsumerPlugin,
"run_post_consume_script",
order.script,
),
):
with self.get_consumer(self.get_test_file()) as consumer:
consumer.run()
self.assertEqual(
[c[0] for c in order.mock_calls],
["flush", "script"],
)
@mock.patch("documents.consumer.load_classifier")
def test_consume_version_takes_media_lock_outside_transaction(self, m) -> None:
"""
GIVEN:
- An existing root document
WHEN:
- A new version of it is consumed (the root row is locked with
select_for_update inside the consumer's transaction)
THEN:
- media.lock is never first acquired inside a transaction
- The root's modified is bumped
"""
m.return_value = MagicMock()
with self.get_consumer(self.get_test_file()) as consumer:
consumer.run()
root_doc = Document.objects.first()
assert root_doc is not None
original_modified = timezone.now() - datetime.timedelta(days=1)
Document.objects.filter(pk=root_doc.pk).update(modified=original_modified)
version_file = self.get_test_file2()
status = FakeProgressManager(version_file.name, None)
overrides = DocumentMetadataOverrides(version_label="v2")
doc = ConsumableDocument(
DocumentSource.ApiUpload,
original_file=version_file,
root_document_id=root_doc.pk,
)
preflight = ConsumerPreflightPlugin(
doc,
overrides,
status, # type: ignore[arg-type]
self.dirs.scratch_dir,
"task-id",
)
preflight.setup()
preflight.run()
consumer = ConsumerPlugin(
doc,
overrides,
status, # type: ignore[arg-type]
self.dirs.scratch_dir,
"task-id",
)
consumer.setup()
try:
with record_media_lock_acquisitions() as acquisitions:
consumer.run()
finally:
consumer.cleanup()
self.assertTrue(acquisitions)
self.assertFalse(any(a.in_user_transaction for a in acquisitions))
root_doc.refresh_from_db()
self.assertGreater(root_doc.modified, original_modified)
@override_settings(
PAPERLESS_EMAIL_HOST="localhost",
EMAIL_ENABLED=True,
PAPERLESS_URL="http://localhost:8000",
)
@mock.patch("documents.consumer.load_classifier")
@mock.patch("django.core.mail.message.EmailMessage.send", return_value=1)
def test_consumption_workflow_email_runs_under_held_lock(self, send, m) -> None:
"""
GIVEN:
- A document added workflow with an email action
WHEN:
- A document is consumed (the consumer holds media.lock while
workflows run)
THEN:
- The email action re-enters media.lock and sends without blocking
"""
m.return_value = MagicMock()
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
)
email = WorkflowActionEmail.objects.create(
subject="added",
body="body",
to="someone@example.com",
include_document=True,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.EMAIL,
email=email,
)
workflow = Workflow.objects.create(name="email on add")
workflow.triggers.add(trigger)
workflow.actions.add(action)
with self.get_consumer(self.get_test_file()) as consumer:
consumer.run()
send.assert_called_once()
@override_settings(
FILENAME_FORMAT=None,
TIME_ZONE="America/Chicago",
@@ -909,7 +750,7 @@ class TestConsumer(
FILENAME_FORMAT="{correspondent}/{title}",
ARCHIVE_FILE_GENERATION="always",
)
@mock.patch("documents.file_moves.generate_unique_filename")
@mock.patch("documents.signals.handlers.generate_unique_filename")
def testFilenameHandlingUnstableFormat(self, m) -> None:
filenames = ["this", "that", "now this", "i cannot decide"]
+25 -23
View File
@@ -92,7 +92,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
# Ensure that filename is properly generated
self.assertEqual(document.filename, Path("none/none.pdf"))
document.save(write_file_paths=True)
document.save()
# test that creating dirs for the source_path creates the correct directory
create_source_path_directory(document.source_path)
@@ -190,7 +190,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
# Set a correspondent and save the document
document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
document.save(write_file_paths=True)
document.save()
# Check proper handling of files
self.assertIsFile(
@@ -232,7 +232,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
disable_auditlog(),
):
m.side_effect = DatabaseError()
document.save(write_file_paths=True)
document.save()
# Check proper handling of files
self.assertIsFile(document.source_path)
@@ -336,7 +336,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
# Ensure that filename is properly generated
document.filename = generate_filename(document)
document.save(write_file_paths=True)
document.save()
self.assertEqual(document.filename, "none/none.pdf")
create_source_path_directory(document.source_path)
@@ -362,7 +362,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
# Ensure that filename is properly generated
document.filename = generate_filename(document)
document.save(write_file_paths=True)
document.save()
self.assertEqual(document.filename, "none/none.pdf")
create_source_path_directory(document.source_path)
@@ -385,7 +385,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
document.save()
document.filename = generate_filename(document)
document.save(write_file_paths=True)
document.save()
create_source_path_directory(document.source_path)
Path(document.source_path).touch()
document.delete()
@@ -421,7 +421,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
# Set a correspondent and save the document
document.correspondent = Correspondent.objects.get_or_create(name="test")[0]
document.save(write_file_paths=True)
document.save()
# Check proper handling of files
self.assertIsDir(settings.ORIGINALS_DIR / "test")
@@ -476,7 +476,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
def test_filename_relative(self) -> None:
doc = Document.objects.create(title="doc1", mime_type="application/pdf")
doc.filename = generate_filename(doc)
doc.save(write_file_paths=True)
doc.save()
self.assertEqual(
doc.source_path,
@@ -532,7 +532,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
# Ensure that filename is properly generated
document.filename = generate_filename(document)
document.save(write_file_paths=True)
document.save()
self.assertEqual(document.filename, "none/none/none.pdf")
create_source_path_directory(document.source_path)
Path(document.source_path).touch()
@@ -620,13 +620,13 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
Path(document.source_path).touch()
Path(document2.source_path).touch()
document.filename = "0000001.pdf"
document.save(write_file_paths=True)
document.save()
self.assertIsFile(document.source_path)
self.assertEqual(document.filename, "qwe.pdf")
document2.filename = "0000002.pdf"
document2.save(write_file_paths=True)
document2.save()
self.assertIsFile(document.source_path)
self.assertEqual(document2.filename, "qwe_01.pdf")
@@ -656,17 +656,19 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertEqual(document2.filename, "qwe.pdf")
@override_settings(FILENAME_FORMAT="{title}")
@mock.patch("documents.file_moves.Document.global_objects.filter")
@mock.patch("documents.file_moves.shutil.move")
@mock.patch("documents.signals.handlers.Document.objects.filter")
@mock.patch("documents.signals.handlers.shutil.move")
def test_no_move_only_save(self, mock_move, mock_filter) -> None:
"""
GIVEN:
- A document with a filename
- The document is saved
- The filename is not changed
WHEN:
- The document is saved without a change that affects its path
- The document is saved
THEN:
- The document modified date is updated by the save itself
- The file is not moved and the mover writes nothing
- The document modified date is updated
- The document is not moved
"""
with disable_auditlog():
doc = Document.objects.create(
@@ -684,7 +686,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
doc.save()
doc.refresh_from_db()
mock_filter.assert_not_called()
mock_filter.assert_called()
self.assertNotEqual(original_modified, doc.modified)
mock_move.assert_not_called()
@@ -692,7 +694,7 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
FILENAME_FORMAT="{{title}}_{{custom_fields|get_cf_value('test')}}",
CELERY_TASK_ALWAYS_EAGER=True,
)
@mock.patch("documents.signals.handlers.move_files_for_document")
@mock.patch("documents.signals.handlers.update_filename_and_move_files")
def test_select_cf_updated(self, m) -> None:
"""
GIVEN:
@@ -2043,7 +2045,7 @@ class TestCustomFieldFilenameUpdates(
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
Path(self.doc.source_path).touch()
with mock.patch("documents.file_moves.generate_unique_filename") as m:
with mock.patch("documents.signals.handlers.generate_unique_filename") as m:
m.side_effect = generate_unique_filename
self.cfi.value_text = "updated"
self.cfi.save()
@@ -2065,7 +2067,7 @@ class TestCustomFieldFilenameUpdates(
Path(self.doc.source_path).parent.mkdir(parents=True, exist_ok=True)
Path(self.doc.source_path).touch()
with mock.patch("documents.file_moves.generate_unique_filename") as m:
with mock.patch("documents.signals.handlers.generate_unique_filename") as m:
m.side_effect = generate_unique_filename
self.cfi.value_text = "updated"
self.cfi.save()
@@ -2143,7 +2145,7 @@ def test_refresh_failure_surfaces_database_error(
GIVEN:
- A document with a stored filename
WHEN:
- Saving it triggers a filename update and loading it from the database fails
- Saving it triggers a filename update and refreshing it from the database fails
THEN:
- The database error is raised, not an unbound variable error from the rollback
"""
@@ -2151,8 +2153,8 @@ def test_refresh_failure_surfaces_database_error(
with (
mock.patch.object(
Document.global_objects,
"get",
Document,
"refresh_from_db",
side_effect=DatabaseError("database is locked"),
),
pytest.raises(DatabaseError, match="database is locked"),
-945
View File
@@ -1,945 +0,0 @@
from __future__ import annotations
import datetime
import hashlib
import logging
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from asgiref.sync import async_to_sync
from asgiref.sync import sync_to_async
from celery.contrib.django.task import DjangoTask
from django.db import transaction
from django.http import HttpRequest
from django.http import HttpResponse
from django.test import RequestFactory
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from filelock import Timeout
from rest_framework import status
from documents.file_moves import _active_collector
from documents.file_moves import defer_file_moves
from documents.file_moves import flush_pending_file_moves
from documents.file_moves import move_files_for_document
from documents.file_moves import request_file_move
from documents.models import CustomField
from documents.models import Document
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.tasks import bulk_update_documents
from documents.tasks import check_scheduled_workflows
from documents.tasks import empty_trash
from paperless.celery import app as celery_app
from paperless.middleware import FileMoveMiddleware
from paperless_testing.factories import CorrespondentFactory
from paperless_testing.factories import CustomFieldFactory
from paperless_testing.factories import CustomFieldInstanceFactory
from paperless_testing.factories import DocumentFactory
from paperless_testing.factories import StoragePathFactory
from paperless_testing.factories import TagFactory
from paperless_testing.locks import record_media_lock_acquisitions
if TYPE_CHECKING:
from collections.abc import Callable
from contextlib import AbstractContextManager
from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture
from rest_framework.test import APIClient
from paperless_testing.dirs import PaperlessDirs
OnCommitCapture = Callable[..., AbstractContextManager[list[Callable[[], object]]]]
CONTENT = b"original content"
@pytest.fixture
def title_format(settings: Settings) -> None:
settings.FILENAME_FORMAT = "{title}"
@pytest.fixture
def document(paperless_dirs: PaperlessDirs, title_format: None) -> Document:
"""A document named after its title, with its original on disk."""
(paperless_dirs.originals_dir / "original.pdf").write_bytes(CONTENT)
return DocumentFactory(
title="original",
filename="original.pdf",
mime_type="application/pdf",
checksum=hashlib.sha256(CONTENT).hexdigest(),
)
@celery_app.task(name="documents.tests.test_file_moves.rename_then_fail")
def rename_then_fail(pk: int, originals_dir: str) -> None:
"""Test-only task: rename the document, check nothing moved yet, then fail."""
doc = Document.objects.get(pk=pk)
doc.title = "renamed"
doc.save()
assert (Path(originals_dir) / "original.pdf").is_file()
raise RuntimeError("task failed after saving")
@pytest.mark.django_db
class TestMoveFilesForDocument:
def test_moves_using_fresh_db_state(
self,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- A document whose title changed in the DB without any signal
WHEN:
- move_files_for_document is called with its pk
THEN:
- The file and the DB filename follow the DB title
- The returned instance carries the final filename
"""
Document.objects.filter(pk=document.pk).update(title="renamed")
moved = move_files_for_document(document.pk)
assert moved is not None
assert moved.filename == "renamed.pdf"
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
assert not (paperless_dirs.originals_dir / "original.pdf").exists()
document.refresh_from_db()
assert document.filename == "renamed.pdf"
def test_missing_document_returns_none(self, paperless_dirs: PaperlessDirs) -> None:
"""
GIVEN:
- A pk with no document row
WHEN:
- move_files_for_document is called
THEN:
- It returns None without raising
"""
assert move_files_for_document(999_999) is None
def test_document_without_filename_skips_lock(
self,
paperless_dirs: PaperlessDirs,
title_format: None,
) -> None:
"""
GIVEN:
- A document still being consumed (no filename yet)
WHEN:
- move_files_for_document is called
THEN:
- It returns None, writes nothing, and never takes media.lock
"""
doc = DocumentFactory(filename=None, mime_type="application/pdf")
with record_media_lock_acquisitions() as acquisitions:
assert move_files_for_document(doc.pk) is None
assert acquisitions == []
doc.refresh_from_db()
assert doc.filename is None
def test_no_change_does_not_write_modified(
self,
document: Document,
) -> None:
"""
GIVEN:
- A document already at its generated path, modified in the past
WHEN:
- move_files_for_document is called
THEN:
- modified is not written
"""
past = timezone.now() - datetime.timedelta(days=1)
Document.objects.filter(pk=document.pk).update(modified=past)
move_files_for_document(document.pk)
document.refresh_from_db()
assert document.modified == past
def test_trashed_already_moved_document_gets_db_path_fixed(
self,
paperless_dirs: PaperlessDirs,
title_format: None,
) -> None:
"""
GIVEN:
- A trashed document whose DB filename is stale
- Its file already sits at the generated path (checksum matches)
WHEN:
- move_files_for_document is called
THEN:
- The DB filename is updated to where the file is
"""
doc = DocumentFactory(
title="current",
filename="stale.pdf",
mime_type="application/pdf",
checksum=hashlib.sha256(CONTENT).hexdigest(),
)
assert Document.objects.get(pk=doc.pk).filename == "stale.pdf"
doc.delete()
(paperless_dirs.originals_dir / "current.pdf").write_bytes(CONTENT)
move_files_for_document(doc.pk)
assert Document.global_objects.get(pk=doc.pk).filename == "current.pdf"
def test_lock_timeout_leaves_consistent_state(
self,
document: Document,
paperless_dirs: PaperlessDirs,
mocker: MockerFixture,
caplog: pytest.LogCaptureFixture,
) -> None:
"""
GIVEN:
- A document whose title changed in the DB
- media.lock cannot be acquired
WHEN:
- move_files_for_document is called
THEN:
- An error is logged and None is returned
- The lock was requested with the 300 second timeout
- The DB filename and the file on disk are unchanged
"""
Document.objects.filter(pk=document.pk).update(title="renamed")
lock = mocker.patch("documents.file_moves.media_lock")
lock.return_value.__enter__.side_effect = Timeout("media.lock")
with caplog.at_level(logging.ERROR, logger="paperless.handlers"):
assert move_files_for_document(document.pk) is None
assert "Timed out" in caplog.text
lock.assert_called_once_with(timeout=300)
document.refresh_from_db()
assert document.filename == "original.pdf"
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
@pytest.mark.django_db
class TestRequestFileMoveWithoutScope:
def test_moves_immediately_and_syncs_only_file_columns(
self,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- No file move scope is open
- A caller instance with an unsaved in-memory change
WHEN:
- request_file_move is called after the title changed in the DB
THEN:
- The file moves immediately
- The caller's filename is updated
- The caller's unsaved change is kept
"""
Document.objects.filter(pk=document.pk).update(title="renamed")
unsaved = CorrespondentFactory()
document.correspondent = unsaved
request_file_move(document)
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
assert document.filename == "renamed.pdf"
assert document.correspondent == unsaved
assert document.title == "original"
@pytest.mark.django_db
class TestReceiver:
def test_reverse_m2m_requests_a_move_per_document(
self,
document: Document,
mocker: MockerFixture,
) -> None:
"""
GIVEN:
- A tag
WHEN:
- A document is added through the tag's reverse relation
THEN:
- A move is requested for the added document, never the tag
"""
tag = TagFactory()
request = mocker.patch("documents.signals.handlers.request_file_move")
tag.documents.add(document)
request.assert_called_once()
assert request.call_args.args[0].pk == document.pk
@pytest.mark.django_db
class TestDeferFileMoves:
def test_moves_once_on_exit(
self,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- An open file move scope
WHEN:
- The document is saved with a new title inside the scope
THEN:
- Nothing moves while the scope is open
- On exit the file and DB filename follow the final title
"""
with defer_file_moves():
document.title = "renamed"
document.save()
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
assert Document.objects.get(pk=document.pk).filename == "original.pdf"
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
assert not (paperless_dirs.originals_dir / "original.pdf").exists()
assert Document.objects.get(pk=document.pk).filename == "renamed.pdf"
def test_nested_scope_defers_to_outer(
self,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- A scope nested inside another
WHEN:
- The document is saved inside the inner scope and the inner scope exits
THEN:
- Nothing moves until the outer scope exits
"""
with defer_file_moves():
with defer_file_moves():
document.title = "renamed"
document.save()
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
def test_closed_collector_moves_immediately(
self,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- A collector that has already been closed (work outliving its scope)
WHEN:
- A save asks for a move while that collector is still current
THEN:
- The move happens immediately instead of being lost
"""
with defer_file_moves():
collector = _active_collector.get()
assert collector is not None
token = _active_collector.set(collector)
try:
document.title = "renamed"
document.save()
finally:
_active_collector.reset(token)
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
def test_scope_closing_inside_atomic_flushes_on_commit(
self,
document: Document,
paperless_dirs: PaperlessDirs,
django_capture_on_commit_callbacks: OnCommitCapture,
) -> None:
"""
GIVEN:
- A scope opened and closed inside an atomic block
WHEN:
- The atomic block commits
THEN:
- The move runs only once the commit callbacks run
"""
with django_capture_on_commit_callbacks(execute=True):
with transaction.atomic(), defer_file_moves():
document.title = "renamed"
document.save()
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
def test_scope_closing_inside_rolled_back_atomic_moves_nothing(
self,
document: Document,
paperless_dirs: PaperlessDirs,
django_capture_on_commit_callbacks: OnCommitCapture,
) -> None:
"""
GIVEN:
- A scope opened and closed inside an atomic block
WHEN:
- The atomic block rolls back
THEN:
- The title change is gone and the file is not moved
"""
class Rollback(Exception):
pass
with django_capture_on_commit_callbacks(execute=True):
with pytest.raises(Rollback):
with transaction.atomic(), defer_file_moves():
document.title = "renamed"
document.save()
raise Rollback
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
assert not (paperless_dirs.originals_dir / "renamed.pdf").exists()
assert Document.objects.get(pk=document.pk).title == "original"
def test_flush_pending_moves_early(
self,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- An open scope with a pending move
WHEN:
- flush_pending_file_moves is called inside the scope
THEN:
- The move happens before the scope exits
"""
with defer_file_moves():
document.title = "renamed"
document.save()
flush_pending_file_moves()
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
def test_document_without_filename_in_scope_is_noop(
self,
paperless_dirs: PaperlessDirs,
title_format: None,
) -> None:
"""
GIVEN:
- A document still being consumed (no filename yet)
WHEN:
- It is saved inside a scope and the scope exits
THEN:
- Nothing is written, media.lock is never taken, nothing raises
"""
doc = DocumentFactory(filename=None, mime_type="application/pdf")
with record_media_lock_acquisitions() as acquisitions:
with defer_file_moves():
doc.title = "renamed"
doc.save()
assert acquisitions == []
doc.refresh_from_db()
assert doc.filename is None
def test_mover_keeps_metadata_changed_after_save(
self,
document: Document,
paperless_dirs: PaperlessDirs,
settings: Settings,
) -> None:
"""
GIVEN:
- A filename format using correspondent and title
- A scope in which the title is saved
WHEN:
- Another writer changes the correspondent before the scope exits
THEN:
- The correspondent change survives the flush
- The final path reflects both changes
"""
settings.FILENAME_FORMAT = "{correspondent}/{title}"
correspondent = CorrespondentFactory(name="acme")
with defer_file_moves():
document.title = "renamed"
document.save()
Document.objects.filter(pk=document.pk).update(correspondent=correspondent)
document.refresh_from_db()
assert document.correspondent == correspondent
assert document.filename == "acme/renamed.pdf"
assert (paperless_dirs.originals_dir / "acme" / "renamed.pdf").is_file()
def test_one_failure_does_not_stop_the_flush(
self,
paperless_dirs: PaperlessDirs,
title_format: None,
mocker: MockerFixture,
) -> None:
"""
GIVEN:
- Two documents saved in one scope
- Moving the first raises an unexpected error
WHEN:
- The scope exits
THEN:
- The scope does not raise
- The second document is still moved
"""
(paperless_dirs.originals_dir / "a.pdf").write_bytes(CONTENT)
first = DocumentFactory(
filename="a.pdf",
title="a",
mime_type="application/pdf",
)
(paperless_dirs.originals_dir / "b.pdf").write_bytes(CONTENT)
second = DocumentFactory(
filename="b.pdf",
title="b",
mime_type="application/pdf",
)
real_move = move_files_for_document
def failing_for_first(pk: int) -> Document | None:
if pk == first.pk:
raise RuntimeError("boom")
return real_move(pk)
mocker.patch(
"documents.file_moves.move_files_for_document",
side_effect=failing_for_first,
)
with defer_file_moves():
first.title = "a2"
first.save()
second.title = "b2"
second.save()
assert (paperless_dirs.originals_dir / "b2.pdf").is_file()
assert (paperless_dirs.originals_dir / "a.pdf").is_file()
@pytest.mark.django_db
class TestFileMoveMiddleware:
def test_sync_mode_flushes_before_returning(
self,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- The middleware wrapping a sync view that renames the document
WHEN:
- A request passes through it
THEN:
- Nothing moves while the view runs
- The file is at its final path when the middleware returns
"""
def view(request: HttpRequest) -> HttpResponse:
document.title = "renamed"
document.save()
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
return HttpResponse()
middleware = FileMoveMiddleware(view)
assert middleware.async_mode is False
middleware(RequestFactory().get("/"))
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
def test_async_mode_flushes_after_await(
self,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- The middleware wrapping an async view that saves through
sync_to_async
WHEN:
- A request passes through it
THEN:
- Nothing moves while the view runs
- The file is at its final path after the await, so the collector
survived the context copy into the sync thread
"""
def rename() -> None:
document.title = "renamed"
document.save()
async def view(request: HttpRequest) -> HttpResponse:
await sync_to_async(rename)()
assert (paperless_dirs.originals_dir / "original.pdf").is_file()
return HttpResponse()
middleware = FileMoveMiddleware(view)
assert middleware.async_mode is True
async_to_sync(middleware)(RequestFactory().get("/"))
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
def test_view_raising_after_save_still_moves(
self,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- A view that saves a rename and then raises
WHEN:
- A request passes through the middleware
THEN:
- The exception propagates
- The committed rename is still moved
"""
def view(request: HttpRequest) -> HttpResponse:
document.title = "renamed"
document.save()
raise RuntimeError("view failed")
with pytest.raises(RuntimeError, match="view failed"):
FileMoveMiddleware(view)(RequestFactory().get("/"))
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
@pytest.mark.django_db
class TestFileMovesThroughApi:
def test_media_lock_never_taken_inside_a_transaction(
self,
admin_client: APIClient,
document: Document,
) -> None:
"""
GIVEN:
- A document and a tag
WHEN:
- A tag + title PATCH and a note POST are made through the API
THEN:
- media.lock is acquired at least once
- media.lock is never first acquired while a transaction is open
"""
tag = TagFactory()
with record_media_lock_acquisitions() as acquisitions:
response = admin_client.patch(
f"/api/documents/{document.pk}/",
{"tags": [tag.pk], "title": "renamed"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
response = admin_client.post(
f"/api/documents/{document.pk}/notes/",
{"note": "a note"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert acquisitions
assert not any(a.in_user_transaction for a in acquisitions)
def test_patch_moves_to_final_path(
self,
admin_client: APIClient,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- A document named after its title
WHEN:
- Its title and tags are changed through the API
THEN:
- The file is at the final path when the response returns
"""
response = admin_client.patch(
f"/api/documents/{document.pk}/",
{"tags": [TagFactory().pk], "title": "renamed"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
assert Document.objects.get(pk=document.pk).filename == "renamed.pdf"
def test_patch_response_reports_post_move_modified(
self,
admin_client: APIClient,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- A document named after its title
WHEN:
- Its title is changed through the API, causing a rename
THEN:
- The modified time in the response equals the stored value,
which the file move bumped after the edit was saved
"""
response = admin_client.patch(
f"/api/documents/{document.pk}/",
{"title": "renamed"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
stored = Document.objects.get(pk=document.pk)
assert stored.filename == "renamed.pdf"
assert parse_datetime(response.json()["modified"]) == stored.modified
def test_trash_restore_keeps_db_and_disk_consistent(
self,
admin_client: APIClient,
document: Document,
) -> None:
"""
GIVEN:
- A document moved to the trash
WHEN:
- It is restored through the trash API
THEN:
- Its DB filename points at a file that exists
"""
admin_client.delete(f"/api/documents/{document.pk}/")
response = admin_client.post(
"/api/trash/",
{"action": "restore", "documents": [document.pk]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
restored = Document.objects.get(pk=document.pk)
assert restored.source_path.is_file()
def test_root_storage_path_change_moves_versions(
self,
admin_client: APIClient,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- A root document with one version, both with files on disk
WHEN:
- The root's storage path is changed through the API
THEN:
- The root and version files both follow the storage path
"""
(paperless_dirs.originals_dir / "version.pdf").write_bytes(CONTENT)
version = DocumentFactory(
root_document=document,
title="version",
filename="version.pdf",
mime_type="application/pdf",
checksum=hashlib.sha256(CONTENT).hexdigest(),
)
storage_path = StoragePathFactory(path="stored/{{ title }}")
response = admin_client.patch(
f"/api/documents/{document.pk}/",
{"storage_path": storage_path.pk},
format="json",
)
assert response.status_code == status.HTTP_200_OK
document.refresh_from_db()
version.refresh_from_db()
assert document.filename.startswith("stored/")
assert version.filename.startswith("stored/")
assert document.source_path.is_file()
assert version.source_path.is_file()
def test_custom_field_change_moves_to_final_path(
self,
admin_client: APIClient,
document: Document,
paperless_dirs: PaperlessDirs,
settings: Settings,
) -> None:
"""
GIVEN:
- A filename format that uses a text custom field
- A document with that field set
WHEN:
- The field value is changed through the API
THEN:
- The file is at the path for the new value
"""
settings.FILENAME_FORMAT = (
"{{ custom_fields|get_cf_value('flavor') }}/{{ title }}"
)
field = CustomFieldFactory(
name="flavor",
data_type=CustomField.FieldDataType.STRING,
)
CustomFieldInstanceFactory(field=field, document=document, value_text="apple")
response = admin_client.patch(
f"/api/documents/{document.pk}/",
{"custom_fields": [{"field": field.pk, "value": "cherry"}]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
document.refresh_from_db()
assert document.filename == "cherry/original.pdf"
assert (paperless_dirs.originals_dir / "cherry" / "original.pdf").is_file()
@pytest.mark.django_db
class TestCeleryTaskScope:
def test_task_class_opens_scope(self) -> None:
"""
GIVEN:
- The paperless Celery app
WHEN:
- A registered task is inspected
THEN:
- It runs through FileMoveScopedTask
- It keeps Celery's DjangoTask API, such as delay_on_commit
"""
from paperless.celery import FileMoveScopedTask
assert isinstance(bulk_update_documents, FileMoveScopedTask)
assert isinstance(bulk_update_documents, DjangoTask)
def test_failing_task_still_moves_committed_change(
self,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- A task that renames a document, then raises
WHEN:
- The task is applied
THEN:
- The task fails with its own error
- Nothing moved while the task body ran
- The committed rename is moved when the task ends
"""
result = rename_then_fail.apply(
args=[document.pk, str(paperless_dirs.originals_dir)],
)
assert result.failed()
assert isinstance(result.result, RuntimeError)
assert (paperless_dirs.originals_dir / "renamed.pdf").is_file()
def test_bulk_update_moves_each_document(
self,
document: Document,
paperless_dirs: PaperlessDirs,
mocker: MockerFixture,
) -> None:
"""
GIVEN:
- Two documents whose titles changed in the DB without signals
WHEN:
- bulk_update_documents runs for both
THEN:
- Each document's file follows its new title
"""
mocker.patch("documents.search.get_backend")
(paperless_dirs.originals_dir / "second.pdf").write_bytes(CONTENT)
second = DocumentFactory(
title="second",
filename="second.pdf",
mime_type="application/pdf",
checksum=hashlib.sha256(CONTENT).hexdigest(),
)
Document.objects.filter(pk=document.pk).update(title="first-renamed")
Document.objects.filter(pk=second.pk).update(title="second-renamed")
bulk_update_documents.apply(args=[[document.pk, second.pk]])
assert (paperless_dirs.originals_dir / "first-renamed.pdf").is_file()
assert (paperless_dirs.originals_dir / "second-renamed.pdf").is_file()
def test_scheduled_workflows_move_each_document(
self,
document: Document,
paperless_dirs: PaperlessDirs,
mocker: MockerFixture,
) -> None:
"""
GIVEN:
- Two documents matched by a scheduled workflow, where running the
workflow renames the document
WHEN:
- check_scheduled_workflows runs
THEN:
- The first document's file is already moved when the workflow
runs for the second, not only when the task ends
"""
(paperless_dirs.originals_dir / "second.pdf").write_bytes(CONTENT)
second = DocumentFactory(
title="second",
filename="second.pdf",
mime_type="application/pdf",
checksum=hashlib.sha256(CONTENT).hexdigest(),
)
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
schedule_offset_days=0,
schedule_date_field=WorkflowTrigger.ScheduleDateField.ADDED,
)
workflow = Workflow.objects.create(name="scheduled", order=0)
workflow.triggers.add(trigger)
workflow.actions.add(WorkflowAction.objects.create(assign_title="unused"))
seen: dict[int, bool] = {}
def rename(*, document: Document, **kwargs: object) -> None:
seen[document.pk] = (paperless_dirs.originals_dir / "first.pdf").is_file()
document.title = "first" if document.pk == first_pk else "other"
document.save()
first_pk = min(document.pk, second.pk)
mocker.patch("documents.tasks.run_workflows", side_effect=rename)
mocker.patch("documents.tasks.send_websocket_document_updated")
check_scheduled_workflows.apply()
second_pk = max(document.pk, second.pk)
assert seen[first_pk] is False
assert seen[second_pk] is True
@pytest.mark.django_db
class TestEmptyTrashLockOrdering:
def test_hard_delete_takes_media_lock_outside_transaction(
self,
document: Document,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- A trashed document with its file on disk
WHEN:
- The trash is emptied for it
THEN:
- media.lock is never first acquired inside a transaction
- The document row and its file are gone
"""
document.delete()
with record_media_lock_acquisitions() as acquisitions:
empty_trash([document.pk])
assert acquisitions
assert not any(a.in_user_transaction for a in acquisitions)
assert not Document.global_objects.filter(pk=document.pk).exists()
assert not (paperless_dirs.originals_dir / "original.pdf").exists()
+1 -61
View File
@@ -18,13 +18,11 @@ if TYPE_CHECKING:
from pytest_mock import MockerFixture
from documents.file_handling import generate_filename
from documents.file_moves import move_files_for_document
from documents.models import Document
from documents.tasks import update_document_content_maybe_archive_file
from paperless_testing.assertions import FileSystemAssertsMixin
from paperless_testing.dirs import DirectoriesMixin
from paperless_testing.factories import DocumentFactory
from paperless_testing.locks import record_media_lock_acquisitions
from paperless_testing.samples import SIMPLE_DIGITAL_PDF
sample_file: Path = SIMPLE_DIGITAL_PDF
@@ -65,28 +63,6 @@ class TestArchiver(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertTrue(filecmp.cmp(sample_file, doc.source_path))
self.assertEqual(doc.archive_filename, "none/A.pdf")
def test_handle_document_takes_media_lock_outside_transaction(self) -> None:
"""
GIVEN:
- A document whose archive version is being generated
WHEN:
- update_document_content_maybe_archive_file runs
THEN:
- media.lock is acquired
- media.lock is never first acquired while a transaction holds the
document row
"""
doc = self.make_models()
shutil.copy(sample_file, Path(self.dirs.originals_dir) / f"{doc.id:07}.pdf")
with record_media_lock_acquisitions() as acquisitions:
update_document_content_maybe_archive_file(doc.pk)
self.assertTrue(acquisitions)
self.assertFalse(any(a.in_user_transaction for a in acquisitions))
doc.refresh_from_db()
self.assertIsFile(doc.archive_path)
def test_unknown_mime_type(self) -> None:
doc = self.make_models()
doc.mime_type = "sdgfh"
@@ -260,7 +236,7 @@ class TestRenamer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
doc = Document.objects.create(title="test", mime_type="image/jpeg")
doc.filename = generate_filename(doc)
doc.archive_filename = generate_filename(doc, archive_filename=True)
doc.save(write_file_paths=True)
doc.save()
Path(doc.source_path).touch()
Path(doc.archive_path).touch()
@@ -277,42 +253,6 @@ class TestRenamer(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertIsFile(doc2.source_path)
self.assertIsFile(doc2.archive_path)
@override_settings(FILENAME_FORMAT="")
def test_rename_moves_each_document_by_direct_call(self) -> None:
"""
GIVEN:
- Two documents whose filename format changed
WHEN:
- document_renamer runs
THEN:
- move_files_for_document is called once per document
- Both files are at their new paths
"""
docs = []
for title in ("first", "second"):
doc = Document.objects.create(title=title, mime_type="image/jpeg")
doc.filename = generate_filename(doc)
doc.save(write_file_paths=True)
Path(doc.source_path).touch()
docs.append(doc)
with (
override_settings(FILENAME_FORMAT="{title}"),
mock.patch(
"documents.management.commands.document_renamer.move_files_for_document",
wraps=move_files_for_document,
) as mover,
):
call_command("document_renamer", skip_checks=True)
self.assertCountEqual(
[c.args[0] for c in mover.call_args_list],
[d.pk for d in docs],
)
for doc in docs:
doc.refresh_from_db()
self.assertIsFile(doc.source_path)
@pytest.mark.management
class TestCreateClassifier:
@@ -4,13 +4,10 @@ Tests for the document_retagger management command.
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from django.core.management import call_command
from django.core.management.base import CommandError
from documents.file_moves import _active_collector
from documents.models import Correspondent
from documents.models import Document
from documents.models import DocumentType
@@ -24,12 +21,6 @@ from paperless_testing.factories import DocumentTypeFactory
from paperless_testing.factories import StoragePathFactory
from paperless_testing.factories import TagFactory
if TYPE_CHECKING:
from pytest_django.fixtures import Settings
from pytest_mock import MockerFixture
from paperless_testing.dirs import PaperlessDirs
# ---------------------------------------------------------------------------
# Module-level type aliases
# ---------------------------------------------------------------------------
@@ -493,69 +484,3 @@ class TestRetaggerEdgeCases(DirectoriesMixin):
assert d_first.tags.count() == 0
assert d_unrelated.tags.count() == 2
@pytest.mark.management
@pytest.mark.django_db
class TestRetaggerFileMoves:
def test_each_document_is_processed_in_its_own_scope(
self,
paperless_dirs: PaperlessDirs,
settings: Settings,
mocker: MockerFixture,
) -> None:
"""
GIVEN:
- Two documents and a filename format using the correspondent
- A classifier suggestion assigning a correspondent to each
WHEN:
- document_retagger runs with --correspondent
THEN:
- Each document is processed inside an open file move scope
- When the second document is processed, the first one's file has
already moved
- Both files end at their final paths
"""
settings.FILENAME_FORMAT = "{correspondent}/{title}"
(paperless_dirs.originals_dir / "none").mkdir(exist_ok=True)
for title in ("first", "second"):
(paperless_dirs.originals_dir / "none" / f"{title}.pdf").write_bytes(b"x")
DocumentFactory(
title=title,
filename=f"none/{title}.pdf",
mime_type="application/pdf",
)
correspondent = CorrespondentFactory(name="acme")
processed: list[Document] = []
scope_open: list[bool] = []
seen_first_moved: list[bool] = []
def fake_set_correspondent(sender, document, **kwargs):
scope_open.append(_active_collector.get() is not None)
# Order-agnostic: on the second call, check the first one's file.
if processed:
first = processed[0]
seen_first_moved.append(
(
paperless_dirs.originals_dir / "acme" / f"{first.title}.pdf"
).is_file(),
)
processed.append(document)
document.correspondent = correspondent
document.save(update_fields=("correspondent",))
return correspondent
mocker.patch(
"documents.management.commands.document_retagger.load_classifier",
)
mocker.patch(
"documents.management.commands.document_retagger.set_correspondent",
side_effect=fake_set_correspondent,
)
call_command("document_retagger", "--correspondent", skip_checks=True)
assert scope_open == [True, True]
assert seen_first_moved == [True]
for title in ("first", "second"):
assert (paperless_dirs.originals_dir / "acme" / f"{title}.pdf").is_file()
-121
View File
@@ -1,121 +0,0 @@
from __future__ import annotations
import threading
from typing import TYPE_CHECKING
import pytest
from django.db import transaction
from filelock import Timeout
from documents.file_moves import in_user_transaction
from documents.media_lock import media_lock
from paperless_testing.locks import record_media_lock_acquisitions
if TYPE_CHECKING:
from paperless_testing.dirs import PaperlessDirs
@pytest.mark.usefixtures("paperless_dirs")
class TestMediaLock:
def test_same_thread_reenters_without_blocking(self) -> None:
"""
GIVEN:
- media.lock held by the current thread
WHEN:
- The same thread acquires it again with a zero timeout
THEN:
- The nested acquisition succeeds immediately
"""
with media_lock(), media_lock(timeout=0):
pass
def test_other_thread_is_excluded(self) -> None:
"""
GIVEN:
- media.lock held by the current thread
WHEN:
- Another thread tries to acquire it with a short timeout
THEN:
- The other thread times out
- After release, the other thread can acquire it
"""
outcomes: list[str] = []
def try_acquire() -> None:
try:
with media_lock(timeout=0.2):
outcomes.append("acquired")
except Timeout:
outcomes.append("timeout")
with media_lock():
worker = threading.Thread(target=try_acquire)
worker.start()
worker.join()
worker = threading.Thread(target=try_acquire)
worker.start()
worker.join()
assert outcomes == ["timeout", "acquired"]
def test_lock_file_is_the_configured_path(
self,
paperless_dirs: PaperlessDirs,
) -> None:
"""
GIVEN:
- The test's media directory layout
WHEN:
- media.lock is acquired
THEN:
- The lock file is created at settings.MEDIA_LOCK
"""
with media_lock():
assert paperless_dirs.media_lock.exists()
@pytest.mark.django_db
class TestInUserTransaction:
def test_false_inside_test_case_wrapper(self) -> None:
"""
GIVEN:
- The transaction pytest-django wraps around the test
WHEN:
- in_user_transaction is called
THEN:
- It returns False
"""
assert in_user_transaction() is False
def test_true_inside_atomic(self) -> None:
"""
GIVEN:
- An atomic block opened by code under test
WHEN:
- in_user_transaction is called inside it
THEN:
- It returns True
"""
with transaction.atomic():
assert in_user_transaction() is True
@pytest.mark.django_db
@pytest.mark.usefixtures("paperless_dirs")
class TestRecordMediaLockAcquisitions:
def test_records_only_first_acquisitions(self) -> None:
"""
GIVEN:
- Nested media.lock acquisitions, the outer one inside a transaction
WHEN:
- They are recorded
THEN:
- Only the outer, first acquisition is recorded, flagged as inside
a transaction
"""
with record_media_lock_acquisitions() as acquisitions:
with transaction.atomic(), media_lock(), media_lock():
pass
assert [a.in_user_transaction for a in acquisitions] == [True]
-101
View File
@@ -1,7 +1,4 @@
import pytest
from django.db import DatabaseError
from django.db import models
from django.db import transaction
from documents.models import Correspondent
from documents.models import Document
@@ -25,101 +22,3 @@ class TestDocument:
assert Correspondent.objects.count() == 0
assert Document.objects.count() == 1
assert Document.objects.first().correspondent is None
@pytest.mark.django_db
@pytest.mark.usefixtures("paperless_dirs")
class TestDocumentFilePathWrites:
def test_full_save_does_not_write_file_paths(self) -> None:
"""
GIVEN:
- A document whose filename columns were changed in the DB after
an instance was loaded
WHEN:
- The stale instance is saved without update_fields
THEN:
- The DB filename columns keep the newer values
- Other fields from the stale instance are written
"""
doc = DocumentFactory(filename="old.pdf", archive_filename="old.pdf")
stale = Document.objects.get(pk=doc.pk)
Document.objects.filter(pk=doc.pk).update(
filename="new.pdf",
archive_filename="new-archive.pdf",
)
stale.title = "changed"
stale.save()
doc.refresh_from_db()
assert doc.filename == "new.pdf"
assert doc.archive_filename == "new-archive.pdf"
assert doc.title == "changed"
@pytest.mark.parametrize(
"save_kwargs",
[
pytest.param({"write_file_paths": True}, id="write-file-paths"),
pytest.param(
{"update_fields": ["filename", "archive_filename"]},
id="explicit-update-fields",
),
],
)
def test_opt_in_writes_file_paths(self, save_kwargs: dict[str, object]) -> None:
"""
GIVEN:
- An existing document
WHEN:
- Its filename columns are changed in memory and saved with an
explicit opt in
THEN:
- The DB filename columns are updated
"""
doc = DocumentFactory(filename="old.pdf", archive_filename="old.pdf")
doc.filename = "new.pdf"
doc.archive_filename = "new.pdf"
doc.save(**save_kwargs)
doc.refresh_from_db()
assert doc.filename == "new.pdf"
assert doc.archive_filename == "new.pdf"
def test_creation_writes_file_paths(self) -> None:
"""
GIVEN:
- A new, unsaved document with filename columns set
WHEN:
- It is saved
THEN:
- The filename columns are written
"""
doc = DocumentFactory.build(filename="a.pdf", archive_filename="b.pdf")
doc.save()
doc.refresh_from_db()
assert doc.filename == "a.pdf"
assert doc.archive_filename == "b.pdf"
def test_full_save_of_deleted_row_raises(self) -> None:
"""
GIVEN:
- An instance whose row was hard-deleted elsewhere
WHEN:
- The instance is saved without update_fields
THEN:
- A DatabaseError is raised instead of re-inserting the row
"""
doc = DocumentFactory()
stale = Document.objects.get(pk=doc.pk)
# A plain QuerySet bypasses the soft-delete manager, so the row is
# really gone (as after emptying the trash).
models.QuerySet(model=Document).filter(pk=doc.pk).delete()
# The savepoint keeps the failed UPDATE from poisoning the test transaction.
with pytest.raises(DatabaseError), transaction.atomic():
stale.save()
assert not Document.global_objects.filter(pk=doc.pk).exists()
+2 -2
View File
@@ -132,7 +132,7 @@ class TestCheckSanityOriginal:
class TestCheckSanityArchive:
def test_checksum_without_filename(self, sample_doc: Document) -> None:
sample_doc.archive_filename = None
sample_doc.save(write_file_paths=True)
sample_doc.save()
messages = check_sanity()
assert messages.has_error
assert any(
@@ -185,7 +185,7 @@ class TestCheckSanityArchive:
Path(sample_doc.archive_path).unlink()
sample_doc.archive_checksum = None
sample_doc.archive_filename = None
sample_doc.save(write_file_paths=True)
sample_doc.save()
messages = check_sanity()
assert not messages.has_error
@@ -366,7 +366,7 @@ class ShareLinkBundleBuildTaskTests(DirectoriesMixin, APITestCase):
def _write_document_file(self, *, archive: bool, content: bytes) -> Path:
if archive:
self.document.archive_filename = f"{self.document.pk:07}.pdf"
self.document.save(write_file_paths=True)
self.document.save()
path = self.document.archive_path
assert path is not None
else:
+36
View File
@@ -5,8 +5,11 @@ from unittest import mock
import pytest
from django.conf import settings
from django.contrib.sessions.models import Session
from django.db import connection
from django.test import TestCase
from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from django.utils import timezone
from documents import tasks
@@ -612,3 +615,36 @@ class TestApplyAISuggestionsTask(DirectoriesMixin, TestCase):
apply_suggestions.assert_not_called()
self.assertIn("no longer exists", "".join(cm.output))
@pytest.mark.django_db
class TestClearExpiredSessions:
def test_clears_only_expired_sessions_in_batches(self) -> None:
"""
GIVEN:
- More than one batch of expired sessions and one still-valid session
WHEN:
- The clear_expired_sessions task runs
THEN:
- Only expired sessions are removed, using multiple deletes
"""
now = timezone.now()
Session.objects.bulk_create(
Session(
session_key=f"expired-{i}",
session_data="",
expire_date=now - timedelta(days=1),
)
for i in range(501)
)
Session.objects.create(
session_key="valid",
session_data="",
expire_date=now + timedelta(days=1),
)
with CaptureQueriesContext(connection) as queries:
tasks.clear_expired_sessions()
assert set(Session.objects.values_list("session_key", flat=True)) == {"valid"}
assert sum(query["sql"].startswith("DELETE FROM") for query in queries) == 2
+38 -148
View File
@@ -11,6 +11,7 @@ from collections.abc import Callable
from datetime import datetime
from datetime import timedelta
from http import HTTPStatus
from inspect import signature
from itertools import islice
from pathlib import Path
from time import mktime
@@ -106,6 +107,9 @@ from documents import bulk_edit
from documents.bulk_download import ArchiveOnlyStrategy
from documents.bulk_download import OriginalAndArchiveStrategy
from documents.bulk_download import OriginalsOnlyStrategy
from documents.bulk_operations import QUEUED_OPERATIONS
from documents.bulk_operations import execute_bulk_operation
from documents.bulk_operations import snapshot_field
from documents.caching import get_llm_suggestion_cache
from documents.caching import get_metadata_cache
from documents.caching import get_suggestion_cache
@@ -128,7 +132,6 @@ from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
from documents.data_models import DocumentSource
from documents.file_handling import format_filename
from documents.file_moves import flush_pending_file_moves
from documents.filters import CorrespondentFilterSet
from documents.filters import CustomFieldFilterSet
from documents.filters import DocumentFilterSet
@@ -174,6 +177,7 @@ from documents.permissions import ViewDocumentsPermissions
from documents.permissions import annotate_document_count_for_related_queryset
from documents.permissions import get_document_count_filter_for_user
from documents.permissions import get_permission_grantees
from documents.permissions import has_document_operation_permissions
from documents.permissions import has_global_statistics_permission
from documents.permissions import has_perms_owner_aware
from documents.permissions import has_system_status_permission
@@ -1292,8 +1296,6 @@ class DocumentViewSet(
)
serializer.is_valid(raise_exception=True)
self.perform_update(serializer)
# The response must reflect the post-move filename and modified time
flush_pending_file_moves()
if content_updated and content_doc.id != root_doc.id:
content_doc.content = (
@@ -2985,98 +2987,26 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
"merge_as_versions",
}
def _has_document_permissions(
self,
*,
user: User,
documents: list[int],
method,
parameters: dict[str, Any],
) -> bool:
if user.is_superuser:
return True
root_docs = {
get_root_document(doc)
for doc in Document.objects.select_related(
"owner",
"root_document__owner",
).filter(pk__in=documents)
}
user_is_owner_of_all_documents = all(
(doc.owner == user or doc.owner is None) for doc in root_docs
def _queue_bulk_operation(self, method, documents, parameters, from_webui):
if "update_callback" in parameters:
raise ValueError("Unsupported bulk operation parameter: update_callback")
signature(method).bind(documents, **parameters)
task = execute_bulk_operation.apply_async(
kwargs={
"operation": method.__name__,
"document_ids": documents,
"parameters": parameters,
"user_id": self.request.user.pk,
},
headers={
"trigger_source": (
PaperlessTask.TriggerSource.WEB_UI
if from_webui
else PaperlessTask.TriggerSource.API_UPLOAD
),
},
)
# check global and object permissions for all documents
has_perms = (
user.has_perm(
"documents.change_document",
)
and not Document.global_objects.filter(pk__in=documents)
.exclude(pk__in=permitted_document_ids(user, perm="change_document"))
.exists()
)
# check ownership for methods that change original document
if (
(
has_perms
and method
in [
bulk_edit.set_permissions,
bulk_edit.delete,
bulk_edit.rotate,
bulk_edit.delete_pages,
bulk_edit.edit_pdf,
bulk_edit.merge_as_versions,
bulk_edit.remove_password,
]
)
or (
method in [bulk_edit.merge, bulk_edit.split]
and parameters.get("delete_originals")
)
or (method == bulk_edit.edit_pdf and parameters.get("update_document"))
):
has_perms = has_perms and user_is_owner_of_all_documents
# check global add permissions for methods that create documents
if (
has_perms
and (
method in [bulk_edit.split, bulk_edit.merge]
or (
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
and not parameters.get("update_document")
)
)
and not user.has_perm("documents.add_document")
):
has_perms = False
# check global delete permissions for methods that delete documents
if (
has_perms
and (
method == bulk_edit.delete
# Sources stop being documents of their own, and removing one
# again afterwards needs delete_document
or method == bulk_edit.merge_as_versions
or (
method in [bulk_edit.merge, bulk_edit.split]
and parameters.get("delete_originals")
)
or (
method in [bulk_edit.edit_pdf, bulk_edit.remove_password]
and parameters.get("delete_original")
and not parameters.get("update_document")
)
)
and not user.has_perm("documents.delete_document")
):
has_perms = False
return has_perms
return Response({"result": "OK", "task_id": task.id})
def _execute_document_action(
self,
@@ -3113,7 +3043,7 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
else PaperlessTask.TriggerSource.API_UPLOAD
)
if not self._has_document_permissions(
if not has_document_operation_permissions(
user=user,
documents=documents,
method=method,
@@ -3144,67 +3074,20 @@ class DocumentOperationPermissionMixin(PassUserMixin, DocumentSelectionMixin):
name="BulkEditDocumentsResult",
fields={
"result": serializers.CharField(),
"task_id": serializers.CharField(required=False),
},
),
},
),
)
class BulkEditView(DocumentOperationPermissionMixin):
MODIFIED_FIELD_BY_METHOD = {
"set_correspondent": "correspondent",
"set_document_type": "document_type",
"set_storage_path": "storage_path",
"add_tag": "tags",
"remove_tag": "tags",
"modify_tags": "tags",
"modify_custom_fields": "custom_fields",
"set_permissions": None,
LEGACY_MODIFIED_FIELDS = {
"delete": "deleted_at",
# These operations create new documents/versions no longer altering
# fields on the selected document in place
"rotate": None,
"delete_pages": None,
"split": None,
"merge": None,
"edit_pdf": None,
"reprocess": "checksum",
"remove_password": None,
}
serializer_class = BulkEditSerializer
@staticmethod
def _snapshot_field(doc_ids: list[int], field: str) -> dict[int, Any]:
"""
Returns each document's current value of field, for the audit log.
Tags and custom fields are one row per value, so they are gathered
into a sorted list of pks per document (empty when there are none).
Reading them through Document.values() instead would join those rows
and return one arbitrary value per document.
"""
if field == "tags":
rows = (
Document.tags.through.objects.filter(document_id__in=doc_ids)
.order_by("tag_id")
.values_list("document_id", "tag_id")
)
elif field == "custom_fields":
rows = (
CustomFieldInstance.objects.filter(document_id__in=doc_ids)
.order_by("pk")
.values_list("document_id", "pk")
)
else:
return dict(
Document.objects.filter(pk__in=doc_ids).values_list("pk", field),
)
values: dict[int, list[int]] = {doc_id: [] for doc_id in doc_ids}
for doc_id, pk in rows:
values[doc_id].append(pk)
return values
def post(self, request, *args, **kwargs):
request_method = request.data.get("method")
api_version = int(request.version or settings.REST_FRAMEWORK["DEFAULT_VERSION"])
@@ -3240,7 +3123,7 @@ class BulkEditView(DocumentOperationPermissionMixin):
if from_webui
else PaperlessTask.TriggerSource.API_UPLOAD
)
if not self._has_document_permissions(
if not has_document_operation_permissions(
user=user,
documents=documents,
method=method,
@@ -3249,14 +3132,21 @@ class BulkEditView(DocumentOperationPermissionMixin):
return HttpResponseForbidden("Insufficient permissions")
try:
modified_field = self.MODIFIED_FIELD_BY_METHOD.get(method.__name__, None)
if method.__name__ in QUEUED_OPERATIONS:
return self._queue_bulk_operation(
method,
documents,
parameters,
from_webui,
)
modified_field = self.LEGACY_MODIFIED_FIELDS.get(method.__name__)
if settings.AUDIT_LOG_ENABLED and modified_field:
old_values = self._snapshot_field(documents, modified_field)
old_values = snapshot_field(documents, modified_field)
result = method(documents, **parameters)
if settings.AUDIT_LOG_ENABLED and modified_field:
new_values = self._snapshot_field(documents, modified_field)
new_values = snapshot_field(documents, modified_field)
for doc in Document.objects.filter(pk__in=documents):
LogEntry.objects.log_create(
instance=doc,
+50 -50
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-11-07 02:45+0000\n"
"POT-Creation-Date: 2026-11-11 23:50+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1661,8 +1661,8 @@ msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:483 documents/serialisers.py:844
#: documents/serialisers.py:2850 documents/views.py:349 documents/views.py:615
#: documents/views.py:2755 paperless_mail/serialisers.py:156
#: documents/serialisers.py:2850 documents/views.py:354 documents/views.py:620
#: documents/views.py:2760 paperless_mail/serialisers.py:156
msgid "Insufficient permissions."
msgstr ""
@@ -1702,7 +1702,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2936 documents/views.py:4805
#: documents/serialisers.py:2936 documents/views.py:4698
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1953,40 +1953,40 @@ msgstr ""
msgid ", "
msgstr ""
#: documents/views.py:304
#: documents/views.py:309
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:342 documents/views.py:2752
#: documents/views.py:347 documents/views.py:2757
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1679
#: documents/views.py:1684
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1690
#: documents/views.py:1695
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:1702
#: documents/views.py:1707
msgid "AI backend rejected the request. Check logs for details."
msgstr ""
#: documents/views.py:4821
#: documents/views.py:4714
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4868
#: documents/views.py:4761
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4934
#: documents/views.py:4827
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4948
#: documents/views.py:4841
msgid "The share link bundle is unavailable."
msgstr ""
@@ -2278,151 +2278,151 @@ msgstr ""
msgid "paperless application settings"
msgstr ""
#: paperless/settings/__init__.py:570
#: paperless/settings/__init__.py:581
msgid "English (US)"
msgstr ""
#: paperless/settings/__init__.py:571
#: paperless/settings/__init__.py:582
msgid "Arabic"
msgstr ""
#: paperless/settings/__init__.py:572
#: paperless/settings/__init__.py:583
msgid "Afrikaans"
msgstr ""
#: paperless/settings/__init__.py:573
#: paperless/settings/__init__.py:584
msgid "Belarusian"
msgstr ""
#: paperless/settings/__init__.py:574
#: paperless/settings/__init__.py:585
msgid "Bulgarian"
msgstr ""
#: paperless/settings/__init__.py:575
#: paperless/settings/__init__.py:586
msgid "Catalan"
msgstr ""
#: paperless/settings/__init__.py:576
#: paperless/settings/__init__.py:587
msgid "Czech"
msgstr ""
#: paperless/settings/__init__.py:577
#: paperless/settings/__init__.py:588
msgid "Danish"
msgstr ""
#: paperless/settings/__init__.py:578
#: paperless/settings/__init__.py:589
msgid "German"
msgstr ""
#: paperless/settings/__init__.py:579
#: paperless/settings/__init__.py:590
msgid "Greek"
msgstr ""
#: paperless/settings/__init__.py:580
#: paperless/settings/__init__.py:591
msgid "English (GB)"
msgstr ""
#: paperless/settings/__init__.py:581
#: paperless/settings/__init__.py:592
msgid "Spanish"
msgstr ""
#: paperless/settings/__init__.py:582
#: paperless/settings/__init__.py:593
msgid "Persian"
msgstr ""
#: paperless/settings/__init__.py:583
#: paperless/settings/__init__.py:594
msgid "Finnish"
msgstr ""
#: paperless/settings/__init__.py:584
#: paperless/settings/__init__.py:595
msgid "French"
msgstr ""
#: paperless/settings/__init__.py:585
#: paperless/settings/__init__.py:596
msgid "Hungarian"
msgstr ""
#: paperless/settings/__init__.py:586
#: paperless/settings/__init__.py:597
msgid "Indonesian"
msgstr ""
#: paperless/settings/__init__.py:587
#: paperless/settings/__init__.py:598
msgid "Italian"
msgstr ""
#: paperless/settings/__init__.py:588
#: paperless/settings/__init__.py:599
msgid "Japanese"
msgstr ""
#: paperless/settings/__init__.py:589
#: paperless/settings/__init__.py:600
msgid "Korean"
msgstr ""
#: paperless/settings/__init__.py:590
#: paperless/settings/__init__.py:601
msgid "Luxembourgish"
msgstr ""
#: paperless/settings/__init__.py:591
#: paperless/settings/__init__.py:602
msgid "Norwegian"
msgstr ""
#: paperless/settings/__init__.py:592
#: paperless/settings/__init__.py:603
msgid "Dutch"
msgstr ""
#: paperless/settings/__init__.py:593
#: paperless/settings/__init__.py:604
msgid "Polish"
msgstr ""
#: paperless/settings/__init__.py:594
#: paperless/settings/__init__.py:605
msgid "Portuguese (Brazil)"
msgstr ""
#: paperless/settings/__init__.py:595
#: paperless/settings/__init__.py:606
msgid "Portuguese"
msgstr ""
#: paperless/settings/__init__.py:596
#: paperless/settings/__init__.py:607
msgid "Romanian"
msgstr ""
#: paperless/settings/__init__.py:597
#: paperless/settings/__init__.py:608
msgid "Russian"
msgstr ""
#: paperless/settings/__init__.py:598
#: paperless/settings/__init__.py:609
msgid "Slovak"
msgstr ""
#: paperless/settings/__init__.py:599
#: paperless/settings/__init__.py:610
msgid "Slovenian"
msgstr ""
#: paperless/settings/__init__.py:600
#: paperless/settings/__init__.py:611
msgid "Serbian"
msgstr ""
#: paperless/settings/__init__.py:601
#: paperless/settings/__init__.py:612
msgid "Swedish"
msgstr ""
#: paperless/settings/__init__.py:602
#: paperless/settings/__init__.py:613
msgid "Turkish"
msgstr ""
#: paperless/settings/__init__.py:603
#: paperless/settings/__init__.py:614
msgid "Ukrainian"
msgstr ""
#: paperless/settings/__init__.py:604
#: paperless/settings/__init__.py:615
msgid "Vietnamese"
msgstr ""
#: paperless/settings/__init__.py:605
#: paperless/settings/__init__.py:616
msgid "Chinese Simplified"
msgstr ""
#: paperless/settings/__init__.py:606
#: paperless/settings/__init__.py:617
msgid "Chinese Traditional"
msgstr ""
+1 -23
View File
@@ -1,7 +1,6 @@
import os
from celery import Celery
from celery.contrib.django.task import DjangoTask
from celery.signals import worker_process_init
from kombu.serialization import register
@@ -27,28 +26,7 @@ register(
content_encoding="binary",
)
class FileMoveScopedTask(DjangoTask):
"""
Run every task inside a document file move scope, so each document a task
changes is moved once when the task ends, after its writes have committed.
The scope also flushes when the task raises, for writes that committed
before the failure.
Subclasses DjangoTask because a custom task_cls replaces the Django fixup's
default, and delay_on_commit lives there.
"""
def __call__(self, *args, **kwargs):
# Function-local: this module is imported by paperless/__init__.py
# before Django apps are loaded.
from documents.file_moves import defer_file_moves
with defer_file_moves():
return super().__call__(*args, **kwargs)
app = Celery("paperless", task_cls=FileMoveScopedTask)
app = Celery("paperless")
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
+11
View File
@@ -12,6 +12,17 @@ from paperless.models import ModeChoices
from paperless.models import OutputTypeChoices
from paperless.models import RemoteOCRMode
# OCRmyPDF arguments the parser sets itself, pointing into its own temporary
# directory. The parser reads these files back after OCR, so user supplied
# values can only break consumption (or write to arbitrary paths), never help.
OCR_RESERVED_USER_ARGS: frozenset[str] = frozenset(
{
"input_file_or_options",
"output_file",
"sidecar",
},
)
@dataclasses.dataclass
class BaseConfig:
+15
View File
@@ -45,12 +45,21 @@ class StatusConsumer(AsyncWebsocketConsumer):
await self.close()
return
await self.channel_layer.group_add("status_updates", self.channel_name)
await self.channel_layer.group_add(
f"bulk_operations_{self.scope['user'].id}",
self.channel_name,
)
await self.accept()
self._start_heartbeat()
async def disconnect(self, code: int) -> None:
await self._stop_heartbeat()
await self.channel_layer.group_discard("status_updates", self.channel_name)
if self._authenticated():
await self.channel_layer.group_discard(
f"bulk_operations_{self.scope['user'].id}",
self.channel_name,
)
def _start_heartbeat(self) -> None:
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
@@ -84,3 +93,9 @@ class StatusConsumer(AsyncWebsocketConsumer):
await self.close()
elif await self._can_view(event["data"]):
await self.send(json.dumps(event))
async def bulk_operation_status(self, event: dict) -> None:
if not self._authenticated():
await self.close()
else:
await self.send(json.dumps(event))
-34
View File
@@ -1,5 +1,3 @@
from asgiref.sync import iscoroutinefunction
from asgiref.sync import markcoroutinefunction
from compression_middleware.middleware import CompressionMiddleware
from django.conf import settings
@@ -32,35 +30,3 @@ class ApiVersionMiddleware:
response["X-Version"] = version.__full_version_str__
return response
class FileMoveMiddleware:
"""
Open a document file move scope around each request, so every document the
request changes is moved once, after its writes have committed, before the
response is returned.
"""
sync_capable = True
async_capable = True
def __init__(self, get_response) -> None:
self.get_response = get_response
self.async_mode = iscoroutinefunction(get_response)
if self.async_mode:
markcoroutinefunction(self)
def __call__(self, request):
if self.async_mode:
return self.__acall__(request)
from documents.file_moves import defer_file_moves
with defer_file_moves():
return self.get_response(request)
async def __acall__(self, request):
from documents.file_moves import adefer_file_moves
async with adefer_file_moves():
return await self.get_response(request)
+22 -1
View File
@@ -17,10 +17,12 @@ from django.conf import settings
from PIL import Image
from documents.parsers import ParseError
from documents.parsers import encode_thumbnail_webp
from documents.parsers import make_thumbnail_from_pdf
from documents.utils import copy_file_with_basic_stats
from documents.utils import maybe_override_pixel_limit
from documents.utils import remove_alpha_channel
from paperless.config import OCR_RESERVED_USER_ARGS
from paperless.config import OcrConfig
from paperless.models import CleanChoices
from paperless.models import ModeChoices
@@ -161,6 +163,10 @@ class RasterisedDocumentParser:
# ------------------------------------------------------------------
def get_thumbnail(self, document_path: Path, mime_type: str) -> Path:
if self.archive_path is None and self.is_image(mime_type):
thumbnail_path = self.tempdir / "convert.webp"
encode_thumbnail_webp(Path(document_path), thumbnail_path)
return thumbnail_path
return make_thumbnail_from_pdf(
self.archive_path or Path(document_path),
self.tempdir,
@@ -351,7 +357,22 @@ class RasterisedDocumentParser:
if self.settings.user_args is not None:
try:
ocrmypdf_args = {**ocrmypdf_args, **self.settings.user_args}
reserved = sorted(
OCR_RESERVED_USER_ARGS & self.settings.user_args.keys(),
)
if reserved:
self.log.warning(
f"Ignoring OCR user arguments managed by paperless: "
f"{', '.join(reserved)}",
)
ocrmypdf_args = {
**ocrmypdf_args,
**{
k: v
for k, v in self.settings.user_args.items()
if k not in OCR_RESERVED_USER_ARGS
},
}
except Exception as e:
self.log.warning(
f"There is an issue with PAPERLESS_OCR_USER_ARGS, so "
+8
View File
@@ -19,6 +19,7 @@ from PIL import Image
from rest_framework import serializers
from rest_framework.authtoken.serializers import AuthTokenSerializer
from paperless.config import OCR_RESERVED_USER_ARGS
from paperless.models import ApplicationConfiguration
from paperless.network import validate_outbound_http_url
from paperless.validators import reject_dangerous_svg
@@ -251,6 +252,13 @@ class ApplicationConfigurationSerializer(
def validate_user_args(self, value):
self._require_json_object("user_args", value)
if value is not None:
reserved = sorted(OCR_RESERVED_USER_ARGS & value.keys())
if reserved:
raise serializers.ValidationError(
f"user_args may not set arguments managed by paperless: "
f"{', '.join(reserved)}.",
)
return value
def validate_barcode_tag_mapping(self, value):
+19 -9
View File
@@ -188,7 +188,6 @@ if DEBUG:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"paperless.middleware.FileMoveMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"corsheaders.middleware.CorsMiddleware",
@@ -451,23 +450,34 @@ CORS_EXPOSE_HEADERS = [
"Content-Disposition",
]
ALLOWED_HOSTS = get_list_from_env("PAPERLESS_ALLOWED_HOSTS", default=["*"])
if ALLOWED_HOSTS != ["*"]:
# always allow localhost. Necessary e.g. for healthcheck in docker.
ALLOWED_HOSTS.append("localhost")
def _get_allowed_hosts() -> list[str]:
if "PAPERLESS_ALLOWED_HOSTS" in os.environ or os.getenv("PAPERLESS_URL"):
allowed_hosts = get_list_from_env("PAPERLESS_ALLOWED_HOSTS")
else:
allowed_hosts = ["*"]
if allowed_hosts != ["*"]:
# Always allow localhost for the Docker health check.
allowed_hosts.append("localhost")
return allowed_hosts
ALLOWED_HOSTS = _get_allowed_hosts()
def _parse_paperless_url():
global CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, ALLOWED_HOSTS
url = os.getenv("PAPERLESS_URL")
if url:
hostname = urlparse(url).hostname
if not hostname:
hostname = (urlparse(url).hostname or "").removesuffix(".")
if not hostname or hostname == "*" or hostname.startswith("."):
raise ImproperlyConfigured(
f"PAPERLESS_URL {url!r} has no hostname, expected a URL like https://example.com",
f"PAPERLESS_URL {url!r} has no valid hostname, expected a URL like https://example.com",
)
CSRF_TRUSTED_ORIGINS.append(url)
CORS_ALLOWED_ORIGINS.append(url)
if ":" in hostname and not hostname.startswith("["):
hostname = f"[{hostname}]" # Add brackets around IPv6 addresses
ALLOWED_HOSTS.append(hostname)
return url
@@ -691,7 +701,7 @@ logging.config.dictConfig(LOGGING)
# https://docs.celeryq.dev/en/stable/userguide/configuration.html
# Tasks outside tasks.py must be loaded even when Django skips checks
CELERY_IMPORTS = ("documents.bulk_edit",)
CELERY_IMPORTS = ("documents.bulk_edit", "documents.bulk_operations")
CELERY_BROKER_URL = _CELERY_REDIS_URL
CELERY_RESULT_BACKEND = _CELERY_REDIS_URL
+11
View File
@@ -165,6 +165,17 @@ def parse_beat_schedule() -> dict:
"expires": 23.0 * 60.0 * 60.0,
},
},
{
"name": "Clear expired sessions",
"env_key": "PAPERLESS_SESSION_CLEANUP_TASK_CRON",
# Default daily at 03:00
"env_default": "0 3 * * *",
"task": "documents.tasks.clear_expired_sessions",
"options": {
# 1 hour before default schedule sends again
"expires": 23.0 * 60.0 * 60.0,
},
},
]
for task in tasks:
# Either get the environment setting or use the default
@@ -243,6 +243,34 @@ class TestParserSettingsFromDb(DirectoriesMixin, FileSystemAssertsMixin, TestCas
"UseDeviceIndependentColor",
)
def test_ocr_user_args_cannot_override_reserved(self) -> None:
"""
GIVEN:
- User arguments which attempt to override arguments the parser
manages itself, alongside a legitimate argument
WHEN:
- OCR parameters are constructed
THEN:
- Reserved arguments keep the parser supplied values
- The legitimate argument is still applied
"""
instance = ApplicationConfiguration.objects.all().first()
assert instance is not None
instance.user_args = {
"sidecar": "/tmp/evil.py",
"output_file": "/tmp/out.pdf",
"input_file_or_options": "/etc/passwd",
"unpaper_args": "--pre-rotate 90",
}
instance.save()
params = self.get_params()
self.assertEqual(params["sidecar"], "sidecar.txt")
self.assertEqual(params["output_file"], "output.pdf")
self.assertEqual(params["input_file_or_options"], "input.pdf")
self.assertEqual(params["unpaper_args"], "--pre-rotate 90")
def test_ocr_user_args(self) -> None:
"""
GIVEN:
@@ -18,6 +18,7 @@ from ocrmypdf import SubprocessOutputError
from PIL import Image
from documents.parsers import ParseError
from documents.parsers import get_default_thumbnail
from documents.parsers import rasterize_pdf_page_to_png
from paperless.models import ModeChoices
from paperless.parsers import ParserProtocol
@@ -270,6 +271,57 @@ class TestDpiHelpers:
class TestGetThumbnail:
@pytest.mark.parametrize(
("filename", "mime_type"),
[
pytest.param("simple.jpg", "image/jpeg", id="jpeg"),
pytest.param("simple.png", "image/png", id="png"),
pytest.param("simple.tif", "image/tiff", id="tiff"),
pytest.param("simple.gif", "image/gif", id="gif"),
pytest.param("simple.bmp", "image/bmp", id="bmp"),
pytest.param("document.webp", "image/webp", id="webp"),
pytest.param("simple.heic", "image/heic", id="heic"),
],
)
def test_image_without_archive_has_image_thumbnail(
self,
mocker: MockerFixture,
tesseract_parser: RasterisedDocumentParser,
tesseract_samples_dir: Path,
filename: str,
mime_type: str,
) -> None:
original = tesseract_samples_dir / filename
render_pdf = mocker.patch("documents.parsers.rasterize_pdf_page_to_png")
thumb = tesseract_parser.get_thumbnail(original, mime_type)
render_pdf.assert_not_called()
assert thumb.read_bytes() != get_default_thumbnail().read_bytes()
with Image.open(thumb) as im:
assert im.format == "WEBP"
assert 0 < im.width <= 500
assert 0 < im.height <= 5000
def test_image_with_archive_uses_pdf_thumbnail(
self,
mocker: MockerFixture,
tesseract_parser: RasterisedDocumentParser,
tesseract_samples_dir: Path,
simple_digital_pdf_file: Path,
) -> None:
tesseract_parser.archive_path = simple_digital_pdf_file
target = "paperless.parsers.tesseract.make_thumbnail_from_pdf"
make_thumbnail = mocker.patch(target)
original = tesseract_samples_dir / "simple.jpg"
tesseract_parser.get_thumbnail(original, "image/jpeg")
make_thumbnail.assert_called_once_with(
simple_digital_pdf_file,
tesseract_parser.tempdir,
)
def test_thumbnail_is_file(
self,
tesseract_parser: RasterisedDocumentParser,
@@ -182,6 +182,7 @@ def make_expected_schedule(
workflow_expire = 59.0 * 60.0
llm_index_expire = 23.0 * 60.0 * 60.0
share_link_cleanup_expire = 23.0 * 60.0 * 60.0
session_cleanup_expire = 23.0 * 60.0 * 60.0
schedule: dict[str, Any] = {
"Check all e-mail accounts": {
@@ -248,6 +249,14 @@ def make_expected_schedule(
"headers": {"trigger_source": "scheduled"},
},
},
"Clear expired sessions": {
"task": "documents.tasks.clear_expired_sessions",
"schedule": crontab(minute=0, hour="3"),
"options": {
"expires": session_cleanup_expire,
"headers": {"trigger_source": "scheduled"},
},
},
}
overrides = overrides or {}
@@ -298,6 +307,7 @@ class TestParseBeatSchedule:
"PAPERLESS_WORKFLOW_SCHEDULED_TASK_CRON": "disable",
"PAPERLESS_LLM_INDEX_TASK_CRON": "disable",
"PAPERLESS_SHARE_LINK_BUNDLE_CLEANUP_CRON": "disable",
"PAPERLESS_SESSION_CLEANUP_TASK_CRON": "disable",
},
{},
id="all-disabled",
+89 -4
View File
@@ -3,9 +3,14 @@ from unittest import TestCase
from unittest import mock
import pytest
from django.core.exceptions import DisallowedHost
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest
from django.test import override_settings
import paperless.settings as paperless_settings
from paperless.settings import _get_allauth_trusted_proxy_count
from paperless.settings import _get_allowed_hosts
from paperless.settings import _get_classifier_language_setting
from paperless.settings import _get_search_language_setting
from paperless.settings import _parse_paperless_url
@@ -168,8 +173,88 @@ class TestPaperlessURLSettings(TestCase):
self.assertIn(url, settings.CORS_ALLOWED_ORIGINS)
@pytest.mark.parametrize("url", ["example.com", "https://", "/just/a/path"])
def test_paperless_url_without_hostname_is_rejected(
@pytest.mark.parametrize(
("url", "allowed_hosts", "expected", "accepted_host", "rejected_host"),
[
(None, None, ["*"], "other.example.com", None),
(None, "", ["localhost"], "localhost:8000", "other.example.com"),
(
None,
"lan.example.com",
["lan.example.com", "localhost"],
"lan.example.com",
"other.example.com",
),
(None, "*", ["*"], "other.example.com", None),
(
"https://example.com:8443",
None,
["localhost", "example.com"],
"example.com:8443",
"other.example.com",
),
(
"https://example.com.",
"",
["localhost", "example.com"],
"example.com.",
"other.example.com",
),
(
"http://[::1]:8000",
None,
["localhost", "[::1]"],
"[::1]:8000",
"other.example.com",
),
("https://example.com", "*", ["*", "example.com"], "other.example.com", None),
(
"https://example.com",
"lan.example.com",
["lan.example.com", "localhost", "example.com"],
"lan.example.com",
"other.example.com",
),
],
)
def test_allowed_hosts_at_startup(
monkeypatch: pytest.MonkeyPatch,
url: str | None,
allowed_hosts: str | None,
expected: list[str],
accepted_host: str,
rejected_host: str | None,
) -> None:
for name, value in (
("PAPERLESS_URL", url),
("PAPERLESS_ALLOWED_HOSTS", allowed_hosts),
):
if value is None:
monkeypatch.delenv(name, raising=False)
else:
monkeypatch.setenv(name, value)
monkeypatch.setattr(paperless_settings, "ALLOWED_HOSTS", _get_allowed_hosts())
monkeypatch.setattr(paperless_settings, "CSRF_TRUSTED_ORIGINS", [])
monkeypatch.setattr(paperless_settings, "CORS_ALLOWED_ORIGINS", [])
assert _parse_paperless_url() == url
assert expected == paperless_settings.ALLOWED_HOSTS
request = HttpRequest()
with override_settings(ALLOWED_HOSTS=expected):
request.META["HTTP_HOST"] = accepted_host
assert request.get_host() == accepted_host
if rejected_host:
request.META["HTTP_HOST"] = rejected_host
with pytest.raises(DisallowedHost):
request.get_host()
@pytest.mark.parametrize(
"url",
["example.com", "https://", "/just/a/path", "https://*", "https://.example.com"],
)
def test_paperless_url_without_valid_hostname_is_rejected(
monkeypatch: pytest.MonkeyPatch,
url: str,
) -> None:
@@ -179,9 +264,9 @@ def test_paperless_url_without_hostname_is_rejected(
WHEN:
- The URL is parsed
THEN:
- A configuration error is raised instead of adding None to ALLOWED_HOSTS
- A configuration error is raised for missing or wildcard hostnames
"""
monkeypatch.setenv("PAPERLESS_URL", url)
with pytest.raises(ImproperlyConfigured, match="no hostname"):
with pytest.raises(ImproperlyConfigured, match="no valid hostname"):
_parse_paperless_url()
+26 -8
View File
@@ -46,6 +46,16 @@ def _build_document_reference(
}
def _permitted_documents_filter(store, permitted_ids: set[int]):
"""Scope retrieval to ``permitted_ids`` using whichever of an inclusion or
exclusion list is shorter, since a long inclusion list is much slower for
the vector store than a long exclusion list."""
denied_ids = store.indexed_document_ids() - permitted_ids
if len(permitted_ids) <= len(denied_ids):
return document_id_filters(str(pk) for pk in permitted_ids)
return exclude_document_ids_filter(str(pk) for pk in denied_ids)
def _get_document_references(
documents: QuerySet[Document],
top_nodes: list,
@@ -128,24 +138,23 @@ def _stream_chat_with_documents(
from llama_index.core.retrievers import VectorIndexRetriever
config = AIConfig()
permitted_ids: set[int] | None = None
if unrestricted:
# Exclude trashed ids (usually few) instead of an IN filter over the
# full permitted set, which risks the vector store's bound parameter
# limit (_MAX_IN_VALUES) on large installs. Trashed documents stay
# indexed until permanent deletion (delete_document_from_llm_index
# hangs off post_delete, not trash), so must be excluded explicitly.
# Exclude trashed ids (usually few) rather than send every permitted
# id on each query. Trashed documents stay indexed until permanent
# deletion (post_delete, not trash), so must be excluded explicitly.
trashed_ids = Document.deleted_objects.values_list("pk", flat=True)
filters = exclude_document_ids_filter(str(pk) for pk in trashed_ids)
else:
filters = document_id_filters(
str(pk) for pk in documents.values_list("pk", flat=True)
)
permitted_ids = set(documents.values_list("pk", flat=True))
# Hold the shared read lock only while reading the index, so the compaction
# swap cannot replace the database file while this connection is open.
# Everything after retrieval works on the nodes already in memory, so a slow
# LLM stream never holds the lock.
with read_store() as store:
if permitted_ids is not None:
filters = _permitted_documents_filter(store, permitted_ids)
index = load_or_build_index(config, store)
retriever = VectorIndexRetriever(
index=index,
@@ -159,6 +168,15 @@ def _stream_chat_with_documents(
with db_connection_released():
top_nodes = retriever.retrieve(query_str)
if permitted_ids is not None:
# An exclusion filter fails open for a document indexed after the
# permitted set was read, so drop anything not explicitly permitted.
top_nodes = [
node
for node in top_nodes
if int(node.metadata["document_id"]) in permitted_ids
]
if not top_nodes:
logger.warning("No nodes found for the given documents.")
yield CHAT_NO_CONTENT_MESSAGE
+20 -5
View File
@@ -1,4 +1,5 @@
import re
from contextlib import suppress
from typing import TYPE_CHECKING
from django.conf import settings
@@ -20,7 +21,17 @@ OCR_LEADER_REGEX = re.compile(r"[._\-\u00b7]{4,}")
HORIZONTAL_WHITESPACE_REGEX = re.compile(r"[ \t\u00a0]+")
def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
def get_embedding_model(
config: AIConfig,
*,
refresh: bool = False,
) -> "BaseEmbedding":
"""
Build the embedding model for the configured backend.
For Hugging Face, a cached model loads offline by default, saving seconds per
call. Pass ``refresh=True`` to check the Hub for a newer model revision.
"""
match config.llm_embedding_backend:
case LLMEmbeddingBackend.OPENAI_LIKE:
from llama_index.embeddings.openai_like import OpenAILikeEmbedding
@@ -52,11 +63,15 @@ def get_embedding_model(config: AIConfig) -> "BaseEmbedding":
case LLMEmbeddingBackend.HUGGINGFACE:
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
return HuggingFaceEmbedding(
model_name=config.llm_embedding_model
hf_kwargs = {
"model_name": config.llm_embedding_model
or "sentence-transformers/all-MiniLM-L6-v2",
cache_folder=str(settings.DATA_DIR / "hf_cache"),
)
"cache_folder": str(settings.DATA_DIR / "hf_cache"),
}
if not refresh:
with suppress(OSError):
return HuggingFaceEmbedding(**hf_kwargs, local_files_only=True)
return HuggingFaceEmbedding(**hf_kwargs)
case LLMEmbeddingBackend.OLLAMA:
from llama_index.embeddings.ollama import OllamaEmbedding
from ollama import AsyncClient
+1 -1
View File
@@ -470,7 +470,7 @@ def update_llm_index(
logger.warning("No documents found to index.")
chunk_size = config.llm_embedding_chunk_size
embed_model = get_embedding_model(config)
embed_model = get_embedding_model(config, refresh=rebuild)
with write_store(embed_model_name=model_name) as store:
if rebuild or not store.table_exists():
+9
View File
@@ -141,6 +141,15 @@ class DocumentMetaTable:
(DocumentMetaRow(r["document_id"], r["modified"]) for r in batch),
)
@staticmethod
def all_document_ids(conn: sqlite3.Connection) -> set[int]:
"""Every indexed document_id. document_meta has one row per document,
so this is a cheap primary-key scan, unlike reading the vec0 table."""
return {
row["document_id"]
for row in conn.execute("SELECT document_id FROM document_meta")
}
@staticmethod
def all_modified_times(conn: sqlite3.Connection) -> dict[str, str]:
"""Full document_id -> modified map, for get_modified_times()'s
@@ -212,6 +212,10 @@ def test_update_llm_index(
ai_config.assert_called_once()
build_document_node.assert_called_once_with(real_document, chunk_size=512)
indexing.get_embedding_model.assert_called_once_with(
mock_config,
refresh=True,
)
@pytest.mark.django_db
@@ -336,6 +340,8 @@ def test_update_llm_index_partial_update(
indexing.update_llm_index(rebuild=False)
assert indexing.get_embedding_model.call_args.kwargs == {"refresh": False}
with indexing.get_vector_store() as store:
assert store.table_exists(), (
"Expected the vector store table to exist after incremental update"
+67 -4
View File
@@ -13,6 +13,7 @@ from django.utils import timezone
from llama_index.core import settings as llama_settings
from llama_index.core.embeddings.mock_embed_model import MockEmbedding
from llama_index.core.schema import TextNode
from llama_index.core.vector_stores.types import FilterOperator
from documents.models import Document
from paperless_ai import chat
@@ -412,10 +413,8 @@ class TestStreamChatRetrieval:
WHEN:
- stream_chat_with_documents is called with unrestricted=True
THEN:
- The retriever receives a NOT IN filter excluding zero ids, so
the whole index is effectively searched -- and no IN-list is
built from the full permitted set, which is what risks the
vector store's safety limit on large installs
- The retriever receives a NOT IN filter excluding zero ids
rather than an IN list of every permitted id
"""
document = DocumentFactory.create(content="indexed document content")
indexing.llm_index_add_or_update_document(document)
@@ -468,6 +467,70 @@ class TestStreamChatRetrieval:
assert str(trashed.pk) in filter_values
assert str(kept.pk) not in filter_values
def test_restricted_chat_excludes_denied_ids_when_few_are_denied(
self,
temp_llm_index_dir: Path,
mock_embed_model: pytest_mock.MockType,
captured_filters: list[Any],
) -> None:
"""
GIVEN:
- Three indexed documents, one of which the caller may not see
WHEN:
- A restricted caller chats
THEN:
- The retriever receives a NOT IN filter on the denied id, not an
IN list of the permitted ids
"""
docs = DocumentFactory.create_batch(3, content="indexed document content")
for doc in docs:
indexing.llm_index_add_or_update_document(doc)
permitted = Document.objects.filter(pk__in=[docs[0].pk, docs[1].pk])
list(chat.stream_chat_with_documents("question?", permitted))
metadata_filter = captured_filters[0].filters[0]
assert metadata_filter.operator == FilterOperator.NIN
assert metadata_filter.value == [str(docs[2].pk)]
def test_restricted_chat_drops_retrieved_nodes_of_unpermitted_documents(
self,
temp_llm_index_dir: Path,
mock_embed_model: pytest_mock.MockType,
mocker: pytest_mock.MockerFixture,
) -> None:
"""
GIVEN:
- A retriever that returns a node for a document the caller may
not see, as when it is indexed after the permitted set was read
WHEN:
- A restricted caller chats
THEN:
- Only the node of the permitted document reaches the synthesizer
"""
permitted_doc = DocumentFactory.create(content="permitted content")
denied_doc = DocumentFactory.create(content="denied content")
permitted_node = MagicMock(metadata={"document_id": str(permitted_doc.pk)})
denied_node = MagicMock(metadata={"document_id": str(denied_doc.pk)})
mocker.patch("paperless_ai.chat.AIClient")
retriever = mocker.patch(
"llama_index.core.retrievers.VectorIndexRetriever",
).return_value
retriever.retrieve.return_value = [denied_node, permitted_node]
synthesizer = mocker.patch(
"llama_index.core.response_synthesizers.get_response_synthesizer",
).return_value
synthesizer.synthesize.return_value.response_gen = iter(["chunk"])
list(
chat.stream_chat_with_documents(
"question?",
Document.objects.filter(pk=permitted_doc.pk),
),
)
synthesizer.synthesize.assert_called_once_with("question?", [permitted_node])
@pytest.mark.django_db
def test_get_document_references_only_queries_referenced_documents(
self,
+36
View File
@@ -1,5 +1,6 @@
from unittest.mock import ANY
from unittest.mock import MagicMock
from unittest.mock import call
from unittest.mock import patch
import pytest
@@ -167,10 +168,45 @@ def test_get_embedding_model_huggingface(mock_ai_config):
MockHuggingFaceEmbedding.assert_called_once_with(
model_name="sentence-transformers/all-MiniLM-L6-v2",
cache_folder=str(settings.DATA_DIR / "hf_cache"),
local_files_only=True,
)
assert model == MockHuggingFaceEmbedding.return_value
def test_get_embedding_model_huggingface_downloads_when_not_cached(mock_ai_config):
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.HUGGINGFACE
mock_ai_config.return_value.llm_embedding_model = "some/model"
downloaded = MagicMock()
with patch(
"llama_index.embeddings.huggingface.HuggingFaceEmbedding",
side_effect=[OSError("not cached"), downloaded],
) as MockHuggingFaceEmbedding:
model = get_embedding_model(mock_ai_config.return_value)
cache_folder = str(settings.DATA_DIR / "hf_cache")
assert MockHuggingFaceEmbedding.call_args_list == [
call(model_name="some/model", cache_folder=cache_folder, local_files_only=True),
call(model_name="some/model", cache_folder=cache_folder),
]
assert model is downloaded
def test_get_embedding_model_huggingface_refresh_checks_the_hub(mock_ai_config):
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.HUGGINGFACE
mock_ai_config.return_value.llm_embedding_model = "some/model"
with patch(
"llama_index.embeddings.huggingface.HuggingFaceEmbedding",
) as MockHuggingFaceEmbedding:
get_embedding_model(mock_ai_config.return_value, refresh=True)
MockHuggingFaceEmbedding.assert_called_once_with(
model_name="some/model",
cache_folder=str(settings.DATA_DIR / "hf_cache"),
)
def test_get_embedding_model_ollama(mock_ai_config):
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OLLAMA
mock_ai_config.return_value.llm_embedding_model = "embeddinggemma"
+23 -21
View File
@@ -18,7 +18,7 @@ from paperless_ai.migrations import Migration
from paperless_ai.migrations import m0001_v1_to_v2
from paperless_ai.tables import DocumentChunksTable
from paperless_ai.tables import DocumentMetaTable
from paperless_ai.vector_store import _MAX_IN_VALUES
from paperless_ai.vector_store import _MAX_INLINE_IN_VALUES
from paperless_ai.vector_store import DB_FILENAME
from paperless_ai.vector_store import DB_PAGE_SIZE
from paperless_ai.vector_store import DEFAULT_TABLE_NAME
@@ -354,37 +354,39 @@ class TestBuildWhere:
assert params == []
@pytest.mark.parametrize(
"build_filter",
[_in_filter, _nin_filter],
ids=["in", "nin"],
("build_filter", "expected_ids"),
[
pytest.param(_in_filter, ["a", "c"], id="in"),
pytest.param(_nin_filter, ["b"], id="nin"),
],
)
def test_fails_closed_when_filter_exceeds_max_values(
def test_query_with_oversized_filter_scopes_results(
self,
store: PaperlessSqliteVecVectorStore,
build_filter: Callable[[list[str]], MetadataFilters],
caplog: pytest.LogCaptureFixture,
expected_ids: list[str],
) -> None:
"""
GIVEN:
- An IN or NOT IN filter with more values than _MAX_IN_VALUES
(SQLite's own bound-parameter limit is 32766; this guard sits
below that with headroom for the query's other bound parameters)
- A store holding documents 1, 2 and 40000
- An IN or NOT IN filter naming documents 1 and 40000, padded
past _MAX_INLINE_IN_VALUES with unindexed ids
WHEN:
- _build_where() translates it to SQL
- The store is queried and get_nodes() is called with that filter
THEN:
- It fails closed ("1 = 0", no params) instead of building a
clause SQLite would reject, and logs a warning -- this filter
scopes document access, so refusing to build it must never
widen the scope to "everything" by accident. Failing open on
a NOT IN would surface exactly the excluded rows
- Both return exactly the documents the filter allows
"""
oversized = build_filter([str(i) for i in range(_MAX_IN_VALUES + 1)])
store.add(
[make_node("a", 1), make_node("b", 2), make_node("c", 40000)],
)
padding = range(100, 100 + _MAX_INLINE_IN_VALUES)
filters = build_filter([str(i) for i in (1, 40000, *padding)])
with caplog.at_level("WARNING"):
where, params = _build_where(oversized)
result = _query(store, [0.0] * DIM, top_k=5, filters=filters)
nodes = store.get_nodes(filters=filters)
assert where == "(1 = 0)"
assert params == []
assert "document_id" in caplog.text
assert sorted(result.ids) == expected_ids
assert sorted(n.node_id for n in nodes) == expected_ids
def test_query_with_untranslatable_filter_returns_no_rows(
self,
+16 -25
View File
@@ -89,15 +89,10 @@ class _Row(NamedTuple):
embedding: bytes
# _build_where(): the largest IN value list translated into bound SQL
# parameters. SQLite's own hard limit (SQLITE_MAX_VARIABLE_NUMBER) is 32766
# by default; this leaves headroom below that for the query's other bound
# parameters (the embedding blob, k, and any NE clause) and for the limit
# itself to move. An IN filter this large should not happen in practice --
# callers are expected to pass None (no filter) rather than every id when
# the filter would not actually narrow anything -- so this is a guard
# against a future regression, not a normal code path.
_MAX_IN_VALUES = 32700
# _build_where(): the largest IN / NOT IN list bound as one parameter per value,
# leaving headroom under SQLite's 32766-variable limit for the query's other
# parameters. Longer lists are bound as a single JSON array via json_each().
_MAX_INLINE_IN_VALUES = 32700
def _pack(embedding: Sequence[float]) -> bytes:
@@ -120,7 +115,7 @@ def _vec0_params(rows: list[_Row]) -> list[tuple[str, int, str, bytes]]:
return [(r.chunk_id, r.document_id, r.node_content, r.embedding) for r in rows]
def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int | str]]:
"""Translate the EQ / IN / NIN / NE filters we use into a parameterized
SQL clause on vec0 metadata columns. Returns ("", []) when there is
nothing to filter. document_id is vec0's only filterable column and is
@@ -132,7 +127,7 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
if filters is None or not filters.filters:
return "", []
clauses: list[str] = []
params: list[int] = []
params: list[int | str] = []
for f in filters.filters:
# filters.filters is Union[MetadataFilter, ExactMatchFilter, MetadataFilters];
# we only build MetadataFilter entries, so skip anything else at runtime.
@@ -149,21 +144,11 @@ def _build_where(filters: MetadataFilters | None) -> tuple[str, list[int]]:
# excludes nothing, so it matches everything.
clauses.append("1 = 0" if is_in else "1 = 1")
continue
if len(values) > _MAX_IN_VALUES:
# Refuse rather than risk SQLite's own bound-parameter limit
# ("too many SQL variables"): a list this large must match no
# rows, never widen the scope to "everything" -- true for
# NOT IN too, where failing open would surface every
# excluded row.
logger.warning(
"Refusing to build a %s filter on %r with %d values "
"(over the %d-value safety limit); returning no rows.",
sql_op,
f.key,
len(values),
_MAX_IN_VALUES,
if len(values) > _MAX_INLINE_IN_VALUES:
clauses.append(
f"{f.key} {sql_op} (SELECT value FROM json_each(?))",
)
clauses.append("1 = 0")
params.append(json.dumps(values))
continue
placeholders = ",".join("?" for _ in values)
clauses.append(f"{f.key} {sql_op} ({placeholders})")
@@ -558,6 +543,12 @@ class PaperlessSqliteVecVectorStore(BasePydanticVectorStore):
return {}
return DocumentMetaTable.all_modified_times(self._conn)
def indexed_document_ids(self) -> set[int]:
"""Return the ids of all indexed documents."""
if not self.table_exists():
return set()
return DocumentMetaTable.all_document_ids(self._conn)
@property
def _db_path(self) -> str:
return str(Path(self._uri) / DB_FILENAME)
+7 -1
View File
@@ -1159,7 +1159,13 @@ class MailAccountHandler(LoggingMixin):
new_headers += message.obj._headers
message.obj._headers = new_headers
f.write(message.obj.as_bytes())
# RFC 5322 requires CRLF; the default compat32 policy writes bare LF,
# which Outlook misreads as literal "=" in quoted-printable soft breaks
f.write(
message.obj.as_bytes(
policy=message.obj.policy.clone(linesep="\r\n"),
),
)
correspondent = self._get_correspondent(message, rule)
-18
View File
@@ -8,8 +8,6 @@ import factory
from django.contrib.auth.models import User
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import DocumentBarcode
from documents.models import DocumentType
@@ -72,22 +70,6 @@ class DocumentFactory(TypedModelFactory[Document]):
storage_path = None
class CustomFieldFactory(TypedModelFactory[CustomField]):
class Meta:
model = CustomField
name = factory.Sequence(lambda n: f"field {n}")
data_type = CustomField.FieldDataType.STRING
class CustomFieldInstanceFactory(TypedModelFactory[CustomFieldInstance]):
class Meta:
model = CustomFieldInstance
field = factory.SubFactory(CustomFieldFactory)
document = factory.SubFactory(DocumentFactory)
class DocumentBarcodeFactory(TypedModelFactory[DocumentBarcode]):
class Meta:
model = DocumentBarcode
-47
View File
@@ -1,47 +0,0 @@
"""Record first acquisitions of the global media lock during a test."""
from __future__ import annotations
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from unittest import mock
if TYPE_CHECKING:
from collections.abc import Iterator
@dataclass(frozen=True, slots=True)
class MediaLockAcquisition:
"""One first (non-reentrant) acquisition of settings.MEDIA_LOCK."""
in_user_transaction: bool
@contextmanager
def record_media_lock_acquisitions() -> Iterator[list[MediaLockAcquisition]]:
"""
Record every first acquisition of settings.MEDIA_LOCK made in the block.
Reentrant acquisitions by a thread that already holds the lock never wait,
so they are not recorded. Each record notes whether a real (non test case)
transaction was open at that moment, which is the lock ordering hazard.
"""
from django.conf import settings
from filelock import BaseFileLock
from documents.file_moves import in_user_transaction
records: list[MediaLockAcquisition] = []
original_acquire = BaseFileLock.acquire
def recording_acquire(self: BaseFileLock, *args: object, **kwargs: object):
if Path(self.lock_file) == Path(settings.MEDIA_LOCK) and not self.is_locked:
records.append(
MediaLockAcquisition(in_user_transaction=in_user_transaction()),
)
return original_acquire(self, *args, **kwargs)
with mock.patch.object(BaseFileLock, "acquire", recording_acquire):
yield records