mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-12 17:20:48 +00:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e4936ee83 | ||
|
|
119a779629 | ||
|
|
a1d76f3a19 | ||
|
|
f5ca9bbcb6 | ||
|
|
60a35edee1 | ||
|
|
7cdcd71a6d | ||
|
|
759f9cd22b | ||
|
|
d05e96cda2 | ||
|
|
fefce7be1b | ||
|
|
cc65226d6d | ||
|
|
00096e0252 | ||
|
|
2829e78009 | ||
|
|
d7d4014bbd | ||
|
|
a139bdff46 |
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>"<x id="PH" equiv-text="items[0].name"/>"</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">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>"<x id="PH" equiv-text="items[0].name"/>" and "<x id="PH_1" equiv-text="items[1].name"/>"</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">511</context>
|
||||
<context context-type="linenumber">533</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">This is for messages like 'modify "tag1" and "tag2"'</note>
|
||||
</trans-unit>
|
||||
@@ -9632,7 +9674,7 @@
|
||||
<source><x id="PH" equiv-text="list"/> and "<x id="PH_1" equiv-text="items[items.length - 1].name"/>"</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">519,521</context>
|
||||
<context context-type="linenumber">541,543</context>
|
||||
</context-group>
|
||||
<note priority="1" from="description">this is for messages like 'modify "tag1", "tag2" and "tag3"'</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 "<x id="PH" equiv-text="tag.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">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 "<x id="PH" equiv-text="tag.name"/>" from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">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 "<x id="PH" equiv-text="correspondent.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">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 "<x id="PH" equiv-text="documentType.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">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 "<x id="PH" equiv-text="storagePath.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">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 "<x id="PH" equiv-text="customField.name"/>" to <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">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 "<x id="PH" equiv-text="customField.name"/>" from <x id="PH_1" equiv-text="this.getSelectionSize()"/> selected document(s).</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
<context context-type="linenumber">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()
|
||||
|
||||
|
||||
+111
-60
@@ -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.`,
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
+7
-3
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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 "
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user