mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-14 08:32:19 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cb5d87247 | ||
|
|
dc292d7ede | ||
|
|
c7fcdbed61 | ||
|
|
d2197bf05e | ||
|
|
cf11735a2b | ||
|
|
871711d394 | ||
|
|
823d1a7835 | ||
|
|
2813eed184 | ||
|
|
05426daee0 | ||
|
|
b11078fd92 | ||
|
|
f0e52756ea | ||
|
|
00301c503a | ||
|
|
e746e008a0 | ||
|
|
897f6a9e5a | ||
|
|
694619c33e | ||
|
|
1f2faaec86 | ||
|
|
6b15d625ed | ||
|
|
b63ea77543 | ||
|
|
bfd5628dcc | ||
|
|
3dbeca603e | ||
|
|
62d729e301 | ||
|
|
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.
|
||||
|
||||
|
||||
@@ -288,6 +288,12 @@ The API supports various bulk-editing operations which are executed asynchronous
|
||||
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
|
||||
a json payload of the format:
|
||||
|
||||
Document bulk operations, including the dedicated document action endpoints, return
|
||||
`{"result":"OK","task_id":"…"}` when queued. Use `/api/tasks/?task_id={uuid}`
|
||||
to check the task outcome. Actions that create or reprocess files may start further
|
||||
consumption or reprocessing tasks and completion of the bulk task means those tasks
|
||||
were scheduled, not that their file processing has finished.
|
||||
|
||||
```json
|
||||
{
|
||||
"documents": [LIST_OF_DOCUMENT_IDS],
|
||||
|
||||
+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:
|
||||
|
||||
|
||||
+31
-5
@@ -493,7 +493,7 @@ fields and permissions, which will be merged.
|
||||
|
||||
#### Types {#workflow-trigger-types}
|
||||
|
||||
Currently, there are four events that correspond to workflow trigger 'types':
|
||||
Currently, there are five events that correspond to workflow trigger 'types':
|
||||
|
||||
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
||||
folder or API), file path, file name, mail rule
|
||||
@@ -505,8 +505,10 @@ Currently, there are four events that correspond to workflow trigger 'types':
|
||||
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
|
||||
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
|
||||
offsets will trigger after the date, negative offsets will trigger before).
|
||||
5. **Manual**: a trigger that only runs when you explicitly choose to run the workflow against one or more existing documents. See
|
||||
[running a workflow manually](#running-a-workflow-manually) below.
|
||||
|
||||
The following flow diagram illustrates the four document trigger types:
|
||||
The following flow diagram illustrates the document trigger types:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -526,6 +528,10 @@ flowchart TD
|
||||
matching
|
||||
trigger(s)"}
|
||||
|
||||
manual{"Matching
|
||||
'Manual'
|
||||
trigger(s)"}
|
||||
|
||||
A[New Document] --> consumption
|
||||
consumption --> |Yes| C[Workflow Actions Run]
|
||||
consumption --> |No| D
|
||||
@@ -543,6 +549,10 @@ flowchart TD
|
||||
scheduled --> |Yes| N[Workflow Actions Run]
|
||||
scheduled --> |No| O[Document Saved]
|
||||
N --> O
|
||||
P[User Runs Workflow] --> manual
|
||||
manual --> |Yes| Q[Workflow Actions Run]
|
||||
manual --> |No| R[Document Saved]
|
||||
Q --> R
|
||||
```
|
||||
|
||||
#### Filters {#workflow-trigger-filters}
|
||||
@@ -554,9 +564,9 @@ Workflows allow you to filter by:
|
||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||
example, automatically assigning documents to different owners based on the upload directory.
|
||||
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
||||
- Content matching (`Added`, `Updated`, `Scheduled` and `Manual` triggers only). Filter document content using the matching settings.
|
||||
|
||||
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
|
||||
There are also 'advanced' filters available for `Added`, `Updated`, `Scheduled` and `Manual` triggers:
|
||||
|
||||
- Any Tags: Filter for documents with any of the specified tags.
|
||||
- All Tags: Filter for documents with all of the specified tags.
|
||||
@@ -569,6 +579,22 @@ There are also 'advanced' filters available for `Added`, `Updated` and `Schedule
|
||||
- Not Storage paths: Filter documents without any of these storage paths.
|
||||
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
|
||||
|
||||
#### Running a workflow manually {#running-a-workflow-manually}
|
||||
|
||||
A workflow with a **Manual** trigger does not run automatically. Instead, it can be run on demand against existing
|
||||
documents from the web UI:
|
||||
|
||||
- From the document list, select one or more documents and choose **Run workflow** from the "Actions" menu.
|
||||
- From an individual document, choose **Run workflow** from the "Actions" menu.
|
||||
|
||||
You will be asked which workflow to run. From the document list, the workflow's trigger filters apply by default, so
|
||||
documents that do not match are skipped. You can choose to **run on all selected documents** to ignore the filters.
|
||||
From an individual document, the option to ignore trigger filters is selected by default. Clear it to run the workflow
|
||||
only if the document matches its trigger filters.
|
||||
|
||||
Manual runs happen in the background. As with scheduled workflows, the document list is not refreshed automatically when
|
||||
they finish, so you may need to reload the list to see the results.
|
||||
|
||||
### Workflow Actions
|
||||
|
||||
#### Types {#workflow-action-types}
|
||||
@@ -649,7 +675,7 @@ new version of the same document, this time with normal OCR/text extraction appl
|
||||
a password-protected file added with this trigger will briefly exist as an un-OCR'd version before
|
||||
the properly processed version is created.
|
||||
|
||||
**Document Added**, **Document Updated**, **Scheduled**: these triggers run against a document that
|
||||
**Document Added**, **Document Updated**, **Scheduled**, **Manual**: these triggers run against a document that
|
||||
already exists, so password removal happens immediately: the decrypted content is queued for
|
||||
consumption as a new version right away. Note that if the document's initial consumption also
|
||||
happened while it was still encrypted, that original version will likewise be missing OCR text.
|
||||
|
||||
+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",
|
||||
|
||||
+459
-287
File diff suppressed because it is too large.
Load diff
@@ -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'
|
||||
@@ -32,6 +35,7 @@ import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { TrashService } from 'src/app/services/trash.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
@@ -95,6 +99,7 @@ describe('AppFrameComponent', () => {
|
||||
let savedViewSpy
|
||||
let modalService: NgbModal
|
||||
let maybeRefreshSpy
|
||||
let bulkEvents: Subject<BulkOperationStatus>
|
||||
let tasksService: TasksService
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -182,6 +187,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 +199,97 @@ 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('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(
|
||||
'Operation failed. Some changes may already have been applied.',
|
||||
'Could not update'
|
||||
)
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'Operation revoked.',
|
||||
'Task expired before execution.'
|
||||
)
|
||||
expect(success).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers the delete success toast to TrashService', () => {
|
||||
const success = jest.spyOn(toastService, 'showSuccess')
|
||||
const trashService = TestBed.inject(TrashService)
|
||||
const handleStatus = jest.spyOn(trashService, 'handleDeleteTaskStatus')
|
||||
bulkEvents.next({
|
||||
task_id: 'delete-1',
|
||||
operation: 'delete',
|
||||
status: PaperlessTaskStatus.Success,
|
||||
})
|
||||
expect(handleStatus).toHaveBeenCalledWith(
|
||||
'delete-1',
|
||||
PaperlessTaskStatus.Success
|
||||
)
|
||||
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,
|
||||
@@ -55,6 +59,7 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { TrashService } from 'src/app/services/trash.service'
|
||||
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
@@ -101,6 +106,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)
|
||||
@@ -109,6 +116,7 @@ export class AppFrameComponent
|
||||
settingsService = inject(SettingsService)
|
||||
tasksService = inject(TasksService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly trashService = inject(TrashService)
|
||||
private modalService = inject(NgbModal)
|
||||
permissionsService = inject(PermissionsService)
|
||||
private djangoMessagesService = inject(DjangoMessagesService)
|
||||
@@ -164,6 +172,49 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.websocketStatusService
|
||||
.onBulkOperationStatus()
|
||||
.pipe(
|
||||
filter(({ status }) =>
|
||||
[
|
||||
PaperlessTaskStatus.Success,
|
||||
PaperlessTaskStatus.Failure,
|
||||
PaperlessTaskStatus.Revoked,
|
||||
].includes(status)
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(({ task_id, operation, 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 (operation === 'delete') {
|
||||
this.trashService.handleDeleteTaskStatus(task_id, status)
|
||||
}
|
||||
if (status === PaperlessTaskStatus.Success && operation !== 'delete') {
|
||||
this.toastService.showSuccess($localize`Operation completed.`)
|
||||
} else if (status === PaperlessTaskStatus.Revoked) {
|
||||
this.toastService.showError($localize`Operation revoked.`, error)
|
||||
} else if (status === PaperlessTaskStatus.Failure) {
|
||||
this.toastService.showError(
|
||||
$localize`Operation failed. Some changes may already have been applied.`,
|
||||
error
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
this.lastScrollY = window.scrollY
|
||||
this.detectClassicScrollbars()
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ import personFill from 'bootstrap-icons/icons/person-fill.svg'
|
||||
import personLock from 'bootstrap-icons/icons/person-lock.svg'
|
||||
import personSquare from 'bootstrap-icons/icons/person-square.svg'
|
||||
import person from 'bootstrap-icons/icons/person.svg'
|
||||
import playCircle from 'bootstrap-icons/icons/play-circle.svg'
|
||||
import playFill from 'bootstrap-icons/icons/play-fill.svg'
|
||||
import plusCircle from 'bootstrap-icons/icons/plus-circle.svg'
|
||||
import plus from 'bootstrap-icons/icons/plus.svg'
|
||||
@@ -279,6 +280,7 @@ export const icons: Record<string, string> = {
|
||||
personFillLock,
|
||||
personLock,
|
||||
personSquare,
|
||||
playCircle,
|
||||
playFill,
|
||||
plus,
|
||||
plusCircle,
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (message) {
|
||||
<p class="text-break" [innerHTML]="message"></p>
|
||||
}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="runWorkflowSelect" i18n>Workflow</label>
|
||||
<select class="form-select" id="runWorkflowSelect" [(ngModel)]="workflowId" (ngModelChange)="workflowSelected()">
|
||||
<option [ngValue]="null" disabled i18n>Select a workflow</option>
|
||||
@for (workflow of workflows; track workflow.id) {
|
||||
<option [ngValue]="workflow.id">{{workflow.name}}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="runWorkflowForce" [(ngModel)]="force" />
|
||||
@if (singleDocument) {
|
||||
<label class="form-check-label" for="runWorkflowForce" i18n>Run on this document regardless of trigger filters</label>
|
||||
<div class="form-text" i18n>Ignore the workflow's trigger filters for this document.</div>
|
||||
} @else {
|
||||
<label class="form-check-label" for="runWorkflowForce" i18n>Run on all selected documents</label>
|
||||
<div class="form-text" i18n>Ignore the workflow's trigger filters and run on every selected document.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||
{{btnCaption}}
|
||||
</button>
|
||||
</div>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { RunWorkflowConfirmDialogComponent } from './run-workflow-confirm-dialog.component'
|
||||
|
||||
describe('RunWorkflowConfirmDialogComponent', () => {
|
||||
let component: RunWorkflowConfirmDialogComponent
|
||||
let fixture: ComponentFixture<RunWorkflowConfirmDialogComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [NgbActiveModal],
|
||||
imports: [RunWorkflowConfirmDialogComponent],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(RunWorkflowConfirmDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
component.workflows = [{ id: 5, name: 'Manual workflow' } as any]
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should disable confirm until a workflow is selected and not force by default', () => {
|
||||
expect(component.confirmButtonEnabled).toBeFalsy()
|
||||
expect(component.force).toBeFalsy()
|
||||
|
||||
component.workflowId = 5
|
||||
component.workflowSelected()
|
||||
expect(component.confirmButtonEnabled).toBeTruthy()
|
||||
|
||||
component.workflowId = null
|
||||
component.workflowSelected()
|
||||
expect(component.confirmButtonEnabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should render an option per workflow', () => {
|
||||
const options = fixture.nativeElement.querySelectorAll(
|
||||
'#runWorkflowSelect option'
|
||||
)
|
||||
// placeholder option + one per workflow
|
||||
expect(options).toHaveLength(2)
|
||||
expect(options[1].textContent).toContain('Manual workflow')
|
||||
})
|
||||
|
||||
it('should describe the checkbox for a selection or a single document', async () => {
|
||||
const checkbox = fixture.nativeElement.querySelector('#runWorkflowForce')
|
||||
expect(checkbox.checked).toBe(false)
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Run on all selected documents'
|
||||
)
|
||||
|
||||
component.singleDocument = true
|
||||
component.force = true
|
||||
fixture.detectChanges()
|
||||
await fixture.whenStable()
|
||||
fixture.detectChanges()
|
||||
expect(checkbox.checked).toBe(true)
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Run on this document regardless of trigger filters'
|
||||
)
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
'Run on all selected documents'
|
||||
)
|
||||
})
|
||||
})
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-run-workflow-confirm-dialog',
|
||||
templateUrl: './run-workflow-confirm-dialog.component.html',
|
||||
imports: [FormsModule],
|
||||
})
|
||||
export class RunWorkflowConfirmDialogComponent extends ConfirmDialogComponent {
|
||||
public workflows: Workflow[] = []
|
||||
|
||||
public workflowId: number = null
|
||||
|
||||
public singleDocument: boolean = false
|
||||
|
||||
// i.e. ignore the workflow's trigger filters
|
||||
public force: boolean = false
|
||||
|
||||
override confirmButtonEnabled = false
|
||||
|
||||
public workflowSelected(): void {
|
||||
this.confirmButtonEnabled = !!this.workflowId
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -164,7 +164,7 @@
|
||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules()" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||
}
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.Manual) {
|
||||
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||
@if (matchingPatternRequired(formGroup)) {
|
||||
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||
@@ -175,7 +175,7 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.Manual) {
|
||||
<div class="row mt-3">
|
||||
<div class="col">
|
||||
<div class="trigger-filters mb-3">
|
||||
|
||||
+4
@@ -123,6 +123,10 @@ export const WORKFLOW_TYPE_OPTIONS = [
|
||||
id: WorkflowTriggerType.Scheduled,
|
||||
name: $localize`Scheduled`,
|
||||
},
|
||||
{
|
||||
id: WorkflowTriggerType.Manual,
|
||||
name: $localize`Manual`,
|
||||
},
|
||||
]
|
||||
|
||||
export const WORKFLOW_ACTION_OPTIONS = [
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
<pngx-bs-icon width="1em" height="1em" name="arrow-counterclockwise" class="me-2"></pngx-bs-icon><span i18n>Reprocess</span>
|
||||
</button>
|
||||
|
||||
@if (manualWorkflows().length > 0) {
|
||||
<button ngbDropdownItem (click)="runWorkflow()" [disabled]="!userCanEdit || !userIsOwner">
|
||||
<pngx-bs-icon width="1em" height="1em" name="play-circle" class="me-2"></pngx-bs-icon><span i18n>Run workflow</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
|
||||
<pngx-bs-icon width="1em" height="1em" name="printer" class="me-2"></pngx-bs-icon><span i18n>Print</span>
|
||||
</button>
|
||||
|
||||
@@ -1013,27 +1013,77 @@ describe('DocumentDetailComponent', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should support reprocess, confirm and close modal after started', () => {
|
||||
it('should queue reprocess, show the task link and close the modal', () => {
|
||||
initNormally()
|
||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||
reprocessSpy.mockReturnValue(of(true))
|
||||
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-1' }))
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
component.reprocess()
|
||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||
openModal.componentInstance.confirmClicked.next()
|
||||
expect(reprocessSpy).toHaveBeenCalledWith({ documents: [doc.id] }, false)
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: 'Document reprocessing queued.',
|
||||
actionName: 'View task',
|
||||
})
|
||||
)
|
||||
toastSpy.mock.calls.at(-1)[0].action()
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/tasks'], {
|
||||
queryParams: { task_id: 'reprocess-1' },
|
||||
})
|
||||
expect(modalCloseSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should run a workflow on the document, confirm and close modal', () => {
|
||||
initNormally()
|
||||
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||
bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-1' }))
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
component.runWorkflow()
|
||||
expect(openModal.componentInstance.singleDocument).toBe(true)
|
||||
expect(openModal.componentInstance.force).toBe(true)
|
||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||
openModal.componentInstance.workflowId = 5
|
||||
openModal.componentInstance.confirmClicked.next()
|
||||
expect(bulkEditSpy).toHaveBeenCalledWith(
|
||||
{ documents: [doc.id] },
|
||||
'run_workflow',
|
||||
{ workflow: 5, filter_documents: false }
|
||||
)
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'Workflow queued.' })
|
||||
)
|
||||
expect(modalCloseSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply workflow filters when the single-document checkbox is cleared', () => {
|
||||
initNormally()
|
||||
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
|
||||
bulkEditSpy.mockReturnValue(of({ result: 'OK', task_id: 'workflow-2' }))
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
component.runWorkflow()
|
||||
openModal.componentInstance.workflowId = 5
|
||||
openModal.componentInstance.force = false
|
||||
openModal.componentInstance.confirmClicked.next()
|
||||
expect(bulkEditSpy).toHaveBeenCalledWith(
|
||||
{ documents: [doc.id] },
|
||||
'run_workflow',
|
||||
{ workflow: 5, filter_documents: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass remote OCR choice when reprocessing', () => {
|
||||
initNormally()
|
||||
const reprocessSpy = jest.spyOn(documentService, 'reprocessDocuments')
|
||||
reprocessSpy.mockReturnValue(of(true))
|
||||
reprocessSpy.mockReturnValue(of({ result: 'OK', task_id: 'reprocess-2' }))
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
component.reprocess()
|
||||
@@ -1966,6 +2016,7 @@ describe('DocumentDetailComponent', () => {
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
const queuedToastSpy = jest.spyOn(toastService, 'show')
|
||||
initNormally()
|
||||
component.selectedVersionId.set(10)
|
||||
component.editPdf()
|
||||
@@ -1984,6 +2035,7 @@ describe('DocumentDetailComponent', () => {
|
||||
update_document: false,
|
||||
include_metadata: true,
|
||||
source_mode: 'explicit_selection',
|
||||
from_webui: true,
|
||||
})
|
||||
req.error(new ErrorEvent('failed'))
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
@@ -1996,13 +2048,18 @@ describe('DocumentDetailComponent', () => {
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/edit_pdf/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
req.flush({ result: 'OK', task_id: 'edit-pdf-1' })
|
||||
expect(closeSpy).not.toHaveBeenCalled()
|
||||
expect(queuedToastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'PDF edit queued.' })
|
||||
)
|
||||
})
|
||||
|
||||
it('should support removing password protection from pdfs', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
|
||||
const queuedToastSpy = jest.spyOn(toastService, 'show')
|
||||
initNormally()
|
||||
component.selectedVersionId.set(10)
|
||||
component.password = 'secret'
|
||||
@@ -2023,8 +2080,13 @@ describe('DocumentDetailComponent', () => {
|
||||
include_metadata: false,
|
||||
delete_original: true,
|
||||
source_mode: 'explicit_selection',
|
||||
from_webui: true,
|
||||
})
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'remove-password-1' })
|
||||
expect(closeSpy).not.toHaveBeenCalled()
|
||||
expect(queuedToastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'Password removal queued.' })
|
||||
)
|
||||
})
|
||||
|
||||
it('should require the current password before removing it', () => {
|
||||
@@ -2076,9 +2138,9 @@ describe('DocumentDetailComponent', () => {
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/remove_password/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'remove-password-2' })
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledWith(doc.id)
|
||||
expect(refreshSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support keyboard shortcuts', () => {
|
||||
|
||||
@@ -68,6 +68,8 @@ import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
@@ -93,6 +95,7 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { TrashService } from 'src/app/services/trash.service'
|
||||
@@ -104,6 +107,7 @@ import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||
import { RunWorkflowConfirmDialogComponent } from '../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component'
|
||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
@@ -233,6 +237,7 @@ export class DocumentDetailComponent
|
||||
private componentRouterService = inject(ComponentRouterService)
|
||||
private deviceDetectorService = inject(DeviceDetectorService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly workflowService = inject(WorkflowService)
|
||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
|
||||
@@ -295,6 +300,7 @@ export class DocumentDetailComponent
|
||||
readonly correspondents = signal<Correspondent[]>(undefined)
|
||||
readonly documentTypes = signal<DocumentType[]>(undefined)
|
||||
readonly storagePaths = signal<StoragePath[]>(undefined)
|
||||
readonly manualWorkflows = signal<Workflow[]>([])
|
||||
|
||||
documentForm: FormGroup = new FormGroup({
|
||||
title: new FormControl(''),
|
||||
@@ -836,6 +842,20 @@ export class DocumentDetailComponent
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => this.users.set(result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Workflow
|
||||
)
|
||||
) {
|
||||
this.workflowService
|
||||
.list(1, 100000, null, null, {
|
||||
trigger_type: WorkflowTriggerType.Manual,
|
||||
enabled: true,
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result) => this.manualWorkflows.set(result.results))
|
||||
}
|
||||
|
||||
this.getCustomFields()
|
||||
|
||||
@@ -1538,9 +1558,10 @@ export class DocumentDetailComponent
|
||||
modal.componentInstance.remoteOcr
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Reprocess operation for "${this.document().title}" will begin in the background.`
|
||||
next: ({ task_id }) => {
|
||||
this.notifyTaskQueued(
|
||||
task_id,
|
||||
$localize`Document reprocessing queued.`
|
||||
)
|
||||
if (modal) {
|
||||
modal.close()
|
||||
@@ -1559,6 +1580,59 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
runWorkflow() {
|
||||
const modal = this.modalService.open(RunWorkflowConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Run workflow`
|
||||
modal.componentInstance.message = $localize`Run a workflow on "${this.document().title}".`
|
||||
modal.componentInstance.btnCaption = $localize`Run`
|
||||
modal.componentInstance.workflows = this.manualWorkflows()
|
||||
modal.componentInstance.singleDocument = true
|
||||
modal.componentInstance.force = true
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
this.documentsService
|
||||
.bulkEdit({ documents: [this.document().id] }, 'run_workflow', {
|
||||
workflow: modal.componentInstance.workflowId,
|
||||
filter_documents: !modal.componentInstance.force,
|
||||
})
|
||||
.subscribe({
|
||||
next: ({ task_id }) => {
|
||||
this.notifyTaskQueued(task_id, $localize`Workflow queued.`)
|
||||
modal.close()
|
||||
},
|
||||
error: (error) => {
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
this.toastService.showError(
|
||||
$localize`Error executing operation`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private notifyTaskQueued(taskId: string, content: string) {
|
||||
const canViewTasks = this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.PaperlessTask
|
||||
)
|
||||
this.toastService.show({
|
||||
content,
|
||||
delay: 5000,
|
||||
...(canViewTasks
|
||||
? {
|
||||
actionName: $localize`View task`,
|
||||
action: () =>
|
||||
this.router.navigate(['/tasks'], {
|
||||
queryParams: { task_id: taskId },
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
private getSelectedNonLatestVersionId(): number | null {
|
||||
const versions = this.document()?.versions ?? []
|
||||
if (!versions.length || !this.selectedVersionId()) {
|
||||
@@ -1934,14 +2008,9 @@ export class DocumentDetailComponent
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`PDF edit operation for "${this.document().title}" will begin in the background.`
|
||||
)
|
||||
next: ({ task_id }) => {
|
||||
this.notifyTaskQueued(task_id, $localize`PDF edit queued.`)
|
||||
modal.close()
|
||||
if (modal.componentInstance.deleteOriginal) {
|
||||
this.openDocumentService.closeDocument(this.document())
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
if (modal) {
|
||||
@@ -1991,17 +2060,13 @@ export class DocumentDetailComponent
|
||||
})
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Password removal operation for "${this.document().title}" will begin in the background.`
|
||||
next: ({ task_id }) => {
|
||||
this.notifyTaskQueued(
|
||||
task_id,
|
||||
$localize`Password removal queued.`
|
||||
)
|
||||
this.networkActive.set(false)
|
||||
modal.close()
|
||||
if (!dialog.updateDocument && dialog.deleteOriginal) {
|
||||
this.openDocumentService.closeDocument(this.document())
|
||||
} else if (dialog.updateDocument) {
|
||||
this.openDocumentService.refreshDocument(this.documentId())
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
dialog.buttonsEnabled.set(true)
|
||||
|
||||
+20
-14
@@ -2,6 +2,7 @@ import { DatePipe } from '@angular/common'
|
||||
import { SimpleChange, signal } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||
@@ -33,7 +34,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
>
|
||||
>
|
||||
let toastService: jest.Mocked<
|
||||
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
|
||||
Pick<ToastService, 'show' | 'showError' | 'showInfo' | 'showSuccess'>
|
||||
>
|
||||
let finished$: Subject<{ taskId: string }>
|
||||
let failed$: Subject<{ taskId: string; message?: string }>
|
||||
@@ -54,6 +55,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
}
|
||||
modalService = { open: jest.fn() }
|
||||
toastService = {
|
||||
show: jest.fn(),
|
||||
showError: jest.fn(),
|
||||
showInfo: jest.fn(),
|
||||
showSuccess: jest.fn(),
|
||||
@@ -88,6 +90,10 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
provide: PermissionsService,
|
||||
useValue: permissionsService,
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { navigate: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: WebsocketStatusService,
|
||||
useValue: {
|
||||
@@ -377,7 +383,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
expect(component.versionLabelDraft).toEqual('')
|
||||
})
|
||||
|
||||
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
|
||||
it('addExistingDocumentAsVersion should queue with a label and wait to refresh versions', () => {
|
||||
const confirmClicked = new Subject<number>()
|
||||
const modal = {
|
||||
componentInstance: {
|
||||
@@ -388,14 +394,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
close: jest.fn(),
|
||||
}
|
||||
modalService.open.mockReturnValue(modal as any)
|
||||
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
|
||||
// Newest first, as the API returns them. The merged document has a lower id
|
||||
// than the root, which is the whole point of merging an existing document.
|
||||
const versions: DocumentVersionInfo[] = [
|
||||
{ id: 2, is_root: false, checksum: 'cccc' },
|
||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||
]
|
||||
documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any))
|
||||
documentService.mergeDocumentsAsVersions.mockReturnValue(
|
||||
of({ result: 'OK', task_id: 'versions-1' })
|
||||
)
|
||||
component.newVersionLabel = ' Imported '
|
||||
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
|
||||
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
|
||||
@@ -410,11 +411,16 @@ describe('DocumentVersionDropdownComponent', () => {
|
||||
'Imported'
|
||||
)
|
||||
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
|
||||
expect(documentService.getVersions).toHaveBeenCalledWith(3)
|
||||
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
||||
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
|
||||
expect(documentService.getVersions).not.toHaveBeenCalled()
|
||||
expect(versionsEmitSpy).not.toHaveBeenCalled()
|
||||
expect(selectedEmitSpy).not.toHaveBeenCalled()
|
||||
expect(component.newVersionLabel).toEqual('')
|
||||
expect(modal.close).toHaveBeenCalled()
|
||||
expect(toastService.showSuccess).toHaveBeenCalled()
|
||||
expect(toastService.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: 'Existing document queued to be added as a version.',
|
||||
actionName: 'View task',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
+25
-9
@@ -11,6 +11,7 @@ import {
|
||||
SimpleChanges,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { merge, of, Subject } from 'rxjs'
|
||||
import {
|
||||
@@ -27,6 +28,11 @@ import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon
|
||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import {
|
||||
@@ -77,6 +83,8 @@ export class DocumentVersionDropdownComponent
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||
private readonly modalService = inject(NgbModal)
|
||||
private readonly router = inject(Router)
|
||||
private readonly permissionsService = inject(PermissionsService)
|
||||
private readonly destroy$ = new Subject<void>()
|
||||
private readonly documentChange$ = new Subject<void>()
|
||||
|
||||
@@ -305,24 +313,32 @@ export class DocumentVersionDropdownComponent
|
||||
versionLabel
|
||||
)
|
||||
.pipe(
|
||||
switchMap(() => this.documentsService.getVersions(this.documentId)),
|
||||
first(),
|
||||
finalize(() => dialog.buttonsEnabled.set(true)),
|
||||
takeUntil(this.destroy$),
|
||||
takeUntil(this.documentChange$)
|
||||
)
|
||||
.subscribe({
|
||||
next: (document) => {
|
||||
if (document?.versions?.length) {
|
||||
this.versionsUpdated.emit(document.versions)
|
||||
// The API returns versions newest first
|
||||
this.versionSelected.emit(document.versions[0].id)
|
||||
}
|
||||
next: ({ task_id }) => {
|
||||
this.newVersionLabel = ''
|
||||
modal.close()
|
||||
this.toastService.showSuccess(
|
||||
$localize`Existing document added as a version.`
|
||||
const canViewTasks = this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.PaperlessTask
|
||||
)
|
||||
this.toastService.show({
|
||||
content: $localize`Existing document queued to be added as a version.`,
|
||||
delay: 5000,
|
||||
...(canViewTasks
|
||||
? {
|
||||
actionName: $localize`View task`,
|
||||
action: () =>
|
||||
this.router.navigate(['/tasks'], {
|
||||
queryParams: { task_id },
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
|
||||
@@ -98,6 +98,11 @@
|
||||
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
||||
<pngx-bs-icon name="journal-bookmark-fill" class="me-2"></pngx-bs-icon><ng-container i18n>Merge as versions</ng-container>
|
||||
</button>
|
||||
@if (manualWorkflows.length > 0) {
|
||||
<button ngbDropdownItem (click)="runWorkflowSelected()" [disabled]="!canRunWorkflow">
|
||||
<pngx-bs-icon name="play-circle" class="me-2"></pngx-bs-icon><ng-container i18n>Run workflow</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+195
-71
@@ -17,6 +17,7 @@ import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
|
||||
import { Results } from 'src/app/data/results'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import {
|
||||
@@ -36,6 +37,7 @@ import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { TrashService } from 'src/app/services/trash.service'
|
||||
@@ -83,6 +85,7 @@ describe('BulkEditorComponent', () => {
|
||||
let customFieldsService: CustomFieldsService
|
||||
let httpTestingController: HttpTestingController
|
||||
let shareLinkBundleService: ShareLinkBundleService
|
||||
let workflowService: WorkflowService
|
||||
let router: Router
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -173,6 +176,22 @@ describe('BulkEditorComponent', () => {
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkflowService,
|
||||
useValue: {
|
||||
list: jest.fn(() =>
|
||||
of({
|
||||
results: [
|
||||
{
|
||||
id: 5,
|
||||
name: 'Manual workflow',
|
||||
triggers: [{ type: WorkflowTriggerType.Manual }],
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||
@@ -195,6 +214,7 @@ describe('BulkEditorComponent', () => {
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
||||
workflowService = TestBed.inject(WorkflowService)
|
||||
router = TestBed.inject(Router)
|
||||
|
||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||
@@ -559,18 +579,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 +628,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,6 +666,53 @@ describe('BulkEditorComponent', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||
.flush(true)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
)
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
)
|
||||
})
|
||||
|
||||
it('should load manual workflows on init', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
expect(workflowService.list).toHaveBeenCalledWith(1, 100000, null, null, {
|
||||
trigger_type: WorkflowTriggerType.Manual,
|
||||
enabled: true,
|
||||
})
|
||||
expect(component.manualWorkflows).toHaveLength(1)
|
||||
expect(component.manualWorkflows[0].id).toEqual(5)
|
||||
})
|
||||
|
||||
it('should run a workflow on the selection via the run workflow dialog', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
fixture.detectChanges()
|
||||
component.runWorkflowSelected()
|
||||
expect(modal).not.toBeUndefined()
|
||||
expect(modal.componentInstance.workflows).toEqual(component.manualWorkflows)
|
||||
expect(modal.componentInstance.singleDocument).toBe(false)
|
||||
expect(modal.componentInstance.force).toBe(false)
|
||||
modal.componentInstance.workflowId = 5
|
||||
modal.componentInstance.force = true
|
||||
modal.componentInstance.confirm()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
expect(req.request.body.method).toEqual('run_workflow')
|
||||
expect(req.request.body.parameters).toEqual({
|
||||
workflow: 5,
|
||||
filter_documents: false,
|
||||
})
|
||||
req.flush(true)
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
@@ -729,18 +798,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 +837,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 +897,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 +936,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 +996,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 +1035,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 +1095,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 +1134,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({
|
||||
@@ -1166,14 +1239,15 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/delete/`
|
||||
)
|
||||
const notifySpy = jest
|
||||
.spyOn(trashService, 'notifyMovedToTrash')
|
||||
const trackSpy = jest
|
||||
.spyOn(trashService, 'trackMovedToTrash')
|
||||
.mockImplementation(() => {})
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'delete-1' })
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
from_webui: true,
|
||||
})
|
||||
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4])
|
||||
expect(trackSpy).toHaveBeenCalledWith('delete-1', 2, [3, 4])
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
@@ -1190,9 +1264,9 @@ describe('BulkEditorComponent', () => {
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/delete/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'delete-2' })
|
||||
// ids aren't known when all matching documents are selected, so no undo
|
||||
expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null)
|
||||
expect(trackSpy).toHaveBeenLastCalledWith('delete-2', 0, null)
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
) // list reload
|
||||
@@ -1272,10 +1346,11 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/reprocess/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'reprocess-1' })
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
remote_ocr: false,
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1306,11 +1381,12 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/rotate/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'rotate-1' })
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
degrees: 90,
|
||||
source_mode: 'latest_version',
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1348,10 +1424,11 @@ describe('BulkEditorComponent', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/merge/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'merge-1' })
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
metadata_document_id: 3,
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1366,11 +1443,12 @@ describe('BulkEditorComponent', () => {
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/merge/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'merge-2' })
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
metadata_document_id: 3,
|
||||
delete_originals: true,
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1387,11 +1465,12 @@ describe('BulkEditorComponent', () => {
|
||||
req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/merge/`
|
||||
)
|
||||
req.flush(true)
|
||||
req.flush({ result: 'OK', task_id: 'merge-3' })
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
metadata_document_id: 3,
|
||||
archive_fallback: true,
|
||||
from_webui: true,
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||
@@ -1430,8 +1509,8 @@ describe('BulkEditorComponent', () => {
|
||||
.mockReturnValue(true)
|
||||
const mergeAsVersionsSpy = jest
|
||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||
.mockReturnValue(of(true))
|
||||
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||
.mockReturnValue(of({ result: 'OK', task_id: 'versions-1' }))
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
fixture.detectChanges()
|
||||
|
||||
component.mergeSelectedAsVersions()
|
||||
@@ -1447,8 +1526,8 @@ describe('BulkEditorComponent', () => {
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
)
|
||||
expect(documentListViewService.selected.size).toEqual(0)
|
||||
expect(toastSuccessSpy).toHaveBeenCalledWith(
|
||||
'Documents merged as versions.'
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ content: 'Bulk operation queued.' })
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1540,6 +1619,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 +1692,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 +1702,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 +2008,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', () => {
|
||||
|
||||
@@ -19,24 +19,17 @@ import {
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { saveAs } from 'file-saver'
|
||||
import {
|
||||
first,
|
||||
map,
|
||||
Observable,
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
||||
import { BulkOperationResponse } from 'src/app/data/bulk-operation'
|
||||
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
import { SelectionDataItem } from 'src/app/data/results'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
@@ -51,8 +44,8 @@ import {
|
||||
DocumentService,
|
||||
MergeDocumentsRequest,
|
||||
} from 'src/app/services/rest/document.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
@@ -64,6 +57,7 @@ import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dial
|
||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { RunWorkflowConfirmDialogComponent } from '../../common/confirm-dialog/run-workflow-confirm-dialog/run-workflow-confirm-dialog.component'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
@@ -81,6 +75,7 @@ import { PermissionsDialogComponent } from '../../common/permissions-dialog/perm
|
||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||
import { WorkflowTriggerType } from 'src/app/data/workflow-trigger'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-bulk-editor',
|
||||
@@ -104,18 +99,19 @@ 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)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||
private readonly workflowService = inject(WorkflowService)
|
||||
private readonly router = inject(Router)
|
||||
|
||||
public manualWorkflows: Workflow[] = []
|
||||
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||
@@ -258,6 +254,20 @@ export class BulkEditorComponent
|
||||
(result) => (this.customFieldsSelectionModel.items = result.results)
|
||||
)
|
||||
}
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Workflow
|
||||
)
|
||||
) {
|
||||
this.workflowService
|
||||
.list(1, 100000, null, null, {
|
||||
trigger_type: WorkflowTriggerType.Manual,
|
||||
enabled: true,
|
||||
})
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.manualWorkflows = result.results))
|
||||
}
|
||||
|
||||
this.downloadForm
|
||||
.get('downloadFileTypeArchive')
|
||||
@@ -286,6 +296,28 @@ export class BulkEditorComponent
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
get canRunWorkflow(): boolean {
|
||||
return this.userCanEditAll && this.manualWorkflows.length > 0
|
||||
}
|
||||
|
||||
runWorkflowSelected() {
|
||||
const modal = this.modalService.open(RunWorkflowConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Run workflow`
|
||||
modal.componentInstance.message = $localize`Run a workflow on ${this.getSelectionSize()} selected document(s).`
|
||||
modal.componentInstance.btnCaption = $localize`Run`
|
||||
modal.componentInstance.workflows = this.manualWorkflows
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.executeBulkEditMethod(modal, 'run_workflow', {
|
||||
workflow: modal.componentInstance.workflowId,
|
||||
filter_documents: !modal.componentInstance.force,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private executeBulkEditMethod(
|
||||
modal: NgbModalRef,
|
||||
method: DocumentBulkEditMethod,
|
||||
@@ -299,48 +331,50 @@ 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>,
|
||||
options: { clearSelection?: boolean; successMessage?: string } = {}
|
||||
request: Observable<BulkOperationResponse>,
|
||||
onQueued?: (taskId: string) => void
|
||||
) {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled.set(false)
|
||||
}
|
||||
request.pipe(first()).subscribe({
|
||||
next: () => {
|
||||
this.handleOperationSuccess(modal, options.clearSelection ?? false)
|
||||
if (options.successMessage) {
|
||||
this.toastService.showSuccess(options.successMessage)
|
||||
}
|
||||
next: ({ task_id }) => {
|
||||
this.handleOperationQueued(modal, task_id)
|
||||
onQueued?.(task_id)
|
||||
},
|
||||
error: (error) => this.handleOperationError(modal, error),
|
||||
})
|
||||
}
|
||||
|
||||
private handleOperationSuccess(
|
||||
modal: NgbModalRef,
|
||||
clearSelection: boolean = false
|
||||
) {
|
||||
if (clearSelection) {
|
||||
this.list.selected.clear()
|
||||
}
|
||||
this.list.reload()
|
||||
this.list.reduceSelectionToFilter()
|
||||
this.list.selected.forEach((id) => {
|
||||
this.openDocumentService.refreshDocument(id)
|
||||
})
|
||||
this.savedViewService.maybeRefreshDocumentCounts()
|
||||
if (modal) {
|
||||
modal.close()
|
||||
}
|
||||
}
|
||||
|
||||
private handleOperationError(modal: NgbModalRef, error: any) {
|
||||
if (modal) {
|
||||
modal.componentInstance.buttonsEnabled.set(true)
|
||||
@@ -908,16 +942,8 @@ export class BulkEditorComponent
|
||||
const count = this.getSelectionSize()
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService
|
||||
.deleteDocuments(this.getSelectionQuery())
|
||||
.pipe(
|
||||
tap(() =>
|
||||
this.trashService.notifyMovedToTrash(
|
||||
$localize`Moved ${count} document(s) to the trash`,
|
||||
documents
|
||||
)
|
||||
)
|
||||
)
|
||||
this.documentService.deleteDocuments(this.getSelectionQuery()),
|
||||
(taskId) => this.trashService.trackMovedToTrash(taskId, count, documents)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1031,11 +1057,7 @@ export class BulkEditorComponent
|
||||
mergeDialog.buttonsEnabled.set(false)
|
||||
this.executeDocumentAction(
|
||||
modal,
|
||||
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
|
||||
{ clearSelection: !!args.delete_originals }
|
||||
)
|
||||
this.toastService.showInfo(
|
||||
$localize`Merged document will be queued for consumption.`
|
||||
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1060,11 +1082,7 @@ export class BulkEditorComponent
|
||||
this.documentService.mergeDocumentsAsVersions(
|
||||
mergeDialog.documentIDs(),
|
||||
mergeDialog.rootDocumentID()
|
||||
),
|
||||
{
|
||||
clearSelection: true,
|
||||
successMessage: $localize`Documents merged as versions.`,
|
||||
}
|
||||
)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1088,14 +1106,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)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,26 +2,47 @@
|
||||
<div>
|
||||
<form [formGroup]="noteForm" class="needs-validation mt-3" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Note }" novalidate>
|
||||
<div class="form-group">
|
||||
<textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" rows="3" formControlName="newNote" placeholder="Enter note" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea>
|
||||
<div class="invalid-feedback" i18n>
|
||||
Please enter a note.
|
||||
</div>
|
||||
<textarea class="form-control form-control-sm" [class.is-invalid]="newNoteError" [class.d-none]="noteView() !== NoteViewMode.Edit" [attr.aria-invalid]="newNoteError" aria-label="Note" i18n-aria-label aria-describedby="noteFeedback" rows="3" formControlName="newNote" placeholder="Enter note (Markdown is supported)" i18n-placeholder (keydown)="noteFormKeydown($event)" required></textarea>
|
||||
@if (noteView() === NoteViewMode.Preview) {
|
||||
<section class="form-control form-control-sm note-preview" aria-label="Note preview" i18n-aria-label>
|
||||
@if (noteForm.get('newNote').value) {
|
||||
<div class="note-markdown text-break text-wrap" [innerHTML]="noteForm.get('newNote').value | markdown"></div>
|
||||
} @else {
|
||||
<span class="text-muted" i18n>Nothing to preview</span>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
<div id="noteFeedback" class="invalid-feedback" i18n>
|
||||
Please enter a note.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group mt-2 d-flex justify-content-end align-items-center">
|
||||
<div class="form-group mt-2 d-flex justify-content-between align-items-center">
|
||||
@if (networkActive()) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal me-auto" role="status"></div>
|
||||
}
|
||||
<fieldset class="btn-group btn-group-sm" aria-label="Note input mode" i18n-aria-label>
|
||||
<input type="radio" class="btn-check" id="noteViewEdit" name="noteViewMode" [checked]="noteView() === NoteViewMode.Edit" (change)="noteView.set(NoteViewMode.Edit)">
|
||||
<label class="btn btn-outline-secondary" for="noteViewEdit" i18n>Edit</label>
|
||||
<input type="radio" class="btn-check" id="noteViewPreview" name="noteViewMode" [checked]="noteView() === NoteViewMode.Preview" (change)="noteView.set(NoteViewMode.Preview)">
|
||||
<label class="btn btn-outline-secondary" for="noteViewPreview" i18n>Preview</label>
|
||||
</fieldset>
|
||||
<button type="button" class="btn btn-primary btn-sm" [disabled]="networkActive() || addDisabled()" (click)="addNote()" i18n>Add note</button>
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
@for (note of notes(); track note) {
|
||||
@for (note of notes(); track note.id) {
|
||||
<div class="card border mb-3">
|
||||
<div class="card-body text-dark">
|
||||
<p class="card-text">{{note.note}}</p>
|
||||
@if (note.format === NoteFormat.Markdown) {
|
||||
<div class="card-text note-markdown text-break text-wrap" [innerHTML]="note.note | markdown"></div>
|
||||
} @else {
|
||||
<p class="card-text">{{note.note}}</p>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex card-footer small bg-light text-primary justify-content-between align-items-center">
|
||||
<span>{{displayName(note)}} - {{ note.created | customDate}}</span>
|
||||
<span>
|
||||
{{displayName(note)}} - {{ note.created | customDate}}
|
||||
</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 fade" title="Delete note" i18n-title (click)="deleteNote(note.id)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Note }">
|
||||
<pngx-bs-icon name="trash"></pngx-bs-icon>
|
||||
<span class="visually-hidden" i18n>Delete note</span>
|
||||
|
||||
@@ -5,3 +5,45 @@
|
||||
.card:hover .fade {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.note-preview {
|
||||
min-height: calc(1.5em * 3 + 0.5rem + var(--bs-border-width) * 2); // textfield height with 3 rows
|
||||
}
|
||||
|
||||
.note-markdown ::ng-deep {
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.25em; }
|
||||
h2 { font-size: 1.15em; }
|
||||
h3 { font-size: 1.05em; }
|
||||
|
||||
table {
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
border-radius: var(--bs-border-radius);
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 0.75rem;
|
||||
border-left: 0.25rem solid var(--bs-border-color);
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { DocumentNote } from 'src/app/data/document-note'
|
||||
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
@@ -129,6 +129,66 @@ describe('DocumentNotesComponent', () => {
|
||||
expect(fixture.debugElement.nativeElement.textContent).toContain('user3')
|
||||
})
|
||||
|
||||
it('should render notes according to their format', () => {
|
||||
component.notes.set([
|
||||
{ id: 31, note: '**x**', format: NoteFormat.Markdown },
|
||||
{
|
||||
id: 32,
|
||||
note: '<script>alert(1)</script>',
|
||||
format: NoteFormat.Markdown,
|
||||
},
|
||||
{ id: 33, note: '**x**', format: NoteFormat.Plain },
|
||||
])
|
||||
fixture.detectChanges()
|
||||
|
||||
const noteCards = fixture.debugElement.queryAll(By.css('.card-text'))
|
||||
expect(noteCards[0].query(By.css('strong')).nativeElement.textContent).toBe(
|
||||
'x'
|
||||
)
|
||||
expect(noteCards[1].query(By.css('script'))).toBeNull()
|
||||
expect(noteCards[2].query(By.css('strong'))).toBeNull()
|
||||
expect(noteCards[2].nativeElement.textContent).toBe('**x**')
|
||||
})
|
||||
|
||||
it('should toggle between edit and preview', () => {
|
||||
const radios = fixture.debugElement.queryAll(
|
||||
By.css('.btn-group input[type="radio"]')
|
||||
)
|
||||
radios[1].triggerEventHandler('change')
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.note-preview')).nativeElement
|
||||
.textContent
|
||||
).toContain('Nothing to preview')
|
||||
|
||||
component.noteForm.get('newNote').setValue('**bold**')
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.note-preview strong'))
|
||||
).not.toBeNull()
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('textarea')).nativeElement.classList
|
||||
).toContain('d-none')
|
||||
|
||||
radios[0].triggerEventHandler('change')
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.query(By.css('.note-preview'))).toBeNull()
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('textarea')).nativeElement.classList
|
||||
).not.toContain('d-none')
|
||||
})
|
||||
|
||||
it('should return to edit mode after saving', () => {
|
||||
component.documentId.set(12)
|
||||
component.noteForm.get('newNote').setValue('Preview note')
|
||||
fixture.debugElement
|
||||
.queryAll(By.css('.btn-group input[type="radio"]'))[1]
|
||||
.triggerEventHandler('change')
|
||||
jest.spyOn(notesService, 'addNote').mockReturnValueOnce(of(notes))
|
||||
component.addNote()
|
||||
expect(component.noteView()).toBe('edit')
|
||||
})
|
||||
|
||||
it('should handle note user display in all situations', () => {
|
||||
expect(
|
||||
component.displayName({
|
||||
@@ -161,7 +221,7 @@ describe('DocumentNotesComponent', () => {
|
||||
const addSpy = jest.spyOn(notesService, 'addNote')
|
||||
addSpy.mockReturnValueOnce(throwError(() => new Error('error saving note')))
|
||||
const toastsSpy = jest.spyOn(toastService, 'showError')
|
||||
const addButton = fixture.debugElement.query(By.css('button'))
|
||||
const addButton = fixture.debugElement.query(By.css('.btn-primary'))
|
||||
addButton.triggerEventHandler('click')
|
||||
expect(addSpy).toHaveBeenCalledWith(12, note)
|
||||
expect(toastsSpy).toHaveBeenCalled()
|
||||
@@ -190,7 +250,9 @@ describe('DocumentNotesComponent', () => {
|
||||
component.documentId.set(12)
|
||||
component.notes.set(notes)
|
||||
fixture.detectChanges()
|
||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[1] // 0 is add button
|
||||
const deleteButton = fixture.debugElement.query(
|
||||
By.css('.card-footer button')
|
||||
)
|
||||
const deleteSpy = jest.spyOn(notesService, 'deleteNote')
|
||||
const toastsSpy = jest.spyOn(toastService, 'showError')
|
||||
deleteSpy.mockReturnValueOnce(
|
||||
|
||||
@@ -14,15 +14,21 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||
import { DocumentNote } from 'src/app/data/document-note'
|
||||
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||
import { DocumentNotesService } from 'src/app/services/rest/document-notes.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
|
||||
enum NoteViewMode {
|
||||
Edit = 'edit',
|
||||
Preview = 'preview',
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-notes',
|
||||
templateUrl: './document-notes.component.html',
|
||||
@@ -30,6 +36,7 @@ import { ComponentWithPermissions } from '../with-permissions/with-permissions.c
|
||||
imports: [
|
||||
IfPermissionsDirective,
|
||||
CustomDatePipe,
|
||||
MarkdownPipe,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
BootstrapIconComponent,
|
||||
@@ -43,6 +50,9 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
|
||||
readonly notes = model<DocumentNote[]>([])
|
||||
readonly addDisabled = input(false)
|
||||
readonly networkActive = signal(false)
|
||||
protected readonly NoteViewMode = NoteViewMode
|
||||
protected readonly NoteFormat = NoteFormat
|
||||
protected readonly noteView = signal<NoteViewMode>(NoteViewMode.Edit)
|
||||
|
||||
noteForm: FormGroup = new FormGroup({
|
||||
newNote: new FormControl(''),
|
||||
@@ -75,6 +85,7 @@ export class DocumentNotesComponent extends ComponentWithPermissions {
|
||||
next: (result) => {
|
||||
this.notes.set(result)
|
||||
this.noteForm.get('newNote').reset()
|
||||
this.noteView.set(NoteViewMode.Edit)
|
||||
this.networkActive.set(false)
|
||||
this.updated.emit(this.notes())
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
import { User } from './user'
|
||||
|
||||
export enum NoteFormat {
|
||||
Plain = 'plain',
|
||||
Markdown = 'markdown',
|
||||
}
|
||||
|
||||
export interface DocumentNote extends ObjectWithId {
|
||||
created?: Date
|
||||
note?: string
|
||||
user?: User
|
||||
format?: NoteFormat
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export enum WorkflowTriggerType {
|
||||
DocumentAdded = 2,
|
||||
DocumentUpdated = 3,
|
||||
Scheduled = 4,
|
||||
Manual = 5,
|
||||
}
|
||||
|
||||
export enum ScheduleDateField {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpTestingController } from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { NoteFormat } from 'src/app/data/document-note'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
||||
import { DocumentNotesService } from './document-notes.service'
|
||||
@@ -50,6 +51,7 @@ describe(`Additional service tests for DocumentNotesService`, () => {
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual({
|
||||
note: content,
|
||||
format: NoteFormat.Markdown,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HttpParams } from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { DocumentNote } from 'src/app/data/document-note'
|
||||
import { DocumentNote, NoteFormat } from 'src/app/data/document-note'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
|
||||
@Injectable({
|
||||
@@ -22,6 +22,7 @@ export class DocumentNotesService extends AbstractPaperlessService<DocumentNote>
|
||||
addNote(id: number, note: string): Observable<DocumentNote[]> {
|
||||
return this.http.post<DocumentNote[]>(this.getResourceUrl(id, 'notes'), {
|
||||
note: note,
|
||||
format: NoteFormat.Markdown,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
@@ -288,6 +290,7 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -301,6 +304,7 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
remote_ocr: false,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -315,6 +319,7 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
remote_ocr: true,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -329,6 +334,7 @@ describe(`DocumentService`, () => {
|
||||
documents: ids,
|
||||
degrees: 90,
|
||||
source_mode: 'latest_version',
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -344,6 +350,7 @@ describe(`DocumentService`, () => {
|
||||
documents: ids,
|
||||
metadata_document_id: 1,
|
||||
delete_originals: true,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -357,6 +364,7 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
root_document_id: 2,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -372,6 +380,7 @@ describe(`DocumentService`, () => {
|
||||
documents: ids,
|
||||
root_document_id: 2,
|
||||
version_label: 'Imported',
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -386,6 +395,7 @@ describe(`DocumentService`, () => {
|
||||
expect(req.request.body).toEqual({
|
||||
documents: ids,
|
||||
operations: [{ page: 1, rotate: 90, doc: 0 }],
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -401,6 +411,7 @@ describe(`DocumentService`, () => {
|
||||
documents: ids,
|
||||
password: 'secret',
|
||||
update_document: true,
|
||||
from_webui: true,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -38,6 +39,7 @@ export type DocumentBulkEditMethod =
|
||||
| 'modify_tags'
|
||||
| 'modify_custom_fields'
|
||||
| 'set_permissions'
|
||||
| 'run_workflow'
|
||||
|
||||
export interface MergeDocumentsRequest {
|
||||
metadata_document_id?: number
|
||||
@@ -337,27 +339,36 @@ 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) {
|
||||
return this.http.post(this.getResourceUrl(null, 'delete'), {
|
||||
...selection,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'delete'),
|
||||
{ ...selection, from_webui: true }
|
||||
)
|
||||
}
|
||||
|
||||
reprocessDocuments(
|
||||
selection: DocumentSelectionQuery,
|
||||
remoteOcr: boolean = false
|
||||
) {
|
||||
return this.http.post(this.getResourceUrl(null, 'reprocess'), {
|
||||
...selection,
|
||||
remote_ocr: remoteOcr,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'reprocess'),
|
||||
{
|
||||
...selection,
|
||||
remote_ocr: remoteOcr,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
rotateDocuments(
|
||||
@@ -365,18 +376,26 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
degrees: number,
|
||||
sourceMode: BulkEditSourceMode = BulkEditSourceMode.LATEST_VERSION
|
||||
) {
|
||||
return this.http.post(this.getResourceUrl(null, 'rotate'), {
|
||||
...selection,
|
||||
degrees,
|
||||
source_mode: sourceMode,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'rotate'),
|
||||
{
|
||||
...selection,
|
||||
degrees,
|
||||
source_mode: sourceMode,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
mergeDocuments(ids: number[], request: MergeDocumentsRequest = {}) {
|
||||
return this.http.post(this.getResourceUrl(null, 'merge'), {
|
||||
documents: ids,
|
||||
...request,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'merge'),
|
||||
{
|
||||
documents: ids,
|
||||
...request,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
mergeDocumentsAsVersions(
|
||||
@@ -384,28 +403,40 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
||||
rootDocumentId: number,
|
||||
versionLabel?: string
|
||||
) {
|
||||
return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), {
|
||||
documents: ids,
|
||||
root_document_id: rootDocumentId,
|
||||
...(versionLabel ? { version_label: versionLabel } : {}),
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'merge_as_versions'),
|
||||
{
|
||||
documents: ids,
|
||||
root_document_id: rootDocumentId,
|
||||
...(versionLabel ? { version_label: versionLabel } : {}),
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
|
||||
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
|
||||
documents: ids,
|
||||
...request,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'edit_pdf'),
|
||||
{
|
||||
documents: ids,
|
||||
...request,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
removePasswordDocuments(
|
||||
ids: number[],
|
||||
request: RemovePasswordDocumentsRequest
|
||||
) {
|
||||
return this.http.post(this.getResourceUrl(null, 'remove_password'), {
|
||||
documents: ids,
|
||||
...request,
|
||||
})
|
||||
return this.http.post<BulkOperationResponse>(
|
||||
this.getResourceUrl(null, 'remove_password'),
|
||||
{
|
||||
documents: ids,
|
||||
...request,
|
||||
from_webui: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
getSelectionData(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { PaperlessTaskStatus } from '../data/paperless-task'
|
||||
import { DocumentListViewService } from './document-list-view.service'
|
||||
import { Toast, ToastService } from './toast.service'
|
||||
import { TrashService } from './trash.service'
|
||||
@@ -105,4 +106,27 @@ describe('TrashService', () => {
|
||||
expect(toast.content).toEqual('Moved 500 document(s) to the trash')
|
||||
expect(toast.action).toBeUndefined()
|
||||
})
|
||||
|
||||
it('offers Undo only after the matching delete task succeeds', () => {
|
||||
let toasts: Toast[] = []
|
||||
toastService.getToasts().subscribe((t) => (toasts = [...t]))
|
||||
service.trackMovedToTrash('delete-1', 2, [1, 2])
|
||||
expect(toasts).toHaveLength(0)
|
||||
service.handleDeleteTaskStatus('other-delete', PaperlessTaskStatus.Success)
|
||||
expect(toasts).toHaveLength(0)
|
||||
service.handleDeleteTaskStatus('delete-1', PaperlessTaskStatus.Success)
|
||||
expect(toasts).toHaveLength(1)
|
||||
expect(toasts[0].actionName).toBe('Undo')
|
||||
})
|
||||
|
||||
it('handles completion before the queue response and does not offer Undo on failure', () => {
|
||||
let toasts: Toast[] = []
|
||||
toastService.getToasts().subscribe((t) => (toasts = [...t]))
|
||||
service.handleDeleteTaskStatus('delete-1', PaperlessTaskStatus.Success)
|
||||
service.trackMovedToTrash('delete-1', 1, [1])
|
||||
expect(toasts[0].actionName).toBe('Undo')
|
||||
service.trackMovedToTrash('delete-2', 1, [2])
|
||||
service.handleDeleteTaskStatus('delete-2', PaperlessTaskStatus.Failure)
|
||||
expect(toasts).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { Injectable, inject } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { Document } from '../data/document'
|
||||
import { PaperlessTaskStatus } from '../data/paperless-task'
|
||||
import { Results } from '../data/results'
|
||||
import { DocumentListViewService } from './document-list-view.service'
|
||||
import { ToastLevel, ToastService } from './toast.service'
|
||||
@@ -14,6 +15,49 @@ export class TrashService {
|
||||
private http = inject(HttpClient)
|
||||
private toastService = inject(ToastService)
|
||||
private documentListViewService = inject(DocumentListViewService)
|
||||
private pendingMoves = new Map<
|
||||
string,
|
||||
{ count: number; documents?: number[] }
|
||||
>()
|
||||
private completedMoves = new Map<string, PaperlessTaskStatus>()
|
||||
|
||||
public trackMovedToTrash(
|
||||
taskId: string,
|
||||
count: number,
|
||||
documents?: number[]
|
||||
) {
|
||||
const status = this.completedMoves.get(taskId)
|
||||
if (status !== undefined) {
|
||||
this.completedMoves.delete(taskId)
|
||||
if (status === PaperlessTaskStatus.Success) {
|
||||
this.notifyMovedToTrash(
|
||||
$localize`Moved ${count} document(s) to the trash`,
|
||||
documents
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.pendingMoves.set(taskId, { count, documents })
|
||||
}
|
||||
}
|
||||
|
||||
public handleDeleteTaskStatus(taskId: string, status: PaperlessTaskStatus) {
|
||||
const move = this.pendingMoves.get(taskId)
|
||||
if (move) {
|
||||
this.pendingMoves.delete(taskId)
|
||||
if (status === PaperlessTaskStatus.Success) {
|
||||
this.notifyMovedToTrash(
|
||||
$localize`Moved ${move.count} document(s) to the trash`,
|
||||
move.documents
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// in case the task finishes fast
|
||||
this.completedMoves.set(taskId, status)
|
||||
if (this.completedMoves.size > 100) {
|
||||
this.completedMoves.delete(this.completedMoves.keys().next().value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getTrash(page: number = 1): Observable<Results<Document>> {
|
||||
const httpParams = new HttpParams().set('page', page.toString())
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+74
-36
@@ -42,12 +42,14 @@ from documents.plugins.helpers import DocumentsStatusManager
|
||||
from documents.tasks import bulk_update_documents
|
||||
from documents.tasks import consume_file
|
||||
from documents.tasks import remove_document_from_index
|
||||
from documents.tasks import run_workflows_for_documents
|
||||
from documents.tasks import update_document_content_maybe_archive_file
|
||||
from documents.versioning import get_latest_version_for_root
|
||||
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 +133,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 +163,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 +187,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 +209,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 +240,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 +263,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 +272,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 +317,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 +326,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 +390,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"
|
||||
|
||||
@@ -407,6 +427,7 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
|
||||
"Detected a possible incompatible database column. See https://docs.paperless-ngx.com/troubleshooting/#convert-uuid-field",
|
||||
)
|
||||
logger.error(f"Error deleting documents: {e!s}")
|
||||
raise
|
||||
|
||||
return "OK"
|
||||
|
||||
@@ -445,12 +466,32 @@ def reprocess(doc_ids: list[int], *, remote_ocr: bool = False) -> Literal["OK"]:
|
||||
return "OK"
|
||||
|
||||
|
||||
def run_workflow(
|
||||
doc_ids: list[int],
|
||||
workflow: int,
|
||||
*,
|
||||
filter_documents: bool = True,
|
||||
) -> Literal["OK"]:
|
||||
"""
|
||||
Manually run a single workflow against the given documents.
|
||||
|
||||
Called from the tracked bulk operation task.
|
||||
"""
|
||||
run_workflows_for_documents(
|
||||
document_ids=doc_ids,
|
||||
workflow_id=workflow,
|
||||
filter_documents=filter_documents,
|
||||
)
|
||||
return "OK"
|
||||
|
||||
|
||||
def set_permissions(
|
||||
doc_ids: list[int],
|
||||
set_permissions: PermissionsSpec,
|
||||
*,
|
||||
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 +509,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,170 @@
|
||||
"""Queued document bulk operations."""
|
||||
|
||||
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.models import PaperlessTask
|
||||
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,
|
||||
}
|
||||
LEGACY_MODIFIED_FIELDS = {"delete": "deleted_at", "reprocess": "checksum"}
|
||||
QUEUED_OPERATIONS = frozenset(
|
||||
(
|
||||
*MODIFIED_FIELDS,
|
||||
"delete",
|
||||
"reprocess",
|
||||
"run_workflow",
|
||||
"rotate",
|
||||
"merge",
|
||||
"merge_as_versions",
|
||||
"split",
|
||||
"delete_pages",
|
||||
"edit_pdf",
|
||||
"remove_password",
|
||||
),
|
||||
)
|
||||
USER_OPERATIONS = frozenset(
|
||||
{
|
||||
"split",
|
||||
"merge",
|
||||
"rotate",
|
||||
"delete_pages",
|
||||
"edit_pdf",
|
||||
"remove_password",
|
||||
"merge_as_versions",
|
||||
},
|
||||
)
|
||||
# merge_as_versions doesn't queue any consume tasks
|
||||
TRIGGER_SOURCE_OPERATIONS = USER_OPERATIONS - {"merge_as_versions"}
|
||||
|
||||
|
||||
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,
|
||||
trigger_source: str = PaperlessTask.TriggerSource.API_UPLOAD,
|
||||
) -> 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")
|
||||
|
||||
if operation == "run_workflow" and not user.has_perm("documents.view_workflow"):
|
||||
raise PermissionDenied("Insufficient permissions to run this workflow")
|
||||
|
||||
if operation not in MODIFIED_FIELDS:
|
||||
execution_parameters = parameters.copy()
|
||||
if operation in USER_OPERATIONS:
|
||||
execution_parameters["user"] = user
|
||||
if operation in TRIGGER_SOURCE_OPERATIONS:
|
||||
execution_parameters["trigger_source"] = trigger_source
|
||||
modified_field = LEGACY_MODIFIED_FIELDS.get(operation)
|
||||
with set_actor(user):
|
||||
old_values = (
|
||||
snapshot_field(document_ids, modified_field)
|
||||
if settings.AUDIT_LOG_ENABLED and modified_field
|
||||
else {}
|
||||
)
|
||||
method(document_ids, **execution_parameters)
|
||||
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}"},
|
||||
)
|
||||
return {"operation": operation, "document_count": len(document_ids)}
|
||||
|
||||
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)
|
||||
@@ -35,6 +35,7 @@ from django_filters.rest_framework import DateTimeFilter
|
||||
from django_filters.rest_framework import Filter
|
||||
from django_filters.rest_framework import FilterSet
|
||||
from django_filters.rest_framework import MultipleChoiceFilter
|
||||
from django_filters.rest_framework import NumberFilter
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from guardian.utils import get_group_obj_perms_model
|
||||
from guardian.utils import get_user_obj_perms_model
|
||||
@@ -52,6 +53,7 @@ from documents.models import ShareLink
|
||||
from documents.models import ShareLinkBundle
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import Workflow
|
||||
from documents.permissions import annotate_authorizing_fields
|
||||
from documents.permissions import permitted_document_ids
|
||||
from documents.permissions import permitted_object_ids
|
||||
@@ -249,6 +251,17 @@ class CustomFieldFilterSet(FilterSet):
|
||||
}
|
||||
|
||||
|
||||
class WorkflowFilterSet(FilterSet):
|
||||
trigger_type = NumberFilter(field_name="triggers__type", distinct=True)
|
||||
|
||||
class Meta:
|
||||
model = Workflow
|
||||
fields = {
|
||||
"id": ID_KWARGS,
|
||||
"enabled": ["exact"],
|
||||
}
|
||||
|
||||
|
||||
@extend_schema_field(serializers.CharField)
|
||||
class CustomFieldsFilter(Filter):
|
||||
def filter(self, qs, value):
|
||||
|
||||
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user