mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-06-29 08:44:24 +00:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e09fc54722 | |||
| a0172a2754 | |||
| 810bf3d612 | |||
| 846cc47565 | |||
| 1d396d9160 | |||
| 2a4e8f9acd | |||
| a9dfe8f3f7 | |||
| 906e841ded | |||
| 6684e80ffc | |||
| 3dc7cf3da1 | |||
| 819f606335 | |||
| ad45e3f747 | |||
| 74b10db028 | |||
| cffb9c34f0 | |||
| 6f52614817 | |||
| a0d3527d20 | |||
| 4e64ca7ca6 | |||
| e9511bd3da | |||
| 8b9ca75a90 | |||
| 9f0a4ac19d | |||
| 8f969ecab5 | |||
| 245e52a4eb | |||
| a8c75d95d8 | |||
| d6e2456baf | |||
| 3b75d3271e | |||
| e88816d141 | |||
| e5bd4713ac | |||
| b9aced07fb | |||
| 6b55740f56 | |||
| 9aee063347 | |||
| 7fe411bb1a | |||
| 34b5f4c565 | |||
| 3808a4e14a | |||
| 3bd4135aba | |||
| b60fb8ed82 | |||
| 3f32ed319a | |||
| 03e6d58f86 | |||
| c197487374 | |||
| d718d7d29f | |||
| ce112cda0e | |||
| d904aaef60 | |||
| 35bc673648 | |||
| d0bd111eab | |||
| cd81f750b4 | |||
| 48d21da13b | |||
| 701aafce06 | |||
| cbe8bc35d6 | |||
| 1c4fa7237c | |||
| 63dab0ab09 | |||
| 276dc31abe | |||
| a11a2ec13f | |||
| df9136e7d4 | |||
| 1d8fadcb3c | |||
| 4e85262781 | |||
| 7e5d80fa38 | |||
| 3cfd64b77a | |||
| 0fc595a16a | |||
| 91e2220f23 | |||
| 893c05dfdc | |||
| faf3e8dc0d | |||
| 41b9fff407 | |||
| 26f61c900f | |||
| 8d0e07e931 | |||
| bf9e3fca48 | |||
| 144dd8cdf3 | |||
| 13161ebb01 | |||
| 0ebd9f24b5 | |||
| c9f49f390a | |||
| 31cee7481b | |||
| 78893292f8 | |||
| e4ac079cd7 | |||
| 597c2629dd |
@@ -51,5 +51,5 @@ body:
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs or output
|
||||
description: If you have logs, errors that might help, paste it here.
|
||||
description: If you have logs, errors that might help, paste it here. For example other containers or services (database, redis, etc).
|
||||
render: bash
|
||||
|
||||
@@ -6,8 +6,8 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### ⚠️ Please remember: issues are for *bugs*
|
||||
That is, something you believe affects every single user of Paperless-ngx, not just you. If you're not sure, start with one of the other options below.
|
||||
### ⚠️ Please remember: issues are for *bugs* only! ⚠️
|
||||
That is, something you believe affects every single user of Paperless-ngx (and the demo, for example), not just you. If you are not sure, start with one of the other options below.
|
||||
|
||||
Also, note that **Paperless-ngx does not perform OCR or archive file creation itself**, those are handled by other tools. Problems with OCR or archive versions of specific files should likely be raised 'upstream', see https://github.com/ocrmypdf/OCRmyPDF/issues or https://github.com/tesseract-ocr/tesseract/issues
|
||||
- type: markdown
|
||||
@@ -59,6 +59,12 @@ body:
|
||||
label: Browser logs
|
||||
description: Logs from the web browser related to your issue, if needed
|
||||
render: bash
|
||||
- type: textarea
|
||||
id: logs_services
|
||||
attributes:
|
||||
label: Services logs
|
||||
description: Logs from other services (or containers) related to your issue, if needed. For example, the database or redis logs.
|
||||
render: bash
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
|
||||
@@ -181,10 +181,11 @@ jobs:
|
||||
pytest
|
||||
- name: Upload backend test results to Codecov
|
||||
if: always()
|
||||
uses: codecov/test-results-action@v1
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
flags: backend-python-${{ matrix.python-version }}
|
||||
files: junit.xml
|
||||
report_type: test_results
|
||||
- name: Upload backend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
@@ -260,11 +261,12 @@ jobs:
|
||||
- name: Run Jest unit tests
|
||||
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
- name: Upload frontend test results to Codecov
|
||||
uses: codecov/test-results-action@v1
|
||||
if: always()
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
flags: frontend-node-${{ matrix.node-version }}
|
||||
directory: src-ui/
|
||||
report_type: test_results
|
||||
- name: Upload frontend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
||||
@@ -1,5 +1,232 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 2.19.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: remove unnecessary permission requirements for new email endpoint [@shamoon](https://github.com/shamoon) ([#11215](https://github.com/paperless-ngx/paperless-ngx/pull/11215))
|
||||
- Fix: refactor nested sorting in filterable dropdowns [@shamoon](https://github.com/shamoon) ([#11214](https://github.com/paperless-ngx/paperless-ngx/pull/11214))
|
||||
- Fix: add root tag filtering for tag list page consistency, fix toggle all [@shamoon](https://github.com/shamoon) ([#11208](https://github.com/paperless-ngx/paperless-ngx/pull/11208))
|
||||
- Fix: support ConsumableDocument in email attachments [@shamoon](https://github.com/shamoon) ([#11196](https://github.com/paperless-ngx/paperless-ngx/pull/11196))
|
||||
- Fix: add missing import for ConfirmButtonComponent in user-edit-dialog [@shamoon](https://github.com/shamoon) ([#11167](https://github.com/paperless-ngx/paperless-ngx/pull/11167))
|
||||
- Fix: resolve migration warning in 2.19.2 [@shamoon](https://github.com/shamoon) ([#11157](https://github.com/paperless-ngx/paperless-ngx/pull/11157))
|
||||
|
||||
### Changes
|
||||
|
||||
- Change: make workflow action only title draggable [@shamoon](https://github.com/shamoon) ([#11209](https://github.com/paperless-ngx/paperless-ngx/pull/11209))
|
||||
- Change: change workflowrun to softdeletemodel [@shamoon](https://github.com/shamoon) ([#11194](https://github.com/paperless-ngx/paperless-ngx/pull/11194))
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Chore(deps): Bump django from 5.2.6 to 5.2.7 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11200](https://github.com/paperless-ngx/paperless-ngx/pull/11200))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>9 changes</summary>
|
||||
|
||||
- Chore(deps): Bump django from 5.2.6 to 5.2.7 @[dependabot[bot]](https://github.com/apps/dependabot) ([#11200](https://github.com/paperless-ngx/paperless-ngx/pull/11200))
|
||||
- Fix: remove unnecessary permission requirements for new email endpoint [@shamoon](https://github.com/shamoon) ([#11215](https://github.com/paperless-ngx/paperless-ngx/pull/11215))
|
||||
- Fix: refactor nested sorting in filterable dropdowns [@shamoon](https://github.com/shamoon) ([#11214](https://github.com/paperless-ngx/paperless-ngx/pull/11214))
|
||||
- Fix: add root tag filtering for tag list page consistency, fix toggle all [@shamoon](https://github.com/shamoon) ([#11208](https://github.com/paperless-ngx/paperless-ngx/pull/11208))
|
||||
- Change: make workflow action only title draggable [@shamoon](https://github.com/shamoon) ([#11209](https://github.com/paperless-ngx/paperless-ngx/pull/11209))
|
||||
- Change: change workflowrun to softdeletemodel [@shamoon](https://github.com/shamoon) ([#11194](https://github.com/paperless-ngx/paperless-ngx/pull/11194))
|
||||
- Chore: Minor migration optimization for workflow titles [@stumpylog](https://github.com/stumpylog) ([#11197](https://github.com/paperless-ngx/paperless-ngx/pull/11197))
|
||||
- Fix: support ConsumableDocument in email attachments [@shamoon](https://github.com/shamoon) ([#11196](https://github.com/paperless-ngx/paperless-ngx/pull/11196))
|
||||
- Fix: add missing import for ConfirmButtonComponent in user-edit-dialog [@shamoon](https://github.com/shamoon) ([#11167](https://github.com/paperless-ngx/paperless-ngx/pull/11167))
|
||||
- Fix: resolve migration warning in 2.19.2 [@shamoon](https://github.com/shamoon) ([#11157](https://github.com/paperless-ngx/paperless-ngx/pull/11157))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.19.2
|
||||
|
||||
### Features / Enhancements
|
||||
|
||||
- Fixhancement: display loading status for tags instead of 'Private' [@shamoon](https://github.com/shamoon) ([#11140](https://github.com/paperless-ngx/paperless-ngx/pull/11140))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: Remove edit requirement for bulk email, show based on setting [@shamoon](https://github.com/shamoon) ([#11149](https://github.com/paperless-ngx/paperless-ngx/pull/11149))
|
||||
- Fix: handle undefined IDs in getOriginalObject [@shamoon](https://github.com/shamoon) ([#11147](https://github.com/paperless-ngx/paperless-ngx/pull/11147))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>3 changes</summary>
|
||||
|
||||
- Fix: Remove edit requirement for bulk email, show based on setting [@shamoon](https://github.com/shamoon) ([#11149](https://github.com/paperless-ngx/paperless-ngx/pull/11149))
|
||||
- Fix: handle undefined IDs in getOriginalObject [@shamoon](https://github.com/shamoon) ([#11147](https://github.com/paperless-ngx/paperless-ngx/pull/11147))
|
||||
- Fixhancement: display loading status for tags instead of 'Private' [@shamoon](https://github.com/shamoon) ([#11140](https://github.com/paperless-ngx/paperless-ngx/pull/11140))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.19.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: skip workflow title migration for empty titles [@shamoon](https://github.com/shamoon) ([#11136](https://github.com/paperless-ngx/paperless-ngx/pull/11136))
|
||||
- Fix: restore workflow title migration [@shamoon](https://github.com/shamoon) ([#11131](https://github.com/paperless-ngx/paperless-ngx/pull/11131))
|
||||
- Fix: retrieve document_count for tag children [@shamoon](https://github.com/shamoon) ([#11125](https://github.com/paperless-ngx/paperless-ngx/pull/11125))
|
||||
- Fix: move hierarchical order logic in dropdown sorting [@shamoon](https://github.com/shamoon) ([#11128](https://github.com/paperless-ngx/paperless-ngx/pull/11128))
|
||||
- Fix: use original object for children in tag list [@shamoon](https://github.com/shamoon) ([#11127](https://github.com/paperless-ngx/paperless-ngx/pull/11127))
|
||||
- Fix: dont display or fetch users or groups with insufficient perms [@shamoon](https://github.com/shamoon) ([#11111](https://github.com/paperless-ngx/paperless-ngx/pull/11111))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>6 changes</summary>
|
||||
|
||||
- Fix: skip workflow title migration for empty titles [@shamoon](https://github.com/shamoon) ([#11136](https://github.com/paperless-ngx/paperless-ngx/pull/11136))
|
||||
- Fix: restore workflow title migration [@shamoon](https://github.com/shamoon) ([#11131](https://github.com/paperless-ngx/paperless-ngx/pull/11131))
|
||||
- Fix: retrieve document_count for tag children [@shamoon](https://github.com/shamoon) ([#11125](https://github.com/paperless-ngx/paperless-ngx/pull/11125))
|
||||
- Fix: move hierarchical order logic in dropdown sorting [@shamoon](https://github.com/shamoon) ([#11128](https://github.com/paperless-ngx/paperless-ngx/pull/11128))
|
||||
- Fix: use original object for children in tag list [@shamoon](https://github.com/shamoon) ([#11127](https://github.com/paperless-ngx/paperless-ngx/pull/11127))
|
||||
- Fix: dont display or fetch users or groups with insufficient perms [@shamoon](https://github.com/shamoon) ([#11111](https://github.com/paperless-ngx/paperless-ngx/pull/11111))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.19.0
|
||||
|
||||
### Notable Changes
|
||||
|
||||
- Feature: Advanced Workflow Filters [@shamoon](https://github.com/shamoon) ([#11029](https://github.com/paperless-ngx/paperless-ngx/pull/11029))
|
||||
- Feature: Nested Tags [@shamoon](https://github.com/shamoon) ([#10833](https://github.com/paperless-ngx/paperless-ngx/pull/10833))
|
||||
|
||||
### Features / Enhancements
|
||||
|
||||
- docker(deps): bump astral-sh/uv from 0.9.2-python3.12-bookworm-slim to 0.9.4-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11091](https://github.com/paperless-ngx/paperless-ngx/pull/11091))
|
||||
- Enhancement: use friendly file names when emailing documents [@JanKleine](https://github.com/JanKleine) ([#11055](https://github.com/paperless-ngx/paperless-ngx/pull/11055))
|
||||
- Feature: Advanced Workflow Filters [@shamoon](https://github.com/shamoon) ([#11029](https://github.com/paperless-ngx/paperless-ngx/pull/11029))
|
||||
- docker(deps): Bump astral-sh/uv from 0.8.22-python3.12-bookworm-slim to 0.9.2-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11052](https://github.com/paperless-ngx/paperless-ngx/pull/11052))
|
||||
- Feature: add support for emailing multiple documents [@JanKleine](https://github.com/JanKleine) ([#10666](https://github.com/paperless-ngx/paperless-ngx/pull/10666))
|
||||
- Enhancement: ignore same files in sanity checker as consumer [@shamoon](https://github.com/shamoon) ([#10999](https://github.com/paperless-ngx/paperless-ngx/pull/10999))
|
||||
- Enhancement: open color picker on swatch button click [@shamoon](https://github.com/shamoon) ([#10994](https://github.com/paperless-ngx/paperless-ngx/pull/10994))
|
||||
- Performance: Cache django-guardian permissions when counting documents [@Merinorus](https://github.com/Merinorus) ([#10657](https://github.com/paperless-ngx/paperless-ngx/pull/10657))
|
||||
- Tweakhancement: reorganize some list \& bulk editing buttons [@shamoon](https://github.com/shamoon) ([#10944](https://github.com/paperless-ngx/paperless-ngx/pull/10944))
|
||||
- Enhancement: support workflow path matching of barcode-split documents [@DerRockWolf](https://github.com/DerRockWolf) ([#10723](https://github.com/paperless-ngx/paperless-ngx/pull/10723))
|
||||
- Feature: processed mail UI [@shamoon](https://github.com/shamoon) ([#10866](https://github.com/paperless-ngx/paperless-ngx/pull/10866))
|
||||
- Enhancement: support custom field values on post document [@shamoon](https://github.com/shamoon) ([#10859](https://github.com/paperless-ngx/paperless-ngx/pull/10859))
|
||||
- Feature: Nested Tags [@shamoon](https://github.com/shamoon) ([#10833](https://github.com/paperless-ngx/paperless-ngx/pull/10833))
|
||||
- Enhancement: long text custom field [@jojo2357](https://github.com/jojo2357) ([#10846](https://github.com/paperless-ngx/paperless-ngx/pull/10846))
|
||||
- Enhancement: Add print button [@mpaletti](https://github.com/mpaletti) ([#10626](https://github.com/paperless-ngx/paperless-ngx/pull/10626))
|
||||
- Enhancement: add storage path as workflow trigger filter @david-loe ([#10771](https://github.com/paperless-ngx/paperless-ngx/pull/10771))
|
||||
- Enhancement: jinja template support for workflow title assignment [@sidey79](https://github.com/sidey79) ([#10700](https://github.com/paperless-ngx/paperless-ngx/pull/10700))
|
||||
- Enhancement: Limit excessively long content length when computing suggestions [@Merinorus](https://github.com/Merinorus) ([#10656](https://github.com/paperless-ngx/paperless-ngx/pull/10656))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: remove obsolete warning for custom field value index [@shamoon](https://github.com/shamoon) ([#11083](https://github.com/paperless-ngx/paperless-ngx/pull/11083))
|
||||
- Fix: set min-height for drag-drop items container [@shamoon](https://github.com/shamoon) ([#11064](https://github.com/paperless-ngx/paperless-ngx/pull/11064))
|
||||
- Fix custom field query dropdown toggle corners [@shamoon](https://github.com/shamoon) ([#11028](https://github.com/paperless-ngx/paperless-ngx/pull/11028))
|
||||
- Fix: correct save hotkey action when no next document exists [@shamoon](https://github.com/shamoon) ([#11027](https://github.com/paperless-ngx/paperless-ngx/pull/11027))
|
||||
- Fix: require only change permissions for task dismissal, add frontend error handling [@shamoon](https://github.com/shamoon) ([#11023](https://github.com/paperless-ngx/paperless-ngx/pull/11023))
|
||||
- Chore(deps): Bulk upgrade backend dependencies [@stumpylog](https://github.com/stumpylog) ([#10971](https://github.com/paperless-ngx/paperless-ngx/pull/10971))
|
||||
- Chore: remove Codecov token from CI workflow [@shamoon](https://github.com/shamoon) ([#10941](https://github.com/paperless-ngx/paperless-ngx/pull/10941))
|
||||
- Fix: fix select option removal and pagination update [@shamoon](https://github.com/shamoon) ([#10933](https://github.com/paperless-ngx/paperless-ngx/pull/10933))
|
||||
- Fix: skip fuzzy matching for empty document content [@shamoon](https://github.com/shamoon) ([#10914](https://github.com/paperless-ngx/paperless-ngx/pull/10914))
|
||||
- Fix: add extra error handling to \_consume for file checks [@shamoon](https://github.com/shamoon) ([#10897](https://github.com/paperless-ngx/paperless-ngx/pull/10897))
|
||||
- Fix: restore str celery beat schedule filename [@shamoon](https://github.com/shamoon) ([#10893](https://github.com/paperless-ngx/paperless-ngx/pull/10893))
|
||||
- Fix: fix pdf editor hover rotate counterclockwise button [@shamoon](https://github.com/shamoon) ([#10848](https://github.com/paperless-ngx/paperless-ngx/pull/10848))
|
||||
- Fix: warp long words in toast content [@shamoon](https://github.com/shamoon) ([#10839](https://github.com/paperless-ngx/paperless-ngx/pull/10839))
|
||||
- Fix: fix error when bulk adding empty doc link custom fields [@shamoon](https://github.com/shamoon) ([#10832](https://github.com/paperless-ngx/paperless-ngx/pull/10832))
|
||||
- Fix: set match value for correspondents created by mail rule [@shamoon](https://github.com/shamoon) ([#10820](https://github.com/paperless-ngx/paperless-ngx/pull/10820))
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Chore(deps): Bump the actions group with 5 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10978](https://github.com/paperless-ngx/paperless-ngx/pull/10978))
|
||||
- Chore: remove Codecov token from CI workflow [@shamoon](https://github.com/shamoon) ([#10941](https://github.com/paperless-ngx/paperless-ngx/pull/10941))
|
||||
|
||||
### Dependencies
|
||||
|
||||
<details>
|
||||
<summary>29 changes</summary>
|
||||
|
||||
- docker(deps): bump astral-sh/uv from 0.9.2-python3.12-bookworm-slim to 0.9.4-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11091](https://github.com/paperless-ngx/paperless-ngx/pull/11091))
|
||||
- docker-compose(deps): Bump gotenberg/gotenberg from 8.23 to 8.24 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#11050](https://github.com/paperless-ngx/paperless-ngx/pull/11050))
|
||||
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11065](https://github.com/paperless-ngx/paperless-ngx/pull/11065))
|
||||
- docker(deps): Bump astral-sh/uv from 0.8.22-python3.12-bookworm-slim to 0.9.2-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#11052](https://github.com/paperless-ngx/paperless-ngx/pull/11052))
|
||||
- Chore(deps): Bump the actions group with 5 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10978](https://github.com/paperless-ngx/paperless-ngx/pull/10978))
|
||||
- Chore(deps): Bump uuid from 11.1.0 to 13.0.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10983](https://github.com/paperless-ngx/paperless-ngx/pull/10983))
|
||||
- Chore(deps-dev): Bump @playwright/test from 1.55.0 to 1.55.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10982](https://github.com/paperless-ngx/paperless-ngx/pull/10982))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10981](https://github.com/paperless-ngx/paperless-ngx/pull/10981))
|
||||
- Chore(deps-dev): Bump webpack from 5.101.3 to 5.102.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10986](https://github.com/paperless-ngx/paperless-ngx/pull/10986))
|
||||
- Chore(deps-dev): Bump prettier-plugin-organize-imports from 4.2.0 to 4.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10985](https://github.com/paperless-ngx/paperless-ngx/pull/10985))
|
||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10980](https://github.com/paperless-ngx/paperless-ngx/pull/10980))
|
||||
- Chore(deps-dev): Bump @types/node from 24.3.0 to 24.6.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10984](https://github.com/paperless-ngx/paperless-ngx/pull/10984))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 21 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10979](https://github.com/paperless-ngx/paperless-ngx/pull/10979))
|
||||
- docker-compose(deps): Bump library/postgres from 17 to 18 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10965](https://github.com/paperless-ngx/paperless-ngx/pull/10965))
|
||||
- Chore(deps): Bump the major-versions group with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10960](https://github.com/paperless-ngx/paperless-ngx/pull/10960))
|
||||
- Chore(deps): Bump types-colorama from 0.4.15.20240311 to 0.4.15.20250801 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10961](https://github.com/paperless-ngx/paperless-ngx/pull/10961))
|
||||
- Chore(deps): Bump django-guardian from 3.1.3 to 3.2.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10909](https://github.com/paperless-ngx/paperless-ngx/pull/10909))
|
||||
- Chore(deps): Bump django-soft-delete from 1.0.19 to 1.0.21 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10908](https://github.com/paperless-ngx/paperless-ngx/pull/10908))
|
||||
- Chore(deps): Bump whitenoise from 6.10.0 to 6.11.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10910](https://github.com/paperless-ngx/paperless-ngx/pull/10910))
|
||||
- Chore(deps): Bump django-cors-headers from 4.8.0 to 4.9.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10907](https://github.com/paperless-ngx/paperless-ngx/pull/10907))
|
||||
- docker(deps): bump astral-sh/uv from 0.8.17-python3.12-bookworm-slim to 0.8.19-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10906](https://github.com/paperless-ngx/paperless-ngx/pull/10906))
|
||||
- docker(deps): Bump astral-sh/uv from 0.8.15-python3.12-bookworm-slim to 0.8.17-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10864](https://github.com/paperless-ngx/paperless-ngx/pull/10864))
|
||||
- Chore(deps): Bump the small-changes group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10880](https://github.com/paperless-ngx/paperless-ngx/pull/10880))
|
||||
- Chore(deps): Bump django-guardian from 3.1.2 to 3.1.3 in the django group @[dependabot[bot]](https://github.com/apps/dependabot) ([#10863](https://github.com/paperless-ngx/paperless-ngx/pull/10863))
|
||||
- Chore(deps): Bump pytest-cov from 6.2.1 to 7.0.0 in the development group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#10822](https://github.com/paperless-ngx/paperless-ngx/pull/10822))
|
||||
- Chore(deps): Bump the django group with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10811](https://github.com/paperless-ngx/paperless-ngx/pull/10811))
|
||||
- docker-compose(deps): Bump gotenberg/gotenberg from 8.22 to 8.23 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10812](https://github.com/paperless-ngx/paperless-ngx/pull/10812))
|
||||
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10821](https://github.com/paperless-ngx/paperless-ngx/pull/10821))
|
||||
- docker(deps): Bump astral-sh/uv from 0.8.13-python3.12-bookworm-slim to 0.8.15-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10810](https://github.com/paperless-ngx/paperless-ngx/pull/10810))
|
||||
</details>
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>51 changes</summary>
|
||||
|
||||
- Tweak: improve tag parent validation error handling [@shamoon](https://github.com/shamoon) ([#11096](https://github.com/paperless-ngx/paperless-ngx/pull/11096))
|
||||
- Fix: remove obsolete warning for custom field value index [@shamoon](https://github.com/shamoon) ([#11083](https://github.com/paperless-ngx/paperless-ngx/pull/11083))
|
||||
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#11065](https://github.com/paperless-ngx/paperless-ngx/pull/11065))
|
||||
- Enhancement: use friendly file names when emailing documents [@JanKleine](https://github.com/JanKleine) ([#11055](https://github.com/paperless-ngx/paperless-ngx/pull/11055))
|
||||
- Fix: set min-height for drag-drop items container [@shamoon](https://github.com/shamoon) ([#11064](https://github.com/paperless-ngx/paperless-ngx/pull/11064))
|
||||
- Feature: Advanced Workflow Filters [@shamoon](https://github.com/shamoon) ([#11029](https://github.com/paperless-ngx/paperless-ngx/pull/11029))
|
||||
- Feature: add support for emailing multiple documents [@JanKleine](https://github.com/JanKleine) ([#10666](https://github.com/paperless-ngx/paperless-ngx/pull/10666))
|
||||
- Fix custom field query dropdown toggle corners [@shamoon](https://github.com/shamoon) ([#11028](https://github.com/paperless-ngx/paperless-ngx/pull/11028))
|
||||
- Fix: correct save hotkey action when no next document exists [@shamoon](https://github.com/shamoon) ([#11027](https://github.com/paperless-ngx/paperless-ngx/pull/11027))
|
||||
- Fix: require only change permissions for task dismissal, add frontend error handling [@shamoon](https://github.com/shamoon) ([#11023](https://github.com/paperless-ngx/paperless-ngx/pull/11023))
|
||||
- Enhancement: ignore same files in sanity checker as consumer [@shamoon](https://github.com/shamoon) ([#10999](https://github.com/paperless-ngx/paperless-ngx/pull/10999))
|
||||
- Enhancement: open color picker on swatch button click [@shamoon](https://github.com/shamoon) ([#10994](https://github.com/paperless-ngx/paperless-ngx/pull/10994))
|
||||
- Chore(deps): Bump uuid from 11.1.0 to 13.0.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10983](https://github.com/paperless-ngx/paperless-ngx/pull/10983))
|
||||
- Chore(deps-dev): Bump @playwright/test from 1.55.0 to 1.55.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10982](https://github.com/paperless-ngx/paperless-ngx/pull/10982))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10981](https://github.com/paperless-ngx/paperless-ngx/pull/10981))
|
||||
- Chore(deps-dev): Bump webpack from 5.101.3 to 5.102.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10986](https://github.com/paperless-ngx/paperless-ngx/pull/10986))
|
||||
- Chore(deps-dev): Bump prettier-plugin-organize-imports from 4.2.0 to 4.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10985](https://github.com/paperless-ngx/paperless-ngx/pull/10985))
|
||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10980](https://github.com/paperless-ngx/paperless-ngx/pull/10980))
|
||||
- Chore(deps-dev): Bump @types/node from 24.3.0 to 24.6.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10984](https://github.com/paperless-ngx/paperless-ngx/pull/10984))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 21 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10979](https://github.com/paperless-ngx/paperless-ngx/pull/10979))
|
||||
- Performance: Cache django-guardian permissions when counting documents [@Merinorus](https://github.com/Merinorus) ([#10657](https://github.com/paperless-ngx/paperless-ngx/pull/10657))
|
||||
- Chore(deps): Bulk upgrade backend dependencies [@stumpylog](https://github.com/stumpylog) ([#10971](https://github.com/paperless-ngx/paperless-ngx/pull/10971))
|
||||
- Chore(deps): Bump the major-versions group with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10960](https://github.com/paperless-ngx/paperless-ngx/pull/10960))
|
||||
- Chore(deps): Bump types-colorama from 0.4.15.20240311 to 0.4.15.20250801 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10961](https://github.com/paperless-ngx/paperless-ngx/pull/10961))
|
||||
- Chore(deps): Bump django-guardian from 3.1.3 to 3.2.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10909](https://github.com/paperless-ngx/paperless-ngx/pull/10909))
|
||||
- Chore(deps): Bump django-soft-delete from 1.0.19 to 1.0.21 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10908](https://github.com/paperless-ngx/paperless-ngx/pull/10908))
|
||||
- Chore(deps): Bump whitenoise from 6.10.0 to 6.11.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10910](https://github.com/paperless-ngx/paperless-ngx/pull/10910))
|
||||
- Tweakhancement: reorganize some list \& bulk editing buttons [@shamoon](https://github.com/shamoon) ([#10944](https://github.com/paperless-ngx/paperless-ngx/pull/10944))
|
||||
- Chore(deps): Bump django-cors-headers from 4.8.0 to 4.9.0 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10907](https://github.com/paperless-ngx/paperless-ngx/pull/10907))
|
||||
- Fix: fix select option removal and pagination update [@shamoon](https://github.com/shamoon) ([#10933](https://github.com/paperless-ngx/paperless-ngx/pull/10933))
|
||||
- Enhancement: support workflow path matching of barcode-split documents [@DerRockWolf](https://github.com/DerRockWolf) ([#10723](https://github.com/paperless-ngx/paperless-ngx/pull/10723))
|
||||
- Fix: skip fuzzy matching for empty document content [@shamoon](https://github.com/shamoon) ([#10914](https://github.com/paperless-ngx/paperless-ngx/pull/10914))
|
||||
- Feature: processed mail UI [@shamoon](https://github.com/shamoon) ([#10866](https://github.com/paperless-ngx/paperless-ngx/pull/10866))
|
||||
- Fix: add extra error handling to \_consume for file checks [@shamoon](https://github.com/shamoon) ([#10897](https://github.com/paperless-ngx/paperless-ngx/pull/10897))
|
||||
- Fix: restore str celery beat schedule filename [@shamoon](https://github.com/shamoon) ([#10893](https://github.com/paperless-ngx/paperless-ngx/pull/10893))
|
||||
- Enhancement: support custom field values on post document [@shamoon](https://github.com/shamoon) ([#10859](https://github.com/paperless-ngx/paperless-ngx/pull/10859))
|
||||
- Feature: Nested Tags [@shamoon](https://github.com/shamoon) ([#10833](https://github.com/paperless-ngx/paperless-ngx/pull/10833))
|
||||
- Chore(deps): Bump the small-changes group across 1 directory with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10880](https://github.com/paperless-ngx/paperless-ngx/pull/10880))
|
||||
- Chore(deps): Bump django-guardian from 3.1.2 to 3.1.3 in the django group @[dependabot[bot]](https://github.com/apps/dependabot) ([#10863](https://github.com/paperless-ngx/paperless-ngx/pull/10863))
|
||||
- Enhancement: long text custom field [@jojo2357](https://github.com/jojo2357) ([#10846](https://github.com/paperless-ngx/paperless-ngx/pull/10846))
|
||||
- Fix: fix pdf editor hover rotate counterclockwise button [@shamoon](https://github.com/shamoon) ([#10848](https://github.com/paperless-ngx/paperless-ngx/pull/10848))
|
||||
- Fix: warp long words in toast content [@shamoon](https://github.com/shamoon) ([#10839](https://github.com/paperless-ngx/paperless-ngx/pull/10839))
|
||||
- Fix: fix error when bulk adding empty doc link custom fields [@shamoon](https://github.com/shamoon) ([#10832](https://github.com/paperless-ngx/paperless-ngx/pull/10832))
|
||||
- Enhancement: Add print button [@mpaletti](https://github.com/mpaletti) ([#10626](https://github.com/paperless-ngx/paperless-ngx/pull/10626))
|
||||
- Enhancement: add storage path as workflow trigger filter @david-loe ([#10771](https://github.com/paperless-ngx/paperless-ngx/pull/10771))
|
||||
- Enhancement: jinja template support for workflow title assignment [@sidey79](https://github.com/sidey79) ([#10700](https://github.com/paperless-ngx/paperless-ngx/pull/10700))
|
||||
- Chore(deps): Bump pytest-cov from 6.2.1 to 7.0.0 in the development group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#10822](https://github.com/paperless-ngx/paperless-ngx/pull/10822))
|
||||
- Chore(deps): Bump the django group with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10811](https://github.com/paperless-ngx/paperless-ngx/pull/10811))
|
||||
- Enhancement: Limit excessively long content length when computing suggestions [@Merinorus](https://github.com/Merinorus) ([#10656](https://github.com/paperless-ngx/paperless-ngx/pull/10656))
|
||||
- Chore(deps): Bump the small-changes group across 1 directory with 8 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10821](https://github.com/paperless-ngx/paperless-ngx/pull/10821))
|
||||
- Fix: set match value for correspondents created by mail rule [@shamoon](https://github.com/shamoon) ([#10820](https://github.com/paperless-ngx/paperless-ngx/pull/10820))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.18.4
|
||||
|
||||
### Features / Enhancements
|
||||
|
||||
@@ -374,7 +374,7 @@ fi
|
||||
# of the provided folder
|
||||
if [[ -n $DATABASE_FOLDER ]] ; then
|
||||
if [[ "$DATABASE_BACKEND" == "postgres" ]] ; then
|
||||
sed -i "s#- pgdata:/var/lib/postgresql/data#- $DATABASE_FOLDER:/var/lib/postgresql/data#g" docker-compose.yml
|
||||
sed -i "s#- pgdata:/var/lib/postgresql#- $DATABASE_FOLDER:/var/lib/postgresql#g" docker-compose.yml
|
||||
sed -i "/^\s*pgdata:/d" docker-compose.yml
|
||||
elif [[ "$DATABASE_BACKEND" == "mariadb" ]]; then
|
||||
sed -i "s#- dbdata:/var/lib/mysql#- $DATABASE_FOLDER:/var/lib/mysql#g" docker-compose.yml
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "2.19.0"
|
||||
version = "2.19.3"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
+229
-211
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "2.19.0",
|
||||
"version": "2.19.3",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
@@ -21,7 +21,7 @@
|
||||
"@angular/platform-browser-dynamic": "~20.3.2",
|
||||
"@angular/router": "~20.3.2",
|
||||
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
||||
"@ng-select/ng-select": "^20.2.2",
|
||||
"@ng-select/ng-select": "^20.6.3",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
|
||||
Generated
+6
-6
@@ -39,8 +39,8 @@ importers:
|
||||
specifier: ^19.0.1
|
||||
version: 19.0.1(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(@angular/localize@20.3.2(@angular/compiler-cli@20.3.2(@angular/compiler@20.3.2)(typescript@5.8.3))(@angular/compiler@20.3.2))(@popperjs/core@2.11.8)(rxjs@7.8.2)
|
||||
'@ng-select/ng-select':
|
||||
specifier: ^20.2.2
|
||||
version: 20.2.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))
|
||||
specifier: ^20.6.3
|
||||
version: 20.6.3(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))
|
||||
'@ngneat/dirty-check-forms':
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(@angular/router@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(lodash-es@4.17.21)(rxjs@7.8.2)
|
||||
@@ -2356,9 +2356,9 @@ packages:
|
||||
'@popperjs/core': ^2.11.8
|
||||
rxjs: ^6.5.3 || ^7.4.0
|
||||
|
||||
'@ng-select/ng-select@20.2.2':
|
||||
resolution: {integrity: sha512-7mctt04/q9yquE4Ec1dQG+SkY6fZ2BQnJLsWmb05TCxYKAYAzDrDTgJJruPDuWrpYx+f3SwejpaI+z/GDrwYdw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
'@ng-select/ng-select@20.6.3':
|
||||
resolution: {integrity: sha512-+aX2l3OshyPsyMCAuiA3ND5c6X1DG5jQjdlP8PBIyYEoQWlxEcgJWrMsPa7mHVFRpp+5KZZhnXhyosUE4CEc3w==}
|
||||
engines: {node: ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
'@angular/common': ^20.0.0
|
||||
'@angular/core': ^20.0.0
|
||||
@@ -9413,7 +9413,7 @@ snapshots:
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@ng-select/ng-select@20.2.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))':
|
||||
'@ng-select/ng-select@20.6.3(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/forms@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@20.3.2(@angular/common@20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))':
|
||||
dependencies:
|
||||
'@angular/common': 20.3.2(@angular/core@20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
|
||||
'@angular/core': 20.3.2(@angular/compiler@20.3.2)(rxjs@7.8.2)(zone.js@0.15.1)
|
||||
|
||||
@@ -3,9 +3,23 @@
|
||||
i18n-title
|
||||
info="Review the log files for the application and for email checking."
|
||||
i18n-info>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||
<div class="input-group input-group-sm align-items-center">
|
||||
<div class="input-group input-group-sm me-3">
|
||||
<span class="input-group-text text-muted" i18n>Show</span>
|
||||
<input
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="100"
|
||||
step="100"
|
||||
[(ngModel)]="limit"
|
||||
(ngModelChange)="onLimitChange($event)"
|
||||
style="width: 100px;">
|
||||
<span class="input-group-text text-muted" i18n>lines</span>
|
||||
</div>
|
||||
<div class="form-check form-switch mt-1">
|
||||
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||
</div>
|
||||
</div>
|
||||
</pngx-page-header>
|
||||
|
||||
@@ -29,14 +43,19 @@
|
||||
|
||||
<div [ngbNavOutlet]="nav" class="mt-2"></div>
|
||||
|
||||
<div class="bg-dark p-3 text-light font-monospace log-container" #logContainer>
|
||||
@if (loading && logFiles.length) {
|
||||
<cdk-virtual-scroll-viewport
|
||||
itemSize="20"
|
||||
class="bg-dark p-3 text-light font-monospace log-container"
|
||||
#logContainer>
|
||||
@if (loading && !logFiles.length) {
|
||||
<div>
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</div>
|
||||
}
|
||||
@for (log of logs; track $index) {
|
||||
<p class="m-0 p-0 log-entry-{{getLogLevel(log)}}">{{log}}</p>
|
||||
}
|
||||
</div>
|
||||
<p *cdkVirtualFor="let log of logs"
|
||||
class="m-0 p-0"
|
||||
[ngClass]="'log-entry-' + log.level">
|
||||
{{log.message}}
|
||||
</p>
|
||||
</cdk-virtual-scroll-viewport>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
.log-container {
|
||||
overflow-y: scroll;
|
||||
height: calc(100vh - 200px);
|
||||
top: 70px;
|
||||
top: 0;
|
||||
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
CdkVirtualScrollViewport,
|
||||
ScrollingModule,
|
||||
} from '@angular/cdk/scrolling'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
@@ -38,6 +43,9 @@ describe('LogsComponent', () => {
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
LogsComponent,
|
||||
PageHeaderComponent,
|
||||
CommonModule,
|
||||
CdkVirtualScrollViewport,
|
||||
ScrollingModule,
|
||||
],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
@@ -54,13 +62,12 @@ describe('LogsComponent', () => {
|
||||
fixture = TestBed.createComponent(LogsComponent)
|
||||
component = fixture.componentInstance
|
||||
reloadSpy = jest.spyOn(component, 'reloadLogs')
|
||||
window.HTMLElement.prototype.scroll = function () {} // mock scroll
|
||||
jest.useFakeTimers()
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should display logs with first log initially', () => {
|
||||
expect(logSpy).toHaveBeenCalledWith('paperless')
|
||||
expect(logSpy).toHaveBeenCalledWith('paperless', 5000)
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.nativeElement.textContent).toContain(
|
||||
paperless_logs[0]
|
||||
@@ -71,7 +78,7 @@ describe('LogsComponent', () => {
|
||||
fixture.debugElement
|
||||
.queryAll(By.directive(NgbNavLink))[1]
|
||||
.nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||
expect(logSpy).toHaveBeenCalledWith('mail')
|
||||
expect(logSpy).toHaveBeenCalledWith('mail', 5000)
|
||||
})
|
||||
|
||||
it('should handle error with no logs', () => {
|
||||
@@ -83,6 +90,10 @@ describe('LogsComponent', () => {
|
||||
})
|
||||
|
||||
it('should auto refresh, allow toggle', () => {
|
||||
jest
|
||||
.spyOn(CdkVirtualScrollViewport.prototype, 'scrollToIndex')
|
||||
.mockImplementation(() => undefined)
|
||||
|
||||
jest.advanceTimersByTime(6000)
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||
|
||||
@@ -90,4 +101,13 @@ describe('LogsComponent', () => {
|
||||
jest.advanceTimersByTime(6000)
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should debounce limit changes before reloading logs', () => {
|
||||
const initialCalls = reloadSpy.mock.calls.length
|
||||
component.onLimitChange(6000)
|
||||
jest.advanceTimersByTime(299)
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(initialCalls)
|
||||
jest.advanceTimersByTime(1)
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(initialCalls + 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import {
|
||||
CdkVirtualScrollViewport,
|
||||
ScrollingModule,
|
||||
} from '@angular/cdk/scrolling'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
@@ -9,7 +13,7 @@ import {
|
||||
} from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { filter, takeUntil, timer } from 'rxjs'
|
||||
import { Subject, debounceTime, filter, takeUntil, timer } from 'rxjs'
|
||||
import { LogService } from 'src/app/services/rest/log.service'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
@@ -21,8 +25,11 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
||||
imports: [
|
||||
PageHeaderComponent,
|
||||
NgbNavModule,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
CdkVirtualScrollViewport,
|
||||
ScrollingModule,
|
||||
],
|
||||
})
|
||||
export class LogsComponent
|
||||
@@ -32,7 +39,7 @@ export class LogsComponent
|
||||
private logService = inject(LogService)
|
||||
private changedetectorRef = inject(ChangeDetectorRef)
|
||||
|
||||
public logs: string[] = []
|
||||
public logs: Array<{ message: string; level: number }> = []
|
||||
|
||||
public logFiles: string[] = []
|
||||
|
||||
@@ -40,9 +47,17 @@ export class LogsComponent
|
||||
|
||||
public autoRefreshEnabled: boolean = true
|
||||
|
||||
@ViewChild('logContainer') logContainer: ElementRef
|
||||
public limit: number = 5000
|
||||
|
||||
private readonly limitChange$ = new Subject<number>()
|
||||
|
||||
@ViewChild('logContainer') logContainer: CdkVirtualScrollViewport
|
||||
|
||||
ngOnInit(): void {
|
||||
this.limitChange$
|
||||
.pipe(debounceTime(300), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => this.reloadLogs())
|
||||
|
||||
this.logService
|
||||
.list()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
@@ -68,16 +83,33 @@ export class LogsComponent
|
||||
super.ngOnDestroy()
|
||||
}
|
||||
|
||||
onLimitChange(limit: number): void {
|
||||
this.limitChange$.next(limit)
|
||||
}
|
||||
|
||||
reloadLogs() {
|
||||
this.loading = true
|
||||
this.logService
|
||||
.get(this.activeLog)
|
||||
.get(this.activeLog, this.limit)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.logs = result
|
||||
this.loading = false
|
||||
this.scrollToBottom()
|
||||
const parsed = this.parseLogsWithLevel(result)
|
||||
const hasChanges =
|
||||
parsed.length !== this.logs.length ||
|
||||
parsed.some((log, idx) => {
|
||||
const current = this.logs[idx]
|
||||
return (
|
||||
!current ||
|
||||
current.message !== log.message ||
|
||||
current.level !== log.level
|
||||
)
|
||||
})
|
||||
if (hasChanges) {
|
||||
this.logs = parsed
|
||||
this.scrollToBottom()
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.logs = []
|
||||
@@ -100,12 +132,19 @@ export class LogsComponent
|
||||
}
|
||||
}
|
||||
|
||||
private parseLogsWithLevel(
|
||||
logs: string[]
|
||||
): Array<{ message: string; level: number }> {
|
||||
return logs.map((log) => ({
|
||||
message: log,
|
||||
level: this.getLogLevel(log),
|
||||
}))
|
||||
}
|
||||
|
||||
scrollToBottom(): void {
|
||||
this.changedetectorRef.detectChanges()
|
||||
this.logContainer?.nativeElement.scroll({
|
||||
top: this.logContainer.nativeElement.scrollHeight,
|
||||
left: 0,
|
||||
behavior: 'auto',
|
||||
})
|
||||
if (this.logContainer) {
|
||||
this.logContainer.scrollToIndex(this.logs.length - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
>
|
||||
</pngx-page-header>
|
||||
|
||||
@if (users) {
|
||||
@if (canViewUsers && users) {
|
||||
<h4 class="d-flex">
|
||||
<ng-container i18n>Users</ng-container>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
||||
@@ -45,7 +45,7 @@
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (groups) {
|
||||
@if (canViewGroups && groups) {
|
||||
<h4 class="mt-4 d-flex">
|
||||
<ng-container i18n>Groups</ng-container>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
||||
@@ -86,7 +86,7 @@
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (!users || !groups) {
|
||||
@if ((canViewUsers && !users) || (canViewGroups && !groups)) {
|
||||
<div>
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
|
||||
@@ -5,7 +5,11 @@ import { Subject, first, takeUntil } from 'rxjs'
|
||||
import { Group } from 'src/app/data/group'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
PermissionsService,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
@@ -44,30 +48,48 @@ export class UsersAndGroupsComponent
|
||||
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
ngOnInit(): void {
|
||||
this.usersService
|
||||
.listAll(null, null, { full_perms: true })
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (r) => {
|
||||
this.users = r.results
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error retrieving users`, e)
|
||||
},
|
||||
})
|
||||
public get canViewUsers(): boolean {
|
||||
return this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.User
|
||||
)
|
||||
}
|
||||
|
||||
this.groupsService
|
||||
.listAll(null, null, { full_perms: true })
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (r) => {
|
||||
this.groups = r.results
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error retrieving groups`, e)
|
||||
},
|
||||
})
|
||||
public get canViewGroups(): boolean {
|
||||
return this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Group
|
||||
)
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.canViewUsers) {
|
||||
this.usersService
|
||||
.listAll(null, null, { full_perms: true })
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (r) => {
|
||||
this.users = r.results
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error retrieving users`, e)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (this.canViewGroups) {
|
||||
this.groupsService
|
||||
.listAll(null, null, { full_perms: true })
|
||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (r) => {
|
||||
this.groups = r.results
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error retrieving groups`, e)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@@ -68,13 +68,15 @@
|
||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating"
|
||||
[ngbCollapse]="isMenuCollapsed">
|
||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
||||
@if (slimSidebarEnabled) {
|
||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
|
||||
} @else {
|
||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs>
|
||||
}
|
||||
</button>
|
||||
@if (canSaveSettings) {
|
||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
||||
@if (slimSidebarEnabled) {
|
||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
|
||||
} @else {
|
||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item app-link">
|
||||
|
||||
@@ -152,6 +152,19 @@ export class AppFrameComponent
|
||||
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
||||
}
|
||||
|
||||
get canSaveSettings(): boolean {
|
||||
return (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.Change,
|
||||
PermissionType.UISettings
|
||||
) &&
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.Add,
|
||||
PermissionType.UISettings
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
get slimSidebarEnabled(): boolean {
|
||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||
}
|
||||
|
||||
+1
@@ -63,6 +63,7 @@
|
||||
bindValue="id"
|
||||
[(ngModel)]="atom.value"
|
||||
[disabled]="disabled"
|
||||
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
|
||||
(mousedown)="$event.stopImmediatePropagation()"
|
||||
></ng-select>
|
||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {
|
||||
|
||||
+2
@@ -14,6 +14,7 @@ import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||
import { PasswordComponent } from '../../input/password/password.component'
|
||||
import { SelectComponent } from '../../input/select/select.component'
|
||||
import { TextComponent } from '../../input/text/text.component'
|
||||
@@ -28,6 +29,7 @@ import { PermissionsSelectComponent } from '../../permissions-select/permissions
|
||||
SelectComponent,
|
||||
TextComponent,
|
||||
PasswordComponent,
|
||||
ConfirmButtonComponent,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
|
||||
+5
-3
@@ -77,9 +77,11 @@
|
||||
</div>
|
||||
<div ngbAccordion [closeOthers]="true" cdkDropList (cdkDropListDropped)="onActionDrop($event)">
|
||||
@for (action of object?.actions; track action; let i = $index){
|
||||
<div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]">
|
||||
<div ngbAccordionHeader>
|
||||
<button ngbAccordionButton>{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}}
|
||||
<div ngbAccordionItem [formGroup]="actionFields.controls[i]">
|
||||
<div ngbAccordionHeader cdkDrag>
|
||||
<button ngbAccordionButton>
|
||||
<i-bs name="grip-vertical" class="ms-n3 pe-1"></i-bs>
|
||||
{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}}
|
||||
@if(action.id) {
|
||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span>
|
||||
}
|
||||
|
||||
+4
@@ -11,3 +11,7 @@
|
||||
:host ::ng-deep .filters .paperless-input-select.mb-3 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ms-n3 {
|
||||
margin-left: -1rem !important;
|
||||
}
|
||||
|
||||
+161
@@ -564,6 +564,167 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps children with their parent when parent has document count', () => {
|
||||
const parent: Tag = {
|
||||
id: 10,
|
||||
name: 'Parent Tag',
|
||||
orderIndex: 0,
|
||||
document_count: 2,
|
||||
}
|
||||
const child: Tag = {
|
||||
id: 11,
|
||||
name: 'Child Tag',
|
||||
parent: parent.id,
|
||||
orderIndex: 1,
|
||||
document_count: 0,
|
||||
}
|
||||
const otherRoot: Tag = {
|
||||
id: 20,
|
||||
name: 'Other Tag',
|
||||
orderIndex: 2,
|
||||
document_count: 0,
|
||||
}
|
||||
|
||||
component.selectionModel.items = [parent, child, otherRoot]
|
||||
component.selectionModel = selectionModel
|
||||
component.documentCounts = [
|
||||
{ id: parent.id, document_count: 2 },
|
||||
{ id: otherRoot.id, document_count: 0 },
|
||||
]
|
||||
selectionModel.apply()
|
||||
|
||||
expect(component.selectionModel.items).toEqual([
|
||||
nullItem,
|
||||
parent,
|
||||
child,
|
||||
otherRoot,
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps selected branches ahead of document-based ordering', () => {
|
||||
const selectedRoot: Tag = {
|
||||
id: 30,
|
||||
name: 'Selected Root',
|
||||
orderIndex: 0,
|
||||
document_count: 0,
|
||||
}
|
||||
const otherRoot: Tag = {
|
||||
id: 40,
|
||||
name: 'Other Root',
|
||||
orderIndex: 1,
|
||||
document_count: 2,
|
||||
}
|
||||
|
||||
component.selectionModel.items = [selectedRoot, otherRoot]
|
||||
component.selectionModel = selectionModel
|
||||
selectionModel.set(selectedRoot.id, ToggleableItemState.Selected)
|
||||
component.documentCounts = [
|
||||
{ id: selectedRoot.id, document_count: 0 },
|
||||
{ id: otherRoot.id, document_count: 2 },
|
||||
]
|
||||
selectionModel.apply()
|
||||
|
||||
expect(component.selectionModel.items).toEqual([
|
||||
nullItem,
|
||||
selectedRoot,
|
||||
otherRoot,
|
||||
])
|
||||
})
|
||||
|
||||
it('uses fallback document counts when selection data is missing', () => {
|
||||
const fallbackRoot: Tag = {
|
||||
id: 50,
|
||||
name: 'Fallback Root',
|
||||
orderIndex: 0,
|
||||
document_count: 3,
|
||||
}
|
||||
const fallbackChild: Tag = {
|
||||
id: 51,
|
||||
name: 'Fallback Child',
|
||||
parent: fallbackRoot.id,
|
||||
orderIndex: 1,
|
||||
document_count: 0,
|
||||
}
|
||||
const otherRoot: Tag = {
|
||||
id: 60,
|
||||
name: 'Other Root',
|
||||
orderIndex: 2,
|
||||
document_count: 0,
|
||||
}
|
||||
|
||||
component.selectionModel = selectionModel
|
||||
selectionModel.items = [fallbackRoot, fallbackChild, otherRoot]
|
||||
component.documentCounts = [{ id: otherRoot.id, document_count: 0 }]
|
||||
|
||||
selectionModel.apply()
|
||||
|
||||
expect(selectionModel.items).toEqual([
|
||||
nullItem,
|
||||
fallbackRoot,
|
||||
fallbackChild,
|
||||
otherRoot,
|
||||
])
|
||||
})
|
||||
|
||||
it('handles special and non-numeric ids when promoting branches', () => {
|
||||
const rootWithDocs: Tag = {
|
||||
id: 70,
|
||||
name: 'Root With Docs',
|
||||
orderIndex: 0,
|
||||
document_count: 1,
|
||||
}
|
||||
const miscItem: any = { id: 'misc', name: 'Misc Item' }
|
||||
|
||||
component.selectionModel = selectionModel
|
||||
selectionModel.intersection = Intersection.Exclude
|
||||
selectionModel.items = [rootWithDocs, miscItem as any]
|
||||
component.documentCounts = [{ id: rootWithDocs.id, document_count: 1 }]
|
||||
|
||||
selectionModel.apply()
|
||||
|
||||
expect(selectionModel.items.map((item) => item.id)).toEqual([
|
||||
NEGATIVE_NULL_FILTER_VALUE,
|
||||
rootWithDocs.id,
|
||||
'misc',
|
||||
])
|
||||
})
|
||||
|
||||
it('memoizes root document counts between lookups', () => {
|
||||
const memoRoot: Tag = { id: 80, name: 'Memo Root' }
|
||||
selectionModel.items = [memoRoot]
|
||||
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
|
||||
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||
|
||||
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
||||
selectionModel.documentCounts = []
|
||||
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
||||
})
|
||||
|
||||
it('falls back to model stored document counts if selection data missing entry', () => {
|
||||
const rootWithoutSelection: Tag = {
|
||||
id: 90,
|
||||
name: 'Fallback Root',
|
||||
document_count: 4,
|
||||
}
|
||||
selectionModel.items = [rootWithoutSelection]
|
||||
selectionModel.documentCounts = []
|
||||
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||
|
||||
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
|
||||
})
|
||||
|
||||
it('defaults to zero document count when neither selection nor model provide it', () => {
|
||||
const rootWithoutCounts: Tag = { id: 91, name: 'Fallback Zero Root' }
|
||||
selectionModel.items = [rootWithoutCounts]
|
||||
selectionModel.documentCounts = []
|
||||
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||
|
||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||
})
|
||||
|
||||
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
|
||||
component.selectionModel.items = items
|
||||
component.icon = 'tag-fill'
|
||||
|
||||
+186
@@ -32,6 +32,14 @@ export interface ChangedItems {
|
||||
itemsToRemove: MatchingModel[]
|
||||
}
|
||||
|
||||
type BranchSummary = {
|
||||
items: MatchingModel[]
|
||||
firstIndex: number
|
||||
special: boolean
|
||||
selected: boolean
|
||||
hasDocs: boolean
|
||||
}
|
||||
|
||||
export enum LogicalOperator {
|
||||
And = 'and',
|
||||
Or = 'or',
|
||||
@@ -147,6 +155,10 @@ export class FilterableDropdownSelectionModel {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
})
|
||||
|
||||
if (this._documentCounts.length) {
|
||||
this.promoteBranchesWithDocumentCounts()
|
||||
}
|
||||
}
|
||||
|
||||
private selectionStates = new Map<number, ToggleableItemState>()
|
||||
@@ -380,6 +392,180 @@ export class FilterableDropdownSelectionModel {
|
||||
return this._documentCounts.find((c) => c.id === id)?.document_count
|
||||
}
|
||||
|
||||
private promoteBranchesWithDocumentCounts() {
|
||||
const parentById = this.buildParentById()
|
||||
const findRootId = this.createRootFinder(parentById)
|
||||
const getRootDocCount = this.createRootDocCounter()
|
||||
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
|
||||
const orderedBranches = this.orderBranchesByPriority(summaries)
|
||||
|
||||
this._items = orderedBranches.flatMap((summary) => summary.items)
|
||||
}
|
||||
|
||||
private buildParentById(): Map<number, number | null> {
|
||||
const parentById = new Map<number, number | null>()
|
||||
|
||||
for (const item of this._items) {
|
||||
if (typeof item?.id === 'number') {
|
||||
const parentValue = (item as any)['parent']
|
||||
parentById.set(
|
||||
item.id,
|
||||
typeof parentValue === 'number' ? parentValue : null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return parentById
|
||||
}
|
||||
|
||||
private createRootFinder(
|
||||
parentById: Map<number, number | null>
|
||||
): (id: number) => number {
|
||||
const rootMemo = new Map<number, number>()
|
||||
|
||||
const findRootId = (id: number): number => {
|
||||
const cached = rootMemo.get(id)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const parentId = parentById.get(id)
|
||||
if (parentId === undefined || parentId === null) {
|
||||
rootMemo.set(id, id)
|
||||
return id
|
||||
}
|
||||
|
||||
const rootId = findRootId(parentId)
|
||||
rootMemo.set(id, rootId)
|
||||
return rootId
|
||||
}
|
||||
|
||||
return findRootId
|
||||
}
|
||||
|
||||
private createRootDocCounter(): (rootId: number) => number {
|
||||
const docCountMemo = new Map<number, number>()
|
||||
|
||||
return (rootId: number): number => {
|
||||
const cached = docCountMemo.get(rootId)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const explicit = this.getDocumentCount(rootId)
|
||||
if (typeof explicit === 'number') {
|
||||
docCountMemo.set(rootId, explicit)
|
||||
return explicit
|
||||
}
|
||||
|
||||
const rootItem = this._items.find((i) => i.id === rootId)
|
||||
const fallback =
|
||||
typeof (rootItem as any)?.['document_count'] === 'number'
|
||||
? (rootItem as any)['document_count']
|
||||
: 0
|
||||
|
||||
docCountMemo.set(rootId, fallback)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
private buildBranchSummaries(
|
||||
findRootId: (id: number) => number,
|
||||
getRootDocCount: (rootId: number) => number
|
||||
): Map<string, BranchSummary> {
|
||||
const summaries = new Map<string, BranchSummary>()
|
||||
|
||||
for (const [index, item] of this._items.entries()) {
|
||||
const { key, special, rootId } = this.describeBranchItem(
|
||||
item,
|
||||
index,
|
||||
findRootId
|
||||
)
|
||||
|
||||
let summary = summaries.get(key)
|
||||
if (!summary) {
|
||||
summary = {
|
||||
items: [],
|
||||
firstIndex: index,
|
||||
special,
|
||||
selected: false,
|
||||
hasDocs:
|
||||
special || rootId === null ? false : getRootDocCount(rootId) > 0,
|
||||
}
|
||||
summaries.set(key, summary)
|
||||
}
|
||||
|
||||
summary.items.push(item)
|
||||
|
||||
if (this.shouldMarkSummarySelected(summary, item)) {
|
||||
summary.selected = true
|
||||
}
|
||||
}
|
||||
|
||||
return summaries
|
||||
}
|
||||
|
||||
private describeBranchItem(
|
||||
item: MatchingModel,
|
||||
index: number,
|
||||
findRootId: (id: number) => number
|
||||
): { key: string; special: boolean; rootId: number | null } {
|
||||
if (item?.id === null) {
|
||||
return { key: 'null', special: true, rootId: null }
|
||||
}
|
||||
|
||||
if (item?.id === NEGATIVE_NULL_FILTER_VALUE) {
|
||||
return { key: 'neg-null', special: true, rootId: null }
|
||||
}
|
||||
|
||||
if (typeof item?.id === 'number') {
|
||||
const rootId = findRootId(item.id)
|
||||
return { key: `root-${rootId}`, special: false, rootId }
|
||||
}
|
||||
|
||||
return { key: `misc-${index}`, special: false, rootId: null }
|
||||
}
|
||||
|
||||
private shouldMarkSummarySelected(
|
||||
summary: BranchSummary,
|
||||
item: MatchingModel
|
||||
): boolean {
|
||||
if (summary.special) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof item?.id !== 'number') {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.getNonTemporary(item.id) !== ToggleableItemState.NotSelected
|
||||
}
|
||||
|
||||
private orderBranchesByPriority(
|
||||
summaries: Map<string, BranchSummary>
|
||||
): BranchSummary[] {
|
||||
return Array.from(summaries.values()).sort((a, b) => {
|
||||
const rankDiff = this.branchRank(a) - this.branchRank(b)
|
||||
if (rankDiff !== 0) {
|
||||
return rankDiff
|
||||
}
|
||||
if (a.hasDocs !== b.hasDocs) {
|
||||
return a.hasDocs ? -1 : 1
|
||||
}
|
||||
return a.firstIndex - b.firstIndex
|
||||
})
|
||||
}
|
||||
|
||||
private branchRank(summary: BranchSummary): number {
|
||||
if (summary.special) {
|
||||
return -1
|
||||
}
|
||||
if (summary.selected) {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
init(map: Map<number, ToggleableItemState>) {
|
||||
this.temporarySelectionStates = map
|
||||
this.apply()
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
[multiple]="multiple"
|
||||
[bindLabel]="bindLabel"
|
||||
bindValue="id"
|
||||
[virtualScroll]="items?.length > 100"
|
||||
(change)="onChange(value)"
|
||||
(search)="onSearch($event)"
|
||||
(focus)="clearLastSearchTerm()"
|
||||
|
||||
@@ -106,6 +106,7 @@ describe('TagsComponent', () => {
|
||||
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = { id: 1 }
|
||||
fixture = TestBed.createComponent(TagsComponent)
|
||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||
component = fixture.componentInstance
|
||||
@@ -138,7 +139,7 @@ describe('TagsComponent', () => {
|
||||
settingsService.currentUser = { id: 1 }
|
||||
let activeInstances: NgbModalRef[]
|
||||
modalService.activeInstances.subscribe((v) => (activeInstances = v))
|
||||
component.select.searchTerm = 'foobar'
|
||||
component.select.filter('foobar')
|
||||
component.createTag()
|
||||
expect(modalService.hasOpenModals()).toBeTruthy()
|
||||
expect(activeInstances[0].componentInstance.object.name).toEqual('foobar')
|
||||
|
||||
@@ -169,7 +169,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
if (name) modal.componentInstance.object = { name: name }
|
||||
else if (this.select.searchTerm)
|
||||
modal.componentInstance.object = { name: this.select.searchTerm }
|
||||
this.select.searchTerm = null
|
||||
this.select.filter(null)
|
||||
this.select.detectChanges()
|
||||
return firstValueFrom(
|
||||
(modal.componentInstance as TagEditDialogComponent).succeeded.pipe(
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
@if (clickable) {
|
||||
<a [title]="linkTitle" class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</a>
|
||||
}
|
||||
} @else if (loading) {
|
||||
<span class="placeholder-glow">
|
||||
<span class="placeholder badge private">
|
||||
<span class="text-dark">Loading...</span>
|
||||
</span>
|
||||
</span>
|
||||
} @else {
|
||||
@if (!clickable) {
|
||||
<span class="badge private" i18n>Private</span>
|
||||
|
||||
@@ -53,4 +53,8 @@ export class TagComponent {
|
||||
|
||||
@Input()
|
||||
showParents: boolean = false
|
||||
|
||||
public get loading(): boolean {
|
||||
return this.tagService.loading
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1489,6 +1489,8 @@ describe('DocumentDetailComponent', () => {
|
||||
mockContentWindow.onafterprint(new Event('afterprint'))
|
||||
}
|
||||
|
||||
tick(500)
|
||||
|
||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
|
||||
@@ -1512,65 +1514,97 @@ describe('DocumentDetailComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should show error toast if printing throws inside iframe', fakeAsync(() => {
|
||||
initNormally()
|
||||
const iframePrintErrorCases: Array<{
|
||||
description: string
|
||||
thrownError: Error
|
||||
expectToast: boolean
|
||||
}> = [
|
||||
{
|
||||
description: 'should show error toast if printing throws inside iframe',
|
||||
thrownError: new Error('focus failed'),
|
||||
expectToast: true,
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should suppress toast if cross-origin afterprint error occurs',
|
||||
thrownError: new DOMException(
|
||||
'Accessing onafterprint triggered a cross-origin violation',
|
||||
'SecurityError'
|
||||
),
|
||||
expectToast: false,
|
||||
},
|
||||
]
|
||||
|
||||
const appendChildSpy = jest
|
||||
.spyOn(document.body, 'appendChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const removeChildSpy = jest
|
||||
.spyOn(document.body, 'removeChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const createObjectURLSpy = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:mock-url')
|
||||
const revokeObjectURLSpy = jest
|
||||
.spyOn(URL, 'revokeObjectURL')
|
||||
.mockImplementation(() => {})
|
||||
iframePrintErrorCases.forEach(({ description, thrownError, expectToast }) => {
|
||||
it(
|
||||
description,
|
||||
fakeAsync(() => {
|
||||
initNormally()
|
||||
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
const appendChildSpy = jest
|
||||
.spyOn(document.body, 'appendChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const removeChildSpy = jest
|
||||
.spyOn(document.body, 'removeChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const createObjectURLSpy = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:mock-url')
|
||||
const revokeObjectURLSpy = jest
|
||||
.spyOn(URL, 'revokeObjectURL')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const mockContentWindow = {
|
||||
focus: jest.fn().mockImplementation(() => {
|
||||
throw new Error('focus failed')
|
||||
}),
|
||||
print: jest.fn(),
|
||||
onafterprint: null,
|
||||
}
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
const mockIframe: any = {
|
||||
style: {},
|
||||
src: '',
|
||||
onload: null,
|
||||
contentWindow: mockContentWindow,
|
||||
}
|
||||
const mockContentWindow = {
|
||||
focus: jest.fn().mockImplementation(() => {
|
||||
throw thrownError
|
||||
}),
|
||||
print: jest.fn(),
|
||||
onafterprint: null,
|
||||
}
|
||||
|
||||
const createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(mockIframe as any)
|
||||
const mockIframe: any = {
|
||||
style: {},
|
||||
src: '',
|
||||
onload: null,
|
||||
contentWindow: mockContentWindow,
|
||||
}
|
||||
|
||||
const blob = new Blob(['test'], { type: 'application/pdf' })
|
||||
component.printDocument()
|
||||
const createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(mockIframe as any)
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
||||
const blob = new Blob(['test'], { type: 'application/pdf' })
|
||||
component.printDocument()
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
||||
)
|
||||
req.flush(blob)
|
||||
|
||||
tick()
|
||||
|
||||
if (mockIframe.onload) {
|
||||
mockIframe.onload(new Event('load'))
|
||||
}
|
||||
|
||||
tick(200)
|
||||
|
||||
if (expectToast) {
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
} else {
|
||||
expect(toastSpy).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
|
||||
createElementSpy.mockRestore()
|
||||
appendChildSpy.mockRestore()
|
||||
removeChildSpy.mockRestore()
|
||||
createObjectURLSpy.mockRestore()
|
||||
revokeObjectURLSpy.mockRestore()
|
||||
})
|
||||
)
|
||||
req.flush(blob)
|
||||
|
||||
tick()
|
||||
|
||||
if (mockIframe.onload) {
|
||||
mockIframe.onload(new Event('load'))
|
||||
}
|
||||
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
|
||||
createElementSpy.mockRestore()
|
||||
appendChildSpy.mockRestore()
|
||||
removeChildSpy.mockRestore()
|
||||
createObjectURLSpy.mockRestore()
|
||||
revokeObjectURLSpy.mockRestore()
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
||||
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { DeviceDetectorService } from 'ngx-device-detector'
|
||||
import { BehaviorSubject, Observable, of, Subject } from 'rxjs'
|
||||
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
@@ -1452,9 +1452,18 @@ export class DocumentDetailComponent
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
this.toastService.showError($localize`Print failed.`, err)
|
||||
document.body.removeChild(iframe)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
// FF throws cross-origin error on onafterprint
|
||||
const isCrossOriginAfterPrintError =
|
||||
err instanceof DOMException &&
|
||||
err.message.includes('onafterprint')
|
||||
if (!isCrossOriginAfterPrintError) {
|
||||
this.toastService.showError($localize`Print failed.`, err)
|
||||
}
|
||||
timer(100).subscribe(() => {
|
||||
// delay to avoid FF print failure
|
||||
document.body.removeChild(iframe)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -96,9 +96,11 @@
|
||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
|
||||
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!userCanEdit">
|
||||
<i-bs name="envelope"></i-bs> <ng-container i18n>Email</ng-container>
|
||||
</button>
|
||||
@if (emailEnabled) {
|
||||
<button ngbDropdownItem (click)="emailSelected()">
|
||||
<i-bs name="envelope"></i-bs> <ng-container i18n>Email</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -904,6 +904,10 @@ export class BulkEditorComponent
|
||||
})
|
||||
}
|
||||
|
||||
public get emailEnabled(): boolean {
|
||||
return this.settings.get(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||
}
|
||||
|
||||
emailSelected() {
|
||||
const allHaveArchiveVersion = this.list.documents
|
||||
.filter((d) => this.list.selected.has(d.id))
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</div>
|
||||
<div class="d-none d-sm-flex flex-fill me-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text border-0">Select:</span>
|
||||
<span class="input-group-text border-0" i18n>Select:</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm flex-nowrap">
|
||||
@if (list.selected.size > 0) {
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<ng-template #errorPopover>
|
||||
<pre class="small text-light">
|
||||
<pre class="small">
|
||||
{{ mail.error }}
|
||||
</pre>
|
||||
</ng-template>
|
||||
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
::ng-deep .popover {
|
||||
max-width: 350px;
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
|
||||
@if (object.children && object.children.length > 0) {
|
||||
@for (child of object.children; track child) {
|
||||
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: child, depth: depth + 1 }"></ng-container>
|
||||
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: getOriginalObject(child), depth: depth + 1 }"></ng-container>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@@ -347,4 +347,25 @@ describe('ManagementListComponent', () => {
|
||||
expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()
|
||||
expect(component.userCanBulkEdit(PermissionAction.Change)).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should return an original object from filtered child object', () => {
|
||||
const childTag: Tag = {
|
||||
id: 4,
|
||||
name: 'Child Tag',
|
||||
matching_algorithm: MATCH_LITERAL,
|
||||
match: 'child',
|
||||
document_count: 10,
|
||||
parent: 1,
|
||||
}
|
||||
component['unfilteredData'].push(childTag)
|
||||
const original = component.getOriginalObject({ id: 4 } as Tag)
|
||||
expect(original).toEqual(childTag)
|
||||
})
|
||||
|
||||
it('getSelectableIDs should return flat ids when not overridden', () => {
|
||||
const ids = (
|
||||
ManagementListComponent.prototype as any
|
||||
).getSelectableIDs.call({}, [{ id: 1 }, { id: 5 }] as any)
|
||||
expect(ids).toEqual([1, 5])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -145,6 +145,10 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
)
|
||||
}
|
||||
|
||||
public getOriginalObject(object: T): T {
|
||||
return this.unfilteredData.find((d) => d?.id == object?.id) || object
|
||||
}
|
||||
|
||||
reloadData(extraParams: { [key: string]: any } = null) {
|
||||
this.loading = true
|
||||
this.clearSelection()
|
||||
@@ -293,13 +297,19 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
}
|
||||
|
||||
toggleAll(event: PointerEvent) {
|
||||
if ((event.target as HTMLInputElement).checked) {
|
||||
this.selectedObjects = new Set(this.data.map((o) => o.id))
|
||||
const checked = (event.target as HTMLInputElement).checked
|
||||
this.togggleAll = checked
|
||||
if (checked) {
|
||||
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
||||
} else {
|
||||
this.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
protected getSelectableIDs(objects: T[]): number[] {
|
||||
return objects.map((o) => o.id)
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.togggleAll = false
|
||||
this.selectedObjects.clear()
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('TagListComponent', () => {
|
||||
let component: TagListComponent
|
||||
let fixture: ComponentFixture<TagListComponent>
|
||||
let tagService: TagService
|
||||
let listFilteredSpy: jest.SpyInstance
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -39,7 +40,7 @@ describe('TagListComponent', () => {
|
||||
}).compileComponents()
|
||||
|
||||
tagService = TestBed.inject(TagService)
|
||||
jest.spyOn(tagService, 'listFiltered').mockReturnValue(
|
||||
listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue(
|
||||
of({
|
||||
count: 3,
|
||||
all: [1, 2, 3],
|
||||
@@ -72,9 +73,14 @@ describe('TagListComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should filter out child tags if name filter is empty, otherwise show all', () => {
|
||||
it('should omit matching children from top level when their parent is present', () => {
|
||||
const tags = [
|
||||
{ id: 1, name: 'Tag1', parent: null },
|
||||
{
|
||||
id: 1,
|
||||
name: 'Tag1',
|
||||
parent: null,
|
||||
children: [{ id: 2, name: 'Tag2', parent: 1 }],
|
||||
},
|
||||
{ id: 2, name: 'Tag2', parent: 1 },
|
||||
{ id: 3, name: 'Tag3', parent: null },
|
||||
]
|
||||
@@ -85,6 +91,65 @@ describe('TagListComponent', () => {
|
||||
|
||||
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
|
||||
const filteredWithName = component.filterData(tags as any)
|
||||
expect(filteredWithName.length).toBe(3)
|
||||
expect(filteredWithName.length).toBe(2)
|
||||
expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined()
|
||||
expect(
|
||||
filteredWithName
|
||||
.find((t) => t.id === 1)
|
||||
?.children?.some((c) => c.id === 2)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should request only parent tags when no name filter is applied', () => {
|
||||
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
||||
1,
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
{ is_root: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('should include child tags when a name filter is applied', () => {
|
||||
listFilteredSpy.mockClear()
|
||||
component['_nameFilter'] = 'Tag'
|
||||
component.reloadData()
|
||||
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
||||
1,
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
'Tag',
|
||||
true,
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('should include child tags when selecting all', () => {
|
||||
const parent = {
|
||||
id: 10,
|
||||
name: 'Parent',
|
||||
children: [
|
||||
{
|
||||
id: 11,
|
||||
name: 'Child',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
component.data = [parent as any]
|
||||
const selectEvent = { target: { checked: true } } as unknown as PointerEvent
|
||||
component.toggleAll(selectEvent)
|
||||
|
||||
expect(component.selectedObjects.has(10)).toBe(true)
|
||||
expect(component.selectedObjects.has(11)).toBe(true)
|
||||
|
||||
const deselectEvent = {
|
||||
target: { checked: false },
|
||||
} as unknown as PointerEvent
|
||||
component.toggleAll(deselectEvent)
|
||||
expect(component.selectedObjects.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,9 +61,33 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
||||
return $localize`Do you really want to delete the tag "${object.name}"?`
|
||||
}
|
||||
|
||||
override reloadData(extraParams: { [key: string]: any } = null) {
|
||||
const params = this.nameFilter?.length
|
||||
? extraParams
|
||||
: { ...extraParams, is_root: true }
|
||||
super.reloadData(params)
|
||||
}
|
||||
|
||||
filterData(data: Tag[]) {
|
||||
return this.nameFilter?.length
|
||||
? [...data]
|
||||
: data.filter((tag) => !tag.parent)
|
||||
if (!this.nameFilter?.length) {
|
||||
return data.filter((tag) => !tag.parent)
|
||||
}
|
||||
|
||||
// When filtering by name, exclude children if their parent is also present
|
||||
const availableIds = new Set(data.map((tag) => tag.id))
|
||||
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
|
||||
}
|
||||
|
||||
protected override getSelectableIDs(tags: Tag[]): number[] {
|
||||
const ids: number[] = []
|
||||
for (const tag of tags.filter(Boolean)) {
|
||||
if (tag.id != null) {
|
||||
ids.push(tag.id)
|
||||
}
|
||||
if (Array.isArray(tag.children) && tag.children.length) {
|
||||
ids.push(...this.getSelectableIDs(tag.children))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { map, publishReplay, refCount } from 'rxjs/operators'
|
||||
import { map, publishReplay, refCount, tap } from 'rxjs/operators'
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
import { Results } from 'src/app/data/results'
|
||||
import { environment } from 'src/environments/environment'
|
||||
@@ -13,6 +13,11 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
||||
protected http: HttpClient
|
||||
protected resourceName: string
|
||||
|
||||
protected _loading: boolean = false
|
||||
public get loading(): boolean {
|
||||
return this._loading
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.http = inject(HttpClient)
|
||||
}
|
||||
@@ -43,6 +48,7 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
||||
sortReverse?: boolean,
|
||||
extraParams?
|
||||
): Observable<Results<T>> {
|
||||
this._loading = true
|
||||
let httpParams = new HttpParams()
|
||||
if (page) {
|
||||
httpParams = httpParams.set('page', page.toString())
|
||||
@@ -59,9 +65,15 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
||||
httpParams = httpParams.set(extraParamKey, extraParams[extraParamKey])
|
||||
}
|
||||
}
|
||||
return this.http.get<Results<T>>(this.getResourceUrl(), {
|
||||
params: httpParams,
|
||||
})
|
||||
return this.http
|
||||
.get<Results<T>>(this.getResourceUrl(), {
|
||||
params: httpParams,
|
||||
})
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this._loading = false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private _listAll: Observable<Results<T>>
|
||||
@@ -96,6 +108,7 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
||||
}
|
||||
|
||||
getFew(ids: number[], extraParams?): Observable<Results<T>> {
|
||||
this._loading = true
|
||||
let httpParams = new HttpParams()
|
||||
httpParams = httpParams.set('id__in', ids.join(','))
|
||||
httpParams = httpParams.set('ordering', '-id')
|
||||
@@ -105,9 +118,15 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
||||
httpParams = httpParams.set(extraParamKey, extraParams[extraParamKey])
|
||||
}
|
||||
}
|
||||
return this.http.get<Results<T>>(this.getResourceUrl(), {
|
||||
params: httpParams,
|
||||
})
|
||||
return this.http
|
||||
.get<Results<T>>(this.getResourceUrl(), {
|
||||
params: httpParams,
|
||||
})
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this._loading = false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
@@ -115,7 +134,12 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
||||
}
|
||||
|
||||
get(id: number): Observable<T> {
|
||||
return this.http.get<T>(this.getResourceUrl(id))
|
||||
this._loading = true
|
||||
return this.http.get<T>(this.getResourceUrl(id)).pipe(
|
||||
tap(() => {
|
||||
this._loading = false
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
create(o: T): Observable<T> {
|
||||
|
||||
@@ -49,4 +49,14 @@ describe('LogService', () => {
|
||||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
})
|
||||
|
||||
it('should pass limit param on logs get when provided', () => {
|
||||
const id: string = 'mail'
|
||||
const limit: number = 100
|
||||
subscription = service.get(id, limit).subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/${id}/?limit=${limit}`
|
||||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { HttpClient, HttpParams } from '@angular/common/http'
|
||||
import { Injectable, inject } from '@angular/core'
|
||||
import { Observable } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
@@ -13,7 +13,13 @@ export class LogService {
|
||||
return this.http.get<string[]>(`${environment.apiBaseUrl}logs/`)
|
||||
}
|
||||
|
||||
get(id: string): Observable<string[]> {
|
||||
return this.http.get<string[]>(`${environment.apiBaseUrl}logs/${id}/`)
|
||||
get(id: string, limit?: number): Observable<string[]> {
|
||||
let params = new HttpParams()
|
||||
if (limit !== undefined) {
|
||||
params = params.set('limit', limit.toString())
|
||||
}
|
||||
return this.http.get<string[]>(`${environment.apiBaseUrl}logs/${id}/`, {
|
||||
params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,16 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MailAccountService extends AbstractPaperlessService<MailAccount> {
|
||||
loading: boolean
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.resourceName = 'mail_accounts'
|
||||
}
|
||||
|
||||
private reload() {
|
||||
this.loading = true
|
||||
this._loading = true
|
||||
this.listAll().subscribe((r) => {
|
||||
this.mailAccounts = r.results
|
||||
this.loading = false
|
||||
this._loading = false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -7,18 +7,16 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MailRuleService extends AbstractPaperlessService<MailRule> {
|
||||
loading: boolean
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.resourceName = 'mail_rules'
|
||||
}
|
||||
|
||||
private reload() {
|
||||
this.loading = true
|
||||
this._loading = true
|
||||
this.listAll().subscribe((r) => {
|
||||
this.mailRules = r.results
|
||||
this.loading = false
|
||||
this._loading = false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
|
||||
private settingsService = inject(SettingsService)
|
||||
private documentService = inject(DocumentService)
|
||||
|
||||
public loading: boolean = true
|
||||
private savedViews: SavedView[] = []
|
||||
private savedViewDocumentCounts: Map<number, number> = new Map()
|
||||
private unsubscribeNotifier: Subject<void> = new Subject<void>()
|
||||
@@ -38,12 +37,12 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
|
||||
tap({
|
||||
next: (r) => {
|
||||
this.savedViews = r.results
|
||||
this.loading = false
|
||||
this._loading = false
|
||||
this.settingsService.dashboardIsEmpty =
|
||||
this.dashboardViews.length === 0
|
||||
},
|
||||
error: () => {
|
||||
this.loading = false
|
||||
this._loading = false
|
||||
this.settingsService.dashboardIsEmpty = true
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,18 +7,16 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class WorkflowService extends AbstractPaperlessService<Workflow> {
|
||||
loading: boolean
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.resourceName = 'workflows'
|
||||
}
|
||||
|
||||
public reload() {
|
||||
this.loading = true
|
||||
this._loading = true
|
||||
this.listAll().subscribe((r) => {
|
||||
this.workflows = r.results
|
||||
this.loading = false
|
||||
this._loading = false
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export const environment = {
|
||||
apiVersion: '9', // match src/paperless/settings.py
|
||||
appTitle: 'Paperless-ngx',
|
||||
tag: 'prod',
|
||||
version: '2.19.0',
|
||||
version: '2.19.3',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
||||
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
@@ -92,6 +92,12 @@ class TagFilterSet(FilterSet):
|
||||
"name": CHAR_KWARGS,
|
||||
}
|
||||
|
||||
is_root = BooleanFilter(
|
||||
label="Is root tag",
|
||||
field_name="tn_parent",
|
||||
lookup_expr="isnull",
|
||||
)
|
||||
|
||||
|
||||
class DocumentTypeFilterSet(FilterSet):
|
||||
class Meta:
|
||||
|
||||
+17
-12
@@ -7,6 +7,8 @@ from django.conf import settings
|
||||
from django.core.mail import EmailMessage
|
||||
from filelock import FileLock
|
||||
|
||||
from documents.data_models import ConsumableDocument
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.models import Document
|
||||
|
||||
@@ -15,7 +17,7 @@ def send_email(
|
||||
subject: str,
|
||||
body: str,
|
||||
to: list[str],
|
||||
attachments: list[Document],
|
||||
attachments: list[Document | ConsumableDocument],
|
||||
*,
|
||||
use_archive: bool,
|
||||
) -> int:
|
||||
@@ -45,17 +47,20 @@ def send_email(
|
||||
# Something could be renaming the file concurrently so it can't be attached
|
||||
with FileLock(settings.MEDIA_LOCK):
|
||||
for document in attachments:
|
||||
attachment_path = (
|
||||
document.archive_path
|
||||
if use_archive and document.has_archive_version
|
||||
else document.source_path
|
||||
)
|
||||
|
||||
friendly_filename = _get_unique_filename(
|
||||
document,
|
||||
used_filenames,
|
||||
archive=use_archive and document.has_archive_version,
|
||||
)
|
||||
if isinstance(document, ConsumableDocument):
|
||||
attachment_path = document.original_file
|
||||
friendly_filename = document.original_file.name
|
||||
else:
|
||||
attachment_path = (
|
||||
document.archive_path
|
||||
if use_archive and document.has_archive_version
|
||||
else document.source_path
|
||||
)
|
||||
friendly_filename = _get_unique_filename(
|
||||
document,
|
||||
used_filenames,
|
||||
archive=use_archive and document.has_archive_version,
|
||||
)
|
||||
used_filenames.add(friendly_filename)
|
||||
|
||||
with attachment_path.open("rb") as f:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Generated by Django 5.2.5 on 2025-08-27 22:02
|
||||
import logging
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
from documents.templating.utils import convert_format_str_to_template_format
|
||||
|
||||
logger = logging.getLogger("paperless.migrations")
|
||||
|
||||
|
||||
def convert_from_format_to_template(apps, schema_editor):
|
||||
WorkflowAction = apps.get_model("documents", "WorkflowAction")
|
||||
|
||||
batch_size = 500
|
||||
actions_to_update = []
|
||||
|
||||
queryset = (
|
||||
WorkflowAction.objects.filter(assign_title__isnull=False)
|
||||
.exclude(assign_title="")
|
||||
.only("id", "assign_title")
|
||||
)
|
||||
|
||||
for action in queryset:
|
||||
action.assign_title = convert_format_str_to_template_format(
|
||||
action.assign_title,
|
||||
)
|
||||
logger.debug(
|
||||
"Converted WorkflowAction id %d title to template format: %s",
|
||||
action.id,
|
||||
action.assign_title,
|
||||
)
|
||||
actions_to_update.append(action)
|
||||
|
||||
if actions_to_update:
|
||||
WorkflowAction.objects.bulk_update(
|
||||
actions_to_update,
|
||||
["assign_title"],
|
||||
batch_size=batch_size,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "1072_workflowtrigger_filter_custom_field_query_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="workflowaction",
|
||||
name="assign_title",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="Assign a document title, must be a Jinja2 template, see documentation.",
|
||||
null=True,
|
||||
verbose_name="assign title",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
convert_from_format_to_template,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-27 15:11
|
||||
|
||||
from django.db import migrations
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("documents", "1073_migrate_workflow_title_jinja"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="workflowrun",
|
||||
name="deleted_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workflowrun",
|
||||
name="restored_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="workflowrun",
|
||||
name="transaction_id",
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1547,7 +1547,7 @@ class Workflow(models.Model):
|
||||
return f"Workflow: {self.name}"
|
||||
|
||||
|
||||
class WorkflowRun(models.Model):
|
||||
class WorkflowRun(SoftDeleteModel):
|
||||
workflow = models.ForeignKey(
|
||||
Workflow,
|
||||
on_delete=models.CASCADE,
|
||||
|
||||
@@ -2,6 +2,7 @@ from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from django.db.models import QuerySet
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
from guardian.models import GroupObjectPermission
|
||||
@@ -12,6 +13,8 @@ from guardian.shortcuts import remove_perm
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.permissions import DjangoObjectPermissions
|
||||
|
||||
from documents.models import Document
|
||||
|
||||
|
||||
class PaperlessObjectPermissions(DjangoObjectPermissions):
|
||||
"""
|
||||
@@ -125,6 +128,25 @@ def set_permissions_for_object(permissions: list[str], object, *, merge: bool =
|
||||
)
|
||||
|
||||
|
||||
def get_document_count_filter_for_user(user):
|
||||
"""
|
||||
Return the Q object used to filter document counts for the given user.
|
||||
"""
|
||||
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
return Q(documents__deleted_at__isnull=True, documents__owner__isnull=True)
|
||||
if getattr(user, "is_superuser", False):
|
||||
return Q(documents__deleted_at__isnull=True)
|
||||
return Q(
|
||||
documents__deleted_at__isnull=True,
|
||||
documents__id__in=get_objects_for_user_owner_aware(
|
||||
user,
|
||||
"documents.view_document",
|
||||
Document,
|
||||
).values_list("id", flat=True),
|
||||
)
|
||||
|
||||
|
||||
def get_objects_for_user_owner_aware(user, perms, Model) -> QuerySet:
|
||||
objects_owned = Model.objects.filter(owner=user)
|
||||
objects_unowned = Model.objects.filter(owner__isnull=True)
|
||||
@@ -142,6 +164,24 @@ def has_perms_owner_aware(user, perms, obj):
|
||||
return obj.owner is None or obj.owner == user or checker.has_perm(perms, obj)
|
||||
|
||||
|
||||
class ViewDocumentsPermissions(BasePermission):
|
||||
"""
|
||||
Permissions class that checks for model permissions for only viewing Documents.
|
||||
"""
|
||||
|
||||
perms_map = {
|
||||
"OPTIONS": ["documents.view_document"],
|
||||
"GET": ["documents.view_document"],
|
||||
"POST": ["documents.view_document"],
|
||||
}
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if not request.user or (not request.user.is_authenticated): # pragma: no cover
|
||||
return False
|
||||
|
||||
return request.user.has_perms(self.perms_map.get(request.method, []))
|
||||
|
||||
|
||||
class PaperlessNotePermissions(BasePermission):
|
||||
"""
|
||||
Permissions class that checks for model permissions for Notes.
|
||||
|
||||
@@ -20,6 +20,7 @@ from django.core.validators import EmailValidator
|
||||
from django.core.validators import MaxLengthValidator
|
||||
from django.core.validators import RegexValidator
|
||||
from django.core.validators import integer_validator
|
||||
from django.db.models import Count
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.utils.text import slugify
|
||||
@@ -65,6 +66,7 @@ from documents.models import WorkflowActionEmail
|
||||
from documents.models import WorkflowActionWebhook
|
||||
from documents.models import WorkflowTrigger
|
||||
from documents.parsers import is_mime_type_supported
|
||||
from documents.permissions import get_document_count_filter_for_user
|
||||
from documents.permissions import get_groups_with_only_permission
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.templating.filepath import validate_filepath_template_and_render
|
||||
@@ -572,8 +574,16 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
),
|
||||
)
|
||||
def get_children(self, obj):
|
||||
filter_q = self.context.get("document_count_filter")
|
||||
if filter_q is None:
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None) if request else None
|
||||
filter_q = get_document_count_filter_for_user(user)
|
||||
self.context["document_count_filter"] = filter_q
|
||||
serializer = TagSerializer(
|
||||
obj.get_children(),
|
||||
obj.get_children_queryset()
|
||||
.select_related("owner")
|
||||
.annotate(document_count=Count("documents", filter=filter_q)),
|
||||
many=True,
|
||||
context=self.context,
|
||||
)
|
||||
@@ -1031,7 +1041,7 @@ class DocumentSerializer(
|
||||
request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
|
||||
)
|
||||
|
||||
if api_version < 9:
|
||||
if api_version < 9 and "created" in self.fields:
|
||||
# provide created as a datetime for backwards compatibility
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ from guardian.shortcuts import remove_perm
|
||||
|
||||
from documents import matching
|
||||
from documents.caching import clear_document_caches
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.file_handling import create_source_path_directory
|
||||
from documents.file_handling import delete_empty_directories
|
||||
from documents.file_handling import generate_unique_filename
|
||||
@@ -55,7 +57,6 @@ from documents.templating.workflows import parse_w_workflow_placeholders
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from documents.classifier import DocumentClassifier
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
|
||||
logger = logging.getLogger("paperless.handlers")
|
||||
@@ -1163,8 +1164,21 @@ def run_workflows(
|
||||
)
|
||||
try:
|
||||
attachments = []
|
||||
if action.email.include_document and original_file:
|
||||
attachments = [document]
|
||||
if action.email.include_document:
|
||||
if trigger_type in [
|
||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||
WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
||||
]:
|
||||
# Updated and scheduled can pass the document directly
|
||||
attachments = [document]
|
||||
elif original_file:
|
||||
# For consumed and added document is not yet saved, so pass the original file
|
||||
attachments = [
|
||||
ConsumableDocument(
|
||||
source=DocumentSource.ApiUpload,
|
||||
original_file=original_file,
|
||||
),
|
||||
]
|
||||
n_messages = send_email(
|
||||
subject=subject,
|
||||
body=body,
|
||||
|
||||
@@ -80,7 +80,7 @@ def parse_w_workflow_placeholders(
|
||||
if doc_url is not None:
|
||||
formatting.update({"doc_url": doc_url})
|
||||
|
||||
logger.debug(f"Jinja Template is : {text}")
|
||||
logger.debug(f"Parsing Workflow Jinja template: {text}")
|
||||
try:
|
||||
template = _template_environment.from_string(
|
||||
text,
|
||||
|
||||
@@ -2,9 +2,11 @@ import types
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from documents import index
|
||||
from documents.admin import DocumentAdmin
|
||||
@@ -125,3 +127,36 @@ class TestPaperlessAdmin(DirectoriesMixin, TestCase):
|
||||
form.request = types.SimpleNamespace(user=superuser)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual({}, form.errors)
|
||||
|
||||
def test_superuser_can_only_be_modified_by_superuser(self):
|
||||
superuser = User.objects.create_superuser(username="superuser", password="test")
|
||||
user = User.objects.create(
|
||||
username="test",
|
||||
is_superuser=False,
|
||||
is_staff=True,
|
||||
)
|
||||
change_user_perm = Permission.objects.get(codename="change_user")
|
||||
user.user_permissions.add(change_user_perm)
|
||||
|
||||
self.client.force_login(user)
|
||||
response = self.client.patch(
|
||||
f"/api/users/{superuser.pk}/",
|
||||
{"first_name": "Updated"},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
self.assertEqual(
|
||||
response.content.decode(),
|
||||
"Superusers can only be modified by other superusers",
|
||||
)
|
||||
|
||||
self.client.logout()
|
||||
self.client.force_login(superuser)
|
||||
response = self.client.patch(
|
||||
f"/api/users/{superuser.pk}/",
|
||||
{"first_name": "Updated"},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
superuser.refresh_from_db()
|
||||
self.assertEqual(superuser.first_name, "Updated")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user