Compare commits

...

16 Commits

Author SHA1 Message Date
dependabot[bot]
f9ee564673 docker(deps): Bump astral-sh/uv
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.10.0-python3.12-trixie-slim to 0.10.4-python3.12-trixie-slim.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.10.0...0.10.4)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.10.4-python3.12-trixie-slim
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-23 23:35:46 +00:00
shamoon
fa13ca7a42 Fix: pass api_base to OpenAIEmbedding (#12151) 2026-02-23 13:47:32 -08:00
Trenton H
814f57b099 Allows the typing job to error and still pass, so we get results, but not failures for now (#12147) 2026-02-23 09:44:35 -08:00
GitHub Actions
be7f1c6233 Auto translate strings 2026-02-22 23:18:50 +00:00
shamoon
d6cd6d0311 Tweakhancement: reset to page 1 on reset filters (#12143) 2026-02-22 15:17:02 -08:00
Daniel Herrmann
095ea3cbd3 Documentation: clarify behaviour around document splitting (#12137)
Co-Authored-By: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-22 08:26:38 -08:00
shamoon
5b667621cd Try not to piss off mypy 2026-02-21 17:48:11 -08:00
shamoon
1b912c137a Merge branch 'main' into dev 2026-02-21 17:45:28 -08:00
github-actions[bot]
98298e37cd Changelog v2.20.8 - GHA (#12135)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-21 17:43:19 -08:00
shamoon
35be0850ec Bump version to 2.20.8 2026-02-21 16:49:52 -08:00
shamoon
1bb4b9b473 More permissions on mail account test endpoint 2026-02-21 16:47:55 -08:00
shamoon
f85094dc2b Set owner on OAuth mail credentials 2026-02-21 16:37:32 -08:00
shamoon
65ca78e9e7 Security: fix/GHSA-7qqc-wrcw-2fj9 2026-02-21 16:34:33 -08:00
GitHub Actions
57c5939d7b Auto translate strings 2026-02-20 20:55:01 +00:00
shamoon
43fe932c57 Fix: unify POSTs when toggling sidebar to prevent db lock (#12129) 2026-02-20 12:53:16 -08:00
shamoon
83f68d6063 Fix mailrule_stop_processing migration 2026-02-20 10:19:16 -08:00
26 changed files with 290 additions and 74 deletions

View File

@@ -129,6 +129,7 @@ jobs:
run: | run: |
uv pip list uv pip list
- name: Check typing (pyrefly) - name: Check typing (pyrefly)
continue-on-error: true
run: | run: |
uv run pyrefly \ uv run pyrefly \
check \ check \
@@ -143,6 +144,7 @@ jobs:
${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}- ${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-
${{ runner.os }}-mypy- ${{ runner.os }}-mypy-
- name: Check typing (mypy) - name: Check typing (mypy)
continue-on-error: true
run: | run: |
uv run mypy \ uv run mypy \
--show-error-codes \ --show-error-codes \

View File

@@ -2192,34 +2192,34 @@ src/paperless_mail/tests/test_mail.py:0: error: "MailMessage" has no attribute "
src/paperless_mail/tests/test_mail.py:0: error: "MailMessage" has no attribute "flagged" [attr-defined] src/paperless_mail/tests/test_mail.py:0: error: "MailMessage" has no attribute "flagged" [attr-defined]
src/paperless_mail/tests/test_mail.py:0: error: "MailMessage" has no attribute "seen" [attr-defined] src/paperless_mail/tests/test_mail.py:0: error: "MailMessage" has no attribute "seen" [attr-defined]
src/paperless_mail/tests/test_mail.py:0: error: "MailMessage" has no attribute "seen" [attr-defined] src/paperless_mail/tests/test_mail.py:0: error: "MailMessage" has no attribute "seen" [attr-defined]
src/paperless_mail/tests/test_mail.py:0: error: "type[att@480]" has no attribute "filename" [attr-defined] src/paperless_mail/tests/test_mail.py:0: error: "type[att@481]" has no attribute "filename" [attr-defined]
src/paperless_mail/tests/test_mail.py:0: error: "type[message2@426]" has no attribute "from_" [attr-defined] src/paperless_mail/tests/test_mail.py:0: error: "type[message2@427]" has no attribute "from_" [attr-defined]
src/paperless_mail/tests/test_mail.py:0: error: "type[message2@426]" has no attribute "from_" [attr-defined] src/paperless_mail/tests/test_mail.py:0: error: "type[message2@427]" has no attribute "from_" [attr-defined]
src/paperless_mail/tests/test_mail.py:0: error: "type[message2@426]" has no attribute "from_values" [attr-defined] src/paperless_mail/tests/test_mail.py:0: error: "type[message2@427]" has no attribute "from_values" [attr-defined]
src/paperless_mail/tests/test_mail.py:0: error: "type[message@419]" has no attribute "from_" [attr-defined] src/paperless_mail/tests/test_mail.py:0: error: "type[message@420]" has no attribute "from_" [attr-defined]
src/paperless_mail/tests/test_mail.py:0: error: "type[message@419]" has no attribute "from_values" [attr-defined] src/paperless_mail/tests/test_mail.py:0: error: "type[message@420]" has no attribute "from_values" [attr-defined]
src/paperless_mail/tests/test_mail.py:0: error: "type[message@478]" has no attribute "subject" [attr-defined] src/paperless_mail/tests/test_mail.py:0: error: "type[message@479]" has no attribute "subject" [attr-defined]
src/paperless_mail/tests/test_mail.py:0: error: "type[message@531]" has no attribute "attachments" [attr-defined] src/paperless_mail/tests/test_mail.py:0: error: "type[message@532]" has no attribute "attachments" [attr-defined]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "MailboxFolderSelectError" has incompatible type "None"; expected "tuple[Any, ...]" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "MailboxFolderSelectError" has incompatible type "None"; expected "tuple[Any, ...]" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "MailboxFolderSelectError" has incompatible type "None"; expected "tuple[Any, ...]" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "MailboxFolderSelectError" has incompatible type "None"; expected "tuple[Any, ...]" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "MailboxLoginError" has incompatible type "str"; expected "tuple[Any, ...]" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "MailboxLoginError" has incompatible type "str"; expected "tuple[Any, ...]" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "MailboxLoginError" has incompatible type "str"; expected "tuple[Any, ...]" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "MailboxLoginError" has incompatible type "str"; expected "tuple[Any, ...]" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "MailboxLoginError" has incompatible type "str"; expected "tuple[Any, ...]" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "MailboxLoginError" has incompatible type "str"; expected "tuple[Any, ...]" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "MailboxLoginError" has incompatible type "str"; expected "tuple[Any, ...]" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "MailboxLoginError" has incompatible type "str"; expected "tuple[Any, ...]" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_correspondent" of "MailAccountHandler" has incompatible type "type[message2@426]"; expected "MailMessage" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_correspondent" of "MailAccountHandler" has incompatible type "type[message2@427]"; expected "MailMessage" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_correspondent" of "MailAccountHandler" has incompatible type "type[message2@426]"; expected "MailMessage" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_correspondent" of "MailAccountHandler" has incompatible type "type[message2@427]"; expected "MailMessage" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_correspondent" of "MailAccountHandler" has incompatible type "type[message@419]"; expected "MailMessage" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_correspondent" of "MailAccountHandler" has incompatible type "type[message@420]"; expected "MailMessage" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_correspondent" of "MailAccountHandler" has incompatible type "type[message@419]"; expected "MailMessage" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_correspondent" of "MailAccountHandler" has incompatible type "type[message@420]"; expected "MailMessage" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_correspondent" of "MailAccountHandler" has incompatible type "type[message@419]"; expected "MailMessage" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_correspondent" of "MailAccountHandler" has incompatible type "type[message@420]"; expected "MailMessage" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_correspondent" of "MailAccountHandler" has incompatible type "type[message@419]"; expected "MailMessage" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_correspondent" of "MailAccountHandler" has incompatible type "type[message@420]"; expected "MailMessage" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_title" of "MailAccountHandler" has incompatible type "type[message@478]"; expected "MailMessage" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_title" of "MailAccountHandler" has incompatible type "type[message@479]"; expected "MailMessage" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_title" of "MailAccountHandler" has incompatible type "type[message@478]"; expected "MailMessage" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_title" of "MailAccountHandler" has incompatible type "type[message@479]"; expected "MailMessage" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_title" of "MailAccountHandler" has incompatible type "type[message@478]"; expected "MailMessage" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "_get_title" of "MailAccountHandler" has incompatible type "type[message@479]"; expected "MailMessage" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "filter" has incompatible type "Callable[[Any], bool]"; expected "Callable[[MailMessage], TypeGuard[Never]]" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "filter" has incompatible type "Callable[[Any], bool]"; expected "Callable[[MailMessage], TypeGuard[Never]]" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "filter" has incompatible type "Callable[[Any], bool]"; expected "Callable[[MailMessage], TypeGuard[Never]]" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 1 to "filter" has incompatible type "Callable[[Any], bool]"; expected "Callable[[MailMessage], TypeGuard[Never]]" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 2 to "_get_title" of "MailAccountHandler" has incompatible type "type[att@480]"; expected "MailAttachment" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 2 to "_get_title" of "MailAccountHandler" has incompatible type "type[att@481]"; expected "MailAttachment" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 2 to "_get_title" of "MailAccountHandler" has incompatible type "type[att@480]"; expected "MailAttachment" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 2 to "_get_title" of "MailAccountHandler" has incompatible type "type[att@481]"; expected "MailAttachment" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 2 to "_get_title" of "MailAccountHandler" has incompatible type "type[att@480]"; expected "MailAttachment" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 2 to "_get_title" of "MailAccountHandler" has incompatible type "type[att@481]"; expected "MailAttachment" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Argument 2 to "assertIn" of "TestCase" has incompatible type "str | None"; expected "Iterable[Any] | Container[Any]" [arg-type] src/paperless_mail/tests/test_mail.py:0: error: Argument 2 to "assertIn" of "TestCase" has incompatible type "str | None"; expected "Iterable[Any] | Container[Any]" [arg-type]
src/paperless_mail/tests/test_mail.py:0: error: Dict entry 0 has incompatible type "str": "None"; expected "str": "str" [dict-item] src/paperless_mail/tests/test_mail.py:0: error: Dict entry 0 has incompatible type "str": "None"; expected "str": "str" [dict-item]
src/paperless_mail/tests/test_mail.py:0: error: Dict entry 0 has incompatible type "str": "int"; expected "str": "str" [dict-item] src/paperless_mail/tests/test_mail.py:0: error: Dict entry 0 has incompatible type "str": "int"; expected "str": "str" [dict-item]

View File

@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.10.0-python3.12-trixie-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.10.5-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6

View File

@@ -784,9 +784,17 @@ below.
### Document Splitting {#document-splitting} ### Document Splitting {#document-splitting}
When enabled, Paperless will look for a barcode with the configured value and create a new document If document splitting is enabled, Paperless splits _after_ a separator barcode by default.
starting from the next page. The page with the barcode on it will _not_ be retained. It This means:
is expected to be a page existing only for triggering the split.
- any page containing the configured separator barcode starts a new document, starting with the **next** page
- pages containing the separator barcode are discarded
This is intended for dedicated separator sheets such as PATCH-T pages.
If [`PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES`](configuration.md#PAPERLESS_CONSUMER_BARCODE_RETAIN_SPLIT_PAGES)
is enabled, the page containing the separator barcode is retained instead. In this mode,
each page containing the separator barcode becomes the **first** page of a new document.
### Archive Serial Number Assignment ### Archive Serial Number Assignment
@@ -795,8 +803,9 @@ archive serial number, allowing quick reference back to the original, paper docu
If document splitting via barcode is also enabled, documents will be split when an ASN If document splitting via barcode is also enabled, documents will be split when an ASN
barcode is located. However, differing from the splitting, the page with the barcode is located. However, differing from the splitting, the page with the
barcode _will_ be retained. This allows application of a barcode to any page, including barcode _will_ be retained. Each detected ASN barcode starts a new document _starting with
one which holds data to keep in the document. that page_. This allows placing ASN barcodes on content pages that should remain part of
the document.
### Tag Assignment ### Tag Assignment

View File

@@ -1,5 +1,7 @@
# Changelog # Changelog
## paperless-ngx 2.20.8
## paperless-ngx 2.20.7 ## paperless-ngx 2.20.7
### Bug Fixes ### Bug Fixes

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "2.20.7" version = "2.20.8"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -1781,11 +1781,15 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">216</context> <context context-type="linenumber">156</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">241</context> <context context-type="linenumber">230</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">255</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2991443309752293110" datatype="html"> <trans-unit id="2991443309752293110" datatype="html">
@@ -3113,21 +3117,21 @@
<source>Sidebar views updated</source> <source>Sidebar views updated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">329</context> <context context-type="linenumber">343</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3547923076537026828" datatype="html"> <trans-unit id="3547923076537026828" datatype="html">
<source>Error updating sidebar views</source> <source>Error updating sidebar views</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">332</context> <context context-type="linenumber">346</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2526035785704676448" datatype="html"> <trans-unit id="2526035785704676448" datatype="html">
<source>An error occurred while saving update checking settings.</source> <source>An error occurred while saving update checking settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">353</context> <context context-type="linenumber">367</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4580988005648117665" datatype="html"> <trans-unit id="4580988005648117665" datatype="html">
@@ -8486,7 +8490,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">315</context> <context context-type="linenumber">323</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
@@ -8501,7 +8505,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">308</context> <context context-type="linenumber">316</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context> <context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
@@ -8767,49 +8771,49 @@
<source>Reset filters / selection</source> <source>Reset filters / selection</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">296</context> <context context-type="linenumber">304</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4135055128446167640" datatype="html"> <trans-unit id="4135055128446167640" datatype="html">
<source>Open first [selected] document</source> <source>Open first [selected] document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">324</context> <context context-type="linenumber">332</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3629960544875360046" datatype="html"> <trans-unit id="3629960544875360046" datatype="html">
<source>Previous page</source> <source>Previous page</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">340</context> <context context-type="linenumber">348</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3337301694210287595" datatype="html"> <trans-unit id="3337301694210287595" datatype="html">
<source>Next page</source> <source>Next page</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">352</context> <context context-type="linenumber">360</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2155249406916744630" datatype="html"> <trans-unit id="2155249406916744630" datatype="html">
<source>View &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot; saved successfully.</source> <source>View &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot; saved successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">385</context> <context context-type="linenumber">393</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4646273665293421938" datatype="html"> <trans-unit id="4646273665293421938" datatype="html">
<source>Failed to save view &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot;.</source> <source>Failed to save view &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot;.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">391</context> <context context-type="linenumber">399</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6837554170707123455" datatype="html"> <trans-unit id="6837554170707123455" datatype="html">
<source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully.</source> <source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">437</context> <context context-type="linenumber">445</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="739880801667335279" datatype="html"> <trans-unit id="739880801667335279" datatype="html">

View File

@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "2.20.7", "version": "2.20.8",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",

View File

@@ -243,9 +243,19 @@ describe('AppFrameComponent', () => {
it('should support toggling slim sidebar and saving', fakeAsync(() => { it('should support toggling slim sidebar and saving', fakeAsync(() => {
const saveSettingSpy = jest.spyOn(settingsService, 'set') const saveSettingSpy = jest.spyOn(settingsService, 'set')
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
expect(component.slimSidebarEnabled).toBeFalsy() expect(component.slimSidebarEnabled).toBeFalsy()
expect(component.slimSidebarAnimating).toBeFalsy() expect(component.slimSidebarAnimating).toBeFalsy()
component.toggleSlimSidebar() component.toggleSlimSidebar()
const requests = httpTestingController.match(
`${environment.apiBaseUrl}ui_settings/`
)
expect(requests).toHaveLength(1)
expect(requests[0].request.body.settings.slim_sidebar).toBe(true)
expect(
requests[0].request.body.settings.attributes_sections_collapsed
).toEqual(['attributes'])
requests[0].flush({ success: true })
expect(component.slimSidebarAnimating).toBeTruthy() expect(component.slimSidebarAnimating).toBeTruthy()
tick(200) tick(200)
expect(component.slimSidebarAnimating).toBeFalsy() expect(component.slimSidebarAnimating).toBeFalsy()
@@ -254,6 +264,10 @@ describe('AppFrameComponent', () => {
SETTINGS_KEYS.SLIM_SIDEBAR, SETTINGS_KEYS.SLIM_SIDEBAR,
true true
) )
expect(saveSettingSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
['attributes']
)
})) }))
it('should show error on toggle slim sidebar if store settings fails', () => { it('should show error on toggle slim sidebar if store settings fails', () => {

View File

@@ -140,10 +140,24 @@ export class AppFrameComponent
toggleSlimSidebar(): void { toggleSlimSidebar(): void {
this.slimSidebarAnimating = true this.slimSidebarAnimating = true
this.slimSidebarEnabled = !this.slimSidebarEnabled const slimSidebarEnabled = !this.slimSidebarEnabled
if (this.slimSidebarEnabled) { this.settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, slimSidebarEnabled)
this.attributesSectionsCollapsed = true if (slimSidebarEnabled) {
this.settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [
CollapsibleSection.ATTRIBUTES,
])
} }
this.settingsService
.storeSettings()
.pipe(first())
.subscribe({
error: (error) => {
this.toastService.showError(
$localize`An error occurred while saving settings.`
)
console.warn(error)
},
})
setTimeout(() => { setTimeout(() => {
this.slimSidebarAnimating = false this.slimSidebarAnimating = false
}, 200) // slightly longer than css animation for slim sidebar }, 200) // slightly longer than css animation for slim sidebar

View File

@@ -117,7 +117,7 @@
</pngx-page-header> </pngx-page-header>
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body"> <div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [(filterRules)]="list.filterRules" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor> <pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor> <pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
</div> </div>

View File

@@ -147,21 +147,21 @@ describe('DocumentListComponent', () => {
}) })
it('should show score sort fields on fulltext queries', () => { it('should show score sort fields on fulltext queries', () => {
documentListService.filterRules = [ documentListService.setFilterRules([
{ {
rule_type: FILTER_HAS_TAGS_ANY, rule_type: FILTER_HAS_TAGS_ANY,
value: '10', value: '10',
}, },
] ])
fixture.detectChanges() fixture.detectChanges()
expect(component.getSortFields()).toEqual(documentListService.sortFields) expect(component.getSortFields()).toEqual(documentListService.sortFields)
documentListService.filterRules = [ documentListService.setFilterRules([
{ {
rule_type: FILTER_FULLTEXT_QUERY, rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo', value: 'foo',
}, },
] ])
fixture.detectChanges() fixture.detectChanges()
expect(component.getSortFields()).toEqual( expect(component.getSortFields()).toEqual(
documentListService.sortFieldsFullText documentListService.sortFieldsFullText
@@ -170,12 +170,12 @@ describe('DocumentListComponent', () => {
it('should determine if filtered, support reset', () => { it('should determine if filtered, support reset', () => {
fixture.detectChanges() fixture.detectChanges()
documentListService.filterRules = [ documentListService.setFilterRules([
{ {
rule_type: FILTER_HAS_TAGS_ANY, rule_type: FILTER_HAS_TAGS_ANY,
value: '10', value: '10',
}, },
] ])
documentListService.isReloading = false documentListService.isReloading = false
fixture.detectChanges() fixture.detectChanges()
expect(component.isFiltered).toBeTruthy() expect(component.isFiltered).toBeTruthy()
@@ -185,6 +185,20 @@ describe('DocumentListComponent', () => {
expect(fixture.nativeElement.textContent.match(/Reset/g)).toHaveLength(1) expect(fixture.nativeElement.textContent.match(/Reset/g)).toHaveLength(1)
}) })
it('should apply filter rule changes via list service', () => {
const setFilterRulesSpy = jest.spyOn(documentListService, 'setFilterRules')
const rules = [{ rule_type: FILTER_HAS_TAGS_ANY, value: '10' }]
component.onFilterRulesChange(rules)
expect(setFilterRulesSpy).toHaveBeenCalledWith(rules)
})
it('should reset filter rules to page one via list service', () => {
const setFilterRulesSpy = jest.spyOn(documentListService, 'setFilterRules')
const rules = [{ rule_type: FILTER_HAS_TAGS_ANY, value: '10' }]
component.onFilterRulesReset(rules)
expect(setFilterRulesSpy).toHaveBeenCalledWith(rules, true)
})
it('should load saved view from URL', () => { it('should load saved view from URL', () => {
const view: SavedView = { const view: SavedView = {
id: 10, id: 10,
@@ -217,7 +231,7 @@ describe('DocumentListComponent', () => {
.spyOn(activatedRoute, 'paramMap', 'get') .spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams))) .mockReturnValue(of(convertToParamMap(queryParams)))
activatedRoute.snapshot.queryParams = queryParams activatedRoute.snapshot.queryParams = queryParams
fixture.detectChanges() component.ngOnInit()
expect(getSavedViewSpy).toHaveBeenCalledWith(view.id) expect(getSavedViewSpy).toHaveBeenCalledWith(view.id)
expect(activateSavedViewSpy).toHaveBeenCalledWith( expect(activateSavedViewSpy).toHaveBeenCalledWith(
view, view,

View File

@@ -212,6 +212,14 @@ export class DocumentListComponent
this.list.setSort(event.column, event.reverse) this.list.setSort(event.column, event.reverse)
} }
onFilterRulesChange(filterRules: FilterRule[]) {
this.list.setFilterRules(filterRules)
}
onFilterRulesReset(filterRules: FilterRule[]) {
this.list.setFilterRules(filterRules, true)
}
get isBulkEditing(): boolean { get isBulkEditing(): boolean {
return this.list.selected.size > 0 return this.list.selected.size > 0
} }
@@ -300,7 +308,7 @@ export class DocumentListComponent
if (this.list.selected.size > 0) { if (this.list.selected.size > 0) {
this.list.selectNone() this.list.selectNone()
} else if (this.isFiltered) { } else if (this.isFiltered) {
this.filterEditor.resetSelected() this.resetFilters()
} }
}) })

View File

@@ -2107,6 +2107,22 @@ describe('FilterEditorComponent', () => {
expect(component.filterRules).toEqual(rules) expect(component.filterRules).toEqual(rules)
}) })
it('should emit reset filter rules when resetting', () => {
const rules = [{ rule_type: FILTER_HAS_TAGS_ANY, value: '2' }]
component.unmodifiedFilterRules = rules
component.filterRules = [
{ rule_type: FILTER_DOES_NOT_HAVE_TAG, value: '2' },
]
const resetFilterRulesSpy = jest.spyOn(component.resetFilterRules, 'next')
const filterRulesChangeSpy = jest.spyOn(component.filterRulesChange, 'next')
component.resetSelected()
expect(resetFilterRulesSpy).toHaveBeenCalledWith(rules)
expect(filterRulesChangeSpy).not.toHaveBeenCalled()
})
it('should support resetting text field', () => { it('should support resetting text field', () => {
component.textFilter = 'foo' component.textFilter = 'foo'
component.resetTextField() component.resetTextField()

View File

@@ -1101,6 +1101,9 @@ export class FilterEditorComponent
@Output() @Output()
filterRulesChange = new EventEmitter<FilterRule[]>() filterRulesChange = new EventEmitter<FilterRule[]>()
@Output()
resetFilterRules = new EventEmitter<FilterRule[]>()
@Input() @Input()
set selectionData(selectionData: SelectionData) { set selectionData(selectionData: SelectionData) {
this.tagDocumentCounts = selectionData?.selected_tags ?? null this.tagDocumentCounts = selectionData?.selected_tags ?? null
@@ -1244,7 +1247,7 @@ export class FilterEditorComponent
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
this.documentService.searchQuery = '' this.documentService.searchQuery = ''
this.filterRules = this._unmodifiedFilterRules this.filterRules = this._unmodifiedFilterRules
this.updateRules() this.resetFilterRules.next(this.filterRules)
} }
toggleTag(tagId: number) { toggleTag(tagId: number) {

View File

@@ -164,7 +164,7 @@ describe('DocumentListViewService', () => {
value: tags__id__in, value: tags__id__in,
}, },
] ]
documentListViewService.filterRules = filterRulesAny documentListViewService.setFilterRules(filterRulesAny)
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
) )
@@ -178,7 +178,7 @@ describe('DocumentListViewService', () => {
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
// reset the list // reset the list
documentListViewService.filterRules = [] documentListViewService.setFilterRules([])
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
@@ -210,7 +210,7 @@ describe('DocumentListViewService', () => {
value: tags__id__in, value: tags__id__in,
}, },
] ]
documentListViewService.filterRules = filterRulesAny documentListViewService.setFilterRules(filterRulesAny)
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__in=${tags__id__in}`
) )
@@ -218,7 +218,7 @@ describe('DocumentListViewService', () => {
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' }) req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
expect(documentListViewService.error).toEqual('Generic error') expect(documentListViewService.error).toEqual('Generic error')
// reset the list // reset the list
documentListViewService.filterRules = [] documentListViewService.setFilterRules([])
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
@@ -295,13 +295,41 @@ describe('DocumentListViewService', () => {
}) })
it('should use filter rules to update query params', () => { it('should use filter rules to update query params', () => {
documentListViewService.filterRules = filterRules documentListViewService.setFilterRules(filterRules)
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
}) })
it('should support setting filter rules and resetting to page one', () => {
documentListViewService.currentPage = 2
let req = httpTestingController.expectOne((request) =>
request.urlWithParams.startsWith(
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true`
)
)
expect(req.request.method).toEqual('GET')
req.flush(full_results)
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
req.flush([])
documentListViewService.setFilterRules(filterRules, true)
const filteredReqs = httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
)
expect(filteredReqs).toHaveLength(1)
filteredReqs[0].flush(full_results)
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/selection_data/`
)
req.flush([])
expect(documentListViewService.currentPage).toEqual(1)
})
it('should support quick filter', () => { it('should support quick filter', () => {
documentListViewService.quickFilter(filterRules) documentListViewService.quickFilter(filterRules)
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
@@ -336,7 +364,7 @@ describe('DocumentListViewService', () => {
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&tags__id__all=9` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&tags__id__all=9`
) )
documentListViewService.filterRules = [] documentListViewService.setFilterRules([])
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true`
) )
@@ -348,7 +376,7 @@ describe('DocumentListViewService', () => {
}) })
it('should support navigating next / previous', () => { it('should support navigating next / previous', () => {
documentListViewService.filterRules = [] documentListViewService.setFilterRules([])
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
@@ -558,7 +586,7 @@ describe('DocumentListViewService', () => {
req.flush(full_results) req.flush(full_results)
expect(documentListViewService.selected.size).toEqual(6) expect(documentListViewService.selected.size).toEqual(6)
documentListViewService.filterRules = filterRules documentListViewService.setFilterRules(filterRules)
httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&tags__id__all=9`
) )
@@ -592,7 +620,7 @@ describe('DocumentListViewService', () => {
documentListViewService.loadSavedView(view2) documentListViewService.loadSavedView(view2)
expect(documentListViewService.sortField).toEqual('score') expect(documentListViewService.sortField).toEqual('score')
documentListViewService.filterRules = [] documentListViewService.setFilterRules([])
expect(documentListViewService.sortField).toEqual('created') expect(documentListViewService.sortField).toEqual('created')
httpTestingController.expectOne( httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`

View File

@@ -342,7 +342,7 @@ export class DocumentListViewService {
}) })
} }
set filterRules(filterRules: FilterRule[]) { setFilterRules(filterRules: FilterRule[], resetPage: boolean = false) {
if ( if (
!isFullTextFilterRule(filterRules) && !isFullTextFilterRule(filterRules) &&
this.activeListViewState.sortField == 'score' this.activeListViewState.sortField == 'score'
@@ -350,6 +350,9 @@ export class DocumentListViewService {
this.activeListViewState.sortField = 'created' this.activeListViewState.sortField = 'created'
} }
this.activeListViewState.filterRules = filterRules this.activeListViewState.filterRules = filterRules
if (resetPage) {
this.activeListViewState.currentPage = 1
}
this.reload() this.reload()
this.reduceSelectionToFilter() this.reduceSelectionToFilter()
this.saveDocumentListView() this.saveDocumentListView()
@@ -479,7 +482,7 @@ export class DocumentListViewService {
quickFilter(filterRules: FilterRule[]) { quickFilter(filterRules: FilterRule[]) {
this._activeSavedViewId = null this._activeSavedViewId = null
this.filterRules = filterRules this.setFilterRules(filterRules)
this.router.navigate(['documents']) this.router.navigate(['documents'])
} }

View File

@@ -6,7 +6,7 @@ export const environment = {
apiVersion: '9', // match src/paperless/settings.py apiVersion: '9', // match src/paperless/settings.py
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
tag: 'prod', tag: 'prod',
version: '2.20.7', version: '2.20.8',
webSocketHost: window.location.host, webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/', webSocketBaseUrl: base_url.pathname + 'ws/',

View File

@@ -1,6 +1,6 @@
from typing import Final from typing import Final
__version__: Final[tuple[int, int, int]] = (2, 20, 7) __version__: Final[tuple[int, int, int]] = (2, 20, 8)
# Version string like X.Y.Z # Version string like X.Y.Z
__full_version_str__: Final[str] = ".".join(map(str, __version__)) __full_version_str__: Final[str] = ".".join(map(str, __version__))
# Version string like X.Y # Version string like X.Y

View File

@@ -23,6 +23,7 @@ def get_embedding_model() -> BaseEmbedding:
return OpenAIEmbedding( return OpenAIEmbedding(
model=config.llm_embedding_model or "text-embedding-3-small", model=config.llm_embedding_model or "text-embedding-3-small",
api_key=config.llm_api_key, api_key=config.llm_api_key,
api_base=config.llm_endpoint or None,
) )
case LLMEmbeddingBackend.HUGGINGFACE: case LLMEmbeddingBackend.HUGGINGFACE:
return HuggingFaceEmbedding( return HuggingFaceEmbedding(

View File

@@ -65,12 +65,14 @@ def test_get_embedding_model_openai(mock_ai_config):
mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI mock_ai_config.return_value.llm_embedding_backend = LLMEmbeddingBackend.OPENAI
mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small" mock_ai_config.return_value.llm_embedding_model = "text-embedding-3-small"
mock_ai_config.return_value.llm_api_key = "test_api_key" mock_ai_config.return_value.llm_api_key = "test_api_key"
mock_ai_config.return_value.llm_endpoint = "http://test-url"
with patch("paperless_ai.embedding.OpenAIEmbedding") as MockOpenAIEmbedding: with patch("paperless_ai.embedding.OpenAIEmbedding") as MockOpenAIEmbedding:
model = get_embedding_model() model = get_embedding_model()
MockOpenAIEmbedding.assert_called_once_with( MockOpenAIEmbedding.assert_called_once_with(
model="text-embedding-3-small", model="text-embedding-3-small",
api_key="test_api_key", api_key="test_api_key",
api_base="http://test-url",
) )
assert model == MockOpenAIEmbedding.return_value assert model == MockOpenAIEmbedding.return_value

View File

@@ -15,7 +15,7 @@ class Migration(migrations.Migration):
name="stop_processing", name="stop_processing",
field=models.BooleanField( field=models.BooleanField(
default=False, default=False,
help_text="If True, no further rules will be processed after this one if any document is consumed.", help_text="If True, no further rules will be processed after this one if any document is queued.",
verbose_name="Stop processing further rules", verbose_name="Stop processing further rules",
), ),
), ),

View File

@@ -272,6 +272,24 @@ class TestAPIMailAccounts(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["success"], True) self.assertEqual(response.data["success"], True)
def test_mail_account_test_existing_nonexistent_id_forbidden(self) -> None:
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"id": 999999,
"imap_server": "server.example.com",
"imap_port": 443,
"imap_security": MailAccount.ImapSecurity.SSL,
"username": "admin",
"password": "******",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content.decode(), "Insufficient permissions")
def test_get_mail_accounts_owner_aware(self) -> None: def test_get_mail_accounts_owner_aware(self) -> None:
""" """
GIVEN: GIVEN:

View File

@@ -8,6 +8,7 @@ from datetime import timedelta
from unittest import mock from unittest import mock
import pytest import pytest
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.management import call_command from django.core.management import call_command
from django.db import DatabaseError from django.db import DatabaseError
@@ -1734,6 +1735,10 @@ class TestMailAccountTestView(APITestCase):
username="testuser", username="testuser",
password="testpassword", password="testpassword",
) )
self.user.user_permissions.add(
*Permission.objects.filter(codename__in=["add_mailaccount"]),
)
self.user.save()
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
self.url = "/api/mail_accounts/test/" self.url = "/api/mail_accounts/test/"
@@ -1850,6 +1855,56 @@ class TestMailAccountTestView(APITestCase):
expected_str = "Unable to refresh oauth token" expected_str = "Unable to refresh oauth token"
self.assertIn(expected_str, error_str) self.assertIn(expected_str, error_str)
def test_mail_account_test_view_existing_forbidden_for_other_owner(self) -> None:
other_user = User.objects.create_user(
username="otheruser",
password="testpassword",
)
existing_account = MailAccount.objects.create(
name="Owned account",
imap_server="imap.example.com",
imap_port=993,
imap_security=MailAccount.ImapSecurity.SSL,
username="admin",
password="secret",
owner=other_user,
)
data = {
"id": existing_account.id,
"imap_server": "imap.example.com",
"imap_port": 993,
"imap_security": MailAccount.ImapSecurity.SSL,
"username": "admin",
"password": "****",
"is_token": False,
}
response = self.client.post(self.url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content.decode(), "Insufficient permissions")
def test_mail_account_test_view_requires_add_permission_without_account_id(
self,
) -> None:
self.user.user_permissions.remove(
*Permission.objects.filter(codename__in=["add_mailaccount"]),
)
self.user.save()
data = {
"imap_server": "imap.example.com",
"imap_port": 993,
"imap_security": MailAccount.ImapSecurity.SSL,
"username": "admin",
"password": "secret",
"is_token": False,
}
response = self.client.post(self.url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content.decode(), "Insufficient permissions")
class TestMailAccountProcess(APITestCase): class TestMailAccountProcess(APITestCase):
def setUp(self) -> None: def setUp(self) -> None:

View File

@@ -86,13 +86,34 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
request.data["name"] = datetime.datetime.now().isoformat() request.data["name"] = datetime.datetime.now().isoformat()
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
existing_account = None
account_id = request.data.get("id")
# account exists, use the password from there instead of *** and refresh_token / expiration # testing a new connection requires add permission
if account_id is None and not request.user.has_perms(
["paperless_mail.add_mailaccount"],
):
return HttpResponseForbidden("Insufficient permissions")
# testing an existing account requires change permission on that account
if account_id is not None:
try:
existing_account = MailAccount.objects.get(pk=account_id)
except (TypeError, ValueError, MailAccount.DoesNotExist):
return HttpResponseForbidden("Insufficient permissions")
if not has_perms_owner_aware(
request.user,
"change_mailaccount",
existing_account,
):
return HttpResponseForbidden("Insufficient permissions")
# account exists, use the password from there instead of ***
if ( if (
len(serializer.validated_data.get("password").replace("*", "")) == 0 len(serializer.validated_data.get("password").replace("*", "")) == 0
and request.data["id"] is not None and existing_account is not None
): ):
existing_account = MailAccount.objects.get(pk=request.data["id"])
serializer.validated_data["password"] = existing_account.password serializer.validated_data["password"] = existing_account.password
serializer.validated_data["account_type"] = existing_account.account_type serializer.validated_data["account_type"] = existing_account.account_type
serializer.validated_data["refresh_token"] = existing_account.refresh_token serializer.validated_data["refresh_token"] = existing_account.refresh_token
@@ -106,7 +127,8 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
) as M: ) as M:
try: try:
if ( if (
account.is_token existing_account is not None
and account.is_token
and account.expiration is not None and account.expiration is not None
and account.expiration < timezone.now() and account.expiration < timezone.now()
): ):
@@ -248,6 +270,7 @@ class OauthCallbackView(GenericAPIView):
imap_server=imap_server, imap_server=imap_server,
refresh_token=refresh_token, refresh_token=refresh_token,
expiration=timezone.now() + timedelta(seconds=expires_in), expiration=timezone.now() + timedelta(seconds=expires_in),
owner=request.user,
defaults=defaults, defaults=defaults,
) )
return HttpResponseRedirect( return HttpResponseRedirect(

2
uv.lock generated
View File

@@ -3019,7 +3019,7 @@ wheels = [
[[package]] [[package]]
name = "paperless-ngx" name = "paperless-ngx"
version = "2.20.7" version = "2.20.8"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "azure-ai-documentintelligence", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },