Compare commits

..

60 Commits

Author SHA1 Message Date
shamoon e09fc54722 Enforce email enabled for mandatory verification 2025-11-03 17:34:29 -08:00
shamoon a0172a2754 Chore: fix test error 2025-11-03 16:01:45 -08:00
shamoon 810bf3d612 Fix: fix log loading spinner display condition 2025-11-03 15:54:43 -08:00
github-actions[bot] 846cc47565 New Crowdin translations by GitHub Action (#11238) 2025-11-03 15:34:23 -08:00
GitHub Actions 1d396d9160 Auto translate strings 2025-11-03 17:48:25 +00:00
shamoon 2a4e8f9acd Performance: re-enable virtual scroll, bump ng-select (#11279) 2025-11-03 09:46:35 -08:00
shamoon a9dfe8f3f7 Fix: use original_file when attaching docs to workflow emails with added trigger (#11266) 2025-11-03 08:42:29 -08:00
GitHub Actions 906e841ded Auto translate strings 2025-11-03 14:21:38 +00:00
shamoon 6684e80ffc Fix: mark 'Select' button in doc list for translation (#11278) 2025-11-03 06:18:41 -08:00
GitHub Actions 3dc7cf3da1 Auto translate strings 2025-11-01 20:22:23 +00:00
shamoon 819f606335 Chore: hide slim toggler if insufficient permissions 2025-11-01 13:18:49 -07:00
shamoon ad45e3f747 Fix: respect fields parameter for created field (#11251) 2025-11-01 13:13:39 -07:00
shamoon 74b10db028 Fix: improve legibility of processed mail error popover in light mode (#11258) 2025-11-01 12:49:05 -07:00
shamoon cffb9c34f0 Chore: add headers for wikipedia CI tests (#11253) 2025-11-01 09:37:49 -07:00
GitHub Actions 6f52614817 Auto translate strings 2025-11-01 14:53:03 +00:00
shamoon a0d3527d20 Fixhancement: truncate large logs, improve auto-scroll (#11239) 2025-11-01 07:49:52 -07:00
shamoon 4e64ca7ca6 Chore: add max-height and overflow to processedmail error popover (#11252) 2025-11-01 07:49:31 -07:00
GitHub Actions e9511bd3da Auto translate strings 2025-10-31 01:28:27 +00:00
shamoon 8b9ca75a90 Fix: delay iframe DOM removal, handle onafterprint error for print in FF (#11237) 2025-10-30 18:26:42 -07:00
shamoon 9f0a4ac19d Sure sonar, consolidate 2025-10-30 18:00:19 -07:00
shamoon 8f969ecab5 Fix: delay iframe DOM removal for print in FF 2025-10-30 17:24:44 -07:00
shamoon 245e52a4eb Coverage 2025-10-30 17:00:15 -07:00
shamoon a8c75d95d8 Update document-detail.component.ts 2025-10-30 17:00:15 -07:00
shamoon d6e2456baf Update document-detail.component.ts 2025-10-30 17:00:15 -07:00
shamoon 3b75d3271e Fix: delay iframe DOM removal for print in FF 2025-10-30 17:00:15 -07:00
GitHub Actions e88816d141 Auto translate strings 2025-10-30 23:36:37 +00:00
CanbiZ e5bd4713ac Performance: use virtual scroll container and log level parsing for logs view (#11233)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-10-30 23:34:53 +00:00
shamoon b9aced07fb Chore: cache Github version check for 15 minutes (#11235) 2025-10-30 13:53:30 -07:00
shamoon 6b55740f56 Fix: de-deduplicate children in tag list when filtering (#11229) 2025-10-30 07:02:00 -07:00
github-actions[bot] 9aee063347 Documentation: Add v2.19.3 changelog (#11223)
---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-10-29 11:08:29 -07:00
shamoon 7fe411bb1a Bump version to 2.19.3 2025-10-29 10:22:28 -07:00
shamoon 34b5f4c565 Merge branch 'dev' 2025-10-29 10:21:56 -07:00
github-actions[bot] 3808a4e14a New Crowdin translations by GitHub Action (#11161) 2025-10-29 16:27:40 +00:00
dependabot[bot] 3bd4135aba Chore(deps): Bump django from 5.2.6 to 5.2.7 (#11200)
Bumps [django](https://github.com/django/django) from 5.2.6 to 5.2.7.
- [Commits](https://github.com/django/django/compare/5.2.6...5.2.7)

---
updated-dependencies:
- dependency-name: django
  dependency-version: 5.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-29 14:29:42 +00:00
shamoon b60fb8ed82 Fix: remove unnecessary permission requirements for new email endpoint (#11215) 2025-10-29 07:14:51 -07:00
GitHub Actions 3f32ed319a Auto translate strings 2025-10-29 02:56:27 +00:00
shamoon 03e6d58f86 Fix: refactor nested sorting in filterable dropdowns (#11214) 2025-10-28 19:54:39 -07:00
GitHub Actions c197487374 Auto translate strings 2025-10-28 18:07:20 +00:00
shamoon d718d7d29f Fix: add root tag filtering for tag list page consistency, fix toggle all (#11208) 2025-10-28 11:04:22 -07:00
GitHub Actions ce112cda0e Auto translate strings 2025-10-28 17:17:48 +00:00
shamoon d904aaef60 Change: make workflow action only title draggable (#11209) 2025-10-28 10:14:42 -07:00
shamoon 35bc673648 Update workflows.py 2025-10-27 21:09:19 -07:00
shamoon d0bd111eab Change: make workflowrun a softdeletemodel (#11194) 2025-10-27 20:51:39 +00:00
Trenton H cd81f750b4 Chore: Minor migration optimization for workflow titles (#11197)
* Makes the migration just a little more efficient

* Do it in batches, just in case

* Fixes the model klass name
2025-10-27 13:24:57 -07:00
shamoon 48d21da13b Fix: support ConsumableDocument in email attachments (#11196) 2025-10-27 10:37:57 -07:00
shamoon 701aafce06 Update issue and discussion templates 2025-10-26 12:14:31 -07:00
Lukas Behrendt cbe8bc35d6 Chore: fix Postgres compose volume mount path in install script (#11184)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2025-10-26 14:40:37 +00:00
Tom Hu 1c4fa7237c Chore: Move to using the codecov action instead of the test-results-action (#11179) 2025-10-26 07:07:36 -07:00
shamoon 63dab0ab09 Change: restrict superuser modifications to superusers only 2025-10-24 16:25:59 -07:00
shamoon 276dc31abe Fix: add missing import of ConfirmButtonComponent in user-edit-dialog (#11167) 2025-10-24 15:50:46 -07:00
shamoon a11a2ec13f Fix: resolve migration warning in 2.19.2 (#11157) 2025-10-23 15:29:49 -07:00
github-actions[bot] df9136e7d4 Changelog v2.19.2 - GHA (#11153)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-23 10:56:48 -07:00
Trenton H 1d8fadcb3c Bumps version to 2.19.2 2025-10-23 09:24:48 -07:00
github-actions[bot] 4e85262781 New Crowdin translations by GitHub Action (#11139)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-10-23 09:10:11 -07:00
GitHub Actions 7e5d80fa38 Auto translate strings 2025-10-23 12:53:46 +00:00
shamoon 3cfd64b77a Fix: Remove edit requirement for bulk email, show based on setting (#11149) 2025-10-23 05:50:27 -07:00
shamoon 0fc595a16a Fix: handle undefined IDs in getOriginalObject (#11147) 2025-10-23 05:40:01 -07:00
GitHub Actions 91e2220f23 Auto translate strings 2025-10-23 01:05:32 +00:00
shamoon 893c05dfdc Fixhancement: display loading status for tags instead of 'Private' (#11140) 2025-10-22 18:01:50 -07:00
github-actions[bot] faf3e8dc0d Changelog v2.19.1 - GHA (#11138) 2025-10-22 13:46:41 -07:00
151 changed files with 10796 additions and 8854 deletions
+1 -1
View File
@@ -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
+8 -2
View File
@@ -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:
+4 -2
View File
@@ -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:
+82
View File
@@ -1,5 +1,87 @@
# 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
+1 -1
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "2.19.1"
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"
+202 -184
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paperless-ngx-ui",
"version": "2.19.1",
"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",
+6 -6
View File
@@ -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)
}
}
}
@@ -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)
}
@@ -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) {
@@ -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,
],
@@ -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>
}
@@ -11,3 +11,7 @@
:host ::ng-deep .filters .paperless-input-select.mb-3 {
margin-bottom: 0 !important;
}
.ms-n3 {
margin-left: -1rem !important;
}
@@ -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'
@@ -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',
@@ -114,6 +122,13 @@ export class FilterableDropdownSelectionModel {
b.id == NEGATIVE_NULL_FILTER_VALUE)
) {
return 1
}
// Preserve hierarchical order when provided (e.g., Tags)
const ao = (a as any)['orderIndex']
const bo = (b as any)['orderIndex']
if (ao !== undefined && bo !== undefined) {
return ao - bo
} else if (
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
@@ -136,17 +151,14 @@ export class FilterableDropdownSelectionModel {
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
) {
return 1
}
// Preserve hierarchical order when provided (e.g., Tags)
const ao = (a as any)['orderIndex']
const bo = (b as any)['orderIndex']
if (ao !== undefined && bo !== undefined) {
return ao - bo
} else {
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>&nbsp;<ng-container i18n>Merge</ng-container>
</button>
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!userCanEdit">
<i-bs name="envelope"></i-bs>&nbsp;<ng-container i18n>Email</ng-container>
</button>
@if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()">
<i-bs name="envelope"></i-bs>&nbsp;<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) {
@@ -68,7 +68,7 @@
</td>
<td>
<ng-template #errorPopover>
<pre class="small text-light">
<pre class="small">
{{ mail.error }}
</pre>
</ng-template>
@@ -1,5 +1,7 @@
::ng-deep .popover {
max-width: 350px;
max-height: 600px;
overflow: hidden;
pre {
white-space: pre-wrap;
@@ -361,4 +361,11 @@ describe('ManagementListComponent', () => {
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])
})
})
@@ -146,7 +146,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
}
public getOriginalObject(object: T): T {
return this.unfilteredData.find((d) => d.id == object.id)
return this.unfilteredData.find((d) => d?.id == object?.id) || object
}
reloadData(extraParams: { [key: string]: any } = null) {
@@ -297,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')
})
})
+9 -3
View File
@@ -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
})
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const environment = {
apiVersion: '9', // match src/paperless/settings.py
appTitle: 'Paperless-ngx',
tag: 'prod',
version: '2.19.1',
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
+6
View File
@@ -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
View File
@@ -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:
@@ -3,7 +3,6 @@ import logging
from django.db import migrations
from django.db import models
from django.db import transaction
from documents.templating.utils import convert_format_str_to_template_format
@@ -11,21 +10,34 @@ logger = logging.getLogger("paperless.migrations")
def convert_from_format_to_template(apps, schema_editor):
WorkflowActions = apps.get_model("documents", "WorkflowAction")
WorkflowAction = apps.get_model("documents", "WorkflowAction")
with transaction.atomic():
for WorkflowAction in WorkflowActions.objects.all():
if not WorkflowAction.assign_title:
continue
WorkflowAction.assign_title = convert_format_str_to_template_format(
WorkflowAction.assign_title,
)
logger.debug(
"Converted WorkflowAction id %d title to template format: %s",
WorkflowAction.id,
WorkflowAction.assign_title,
)
WorkflowAction.save()
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):
@@ -35,15 +47,13 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterField(
model_name="WorkflowAction",
model_name="workflowaction",
name="assign_title",
field=models.TextField(
null=True,
blank=True,
help_text=(
"Assign a document title, can be a JINJA2 template, "
"see documentation.",
),
help_text="Assign a document title, must be a Jinja2 template, see documentation.",
null=True,
verbose_name="assign title",
),
),
migrations.RunPython(
@@ -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),
),
]
+1 -1
View File
@@ -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,
+18
View File
@@ -164,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.
+1 -1
View File
@@ -1041,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
+17 -3
View File
@@ -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,
+1 -1
View File
@@ -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,
+35
View File
@@ -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")
+46
View File
@@ -172,6 +172,35 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
results = response.data["results"]
self.assertEqual(len(results[0]), 0)
def test_document_fields_api_version_8_respects_created(self):
Document.objects.create(
title="legacy",
checksum="123",
mime_type="application/pdf",
created=date(2024, 1, 15),
)
response = self.client.get(
"/api/documents/?fields=id",
headers={"Accept": "application/json; version=8"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
results = response.data["results"]
self.assertIn("id", results[0])
self.assertNotIn("created", results[0])
response = self.client.get(
"/api/documents/?fields=id,created",
headers={"Accept": "application/json; version=8"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
results = response.data["results"]
self.assertIn("id", results[0])
self.assertIn("created", results[0])
self.assertRegex(results[0]["created"], r"^2024-01-15T00:00:00.*$")
def test_document_legacy_created_format(self):
"""
GIVEN:
@@ -2250,6 +2279,23 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertListEqual(response.data, ["test", "test2"])
def test_get_log_with_limit(self):
log_data = "test1\ntest2\ntest3\n"
with (Path(settings.LOGGING_DIR) / "paperless.log").open("w") as f:
f.write(log_data)
response = self.client.get("/api/logs/paperless/", {"limit": 2})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertListEqual(response.data, ["test2", "test3"])
def test_get_log_with_invalid_limit(self):
log_data = "test1\ntest2\n"
with (Path(settings.LOGGING_DIR) / "paperless.log").open("w") as f:
f.write(log_data)
response = self.client.get("/api/logs/paperless/", {"limit": "abc"})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
response = self.client.get("/api/logs/paperless/", {"limit": -5})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_invalid_regex_other_algorithm(self):
for endpoint in ["correspondents", "tags", "document_types"]:
response = self.client.post(
+28
View File
@@ -329,6 +329,34 @@ class TestEmail(DirectoriesMixin, SampleDirMixin, APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_email_only_requires_view_permission(self):
"""
GIVEN:
- User having only view documents permission
WHEN:
- API request is made to bulk email documents
THEN:
- Request succeeds
"""
user1 = User.objects.create_user(username="test1")
user1.user_permissions.add(*Permission.objects.filter(codename="view_document"))
self.client.force_authenticate(user1)
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"documents": [self.doc1.pk],
"addresses": "test@example.com",
"subject": "Test",
"message": "Test message",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@override_settings(
EMAIL_ENABLED=True,
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
@@ -1,3 +1,4 @@
from django.core.cache import cache
from pytest_httpx import HTTPXMock
from rest_framework import status
from rest_framework.test import APIClient
@@ -8,6 +9,9 @@ from paperless import version
class TestApiRemoteVersion:
ENDPOINT = "/api/remote_version/"
def setup_method(self):
cache.clear()
def test_remote_version_enabled_no_update_prefix(
self,
rest_api_client: APIClient,

Some files were not shown because too many files have changed in this diff Show More