Compare commits

..

18 Commits

Author SHA1 Message Date
shamoon
f7118f61a0 Prevent duplicate mail processing across rules 2026-02-24 11:44:00 -08:00
GitHub Actions
e08287f791 Auto translate strings 2026-02-24 00:44:37 +00:00
Jan Kleine
c4ea332c61 Feature: move to trash action for workflows (#11176)
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-23 16:42:50 -08: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
63 changed files with 1413 additions and 1321 deletions

View File

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

View File

@@ -344,9 +344,6 @@ src/documents/migrations/0001_initial.py:0: error: Skipping analyzing "multisele
src/documents/migrations/0001_initial.py:0: error: Skipping analyzing "multiselectfield.db.fields": module is installed, but missing library stubs or py.typed marker [import-untyped]
src/documents/migrations/0008_sharelinkbundle.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/migrations/0008_sharelinkbundle.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/migrations/0012_savedview_visibility_to_ui_settings.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/migrations/0012_savedview_visibility_to_ui_settings.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/migrations/0012_savedview_visibility_to_ui_settings.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/models.py:0: error: Argument 1 to "Path" has incompatible type "Path | None"; expected "str | PathLike[str]" [arg-type]
src/documents/models.py:0: error: Could not resolve manager type for "documents.models.Document.deleted_objects" [django-manager-missing]
src/documents/models.py:0: error: Could not resolve manager type for "documents.models.Document.global_objects" [django-manager-missing]
@@ -703,15 +700,11 @@ src/documents/signals/handlers.py:0: error: Function is missing a type annotatio
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/signals/handlers.py:0: error: Incompatible return value type (got "tuple[DocumentMetadataOverrides | None, str]", expected "tuple[DocumentMetadataOverrides, str] | None") [return-value]
src/documents/signals/handlers.py:0: error: Incompatible types in assignment (expression has type "list[Tag]", variable has type "set[Tag]") [assignment]
src/documents/signals/handlers.py:0: error: Incompatible types in assignment (expression has type "tuple[Any, Any, Any]", variable has type "tuple[Any, Any]") [assignment]
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "refresh_from_db" [union-attr]
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "save" [union-attr]
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "source_path" [union-attr]
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "tags" [union-attr]
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "tags" [union-attr]
src/documents/signals/handlers.py:0: error: Item "ConsumableDocument" of "Document | ConsumableDocument" has no attribute "title" [union-attr]
src/documents/signals/handlers.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
src/documents/signals/handlers.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
@@ -1196,14 +1189,6 @@ src/documents/tests/test_management_exporter.py:0: error: Skipping analyzing "al
src/documents/tests/test_management_fuzzy.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/tests/test_management_retagger.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/tests/test_management_superuser.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Cannot determine type of "apps" [has-type]
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Cannot determine type of "apps" [has-type]
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
src/documents/tests/test_migration_share_link_bundle.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/tests/test_migration_share_link_bundle.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/tests/test_migration_share_link_bundle.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
@@ -1580,6 +1565,7 @@ src/documents/views.py:0: error: Function is missing a return type annotation [
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
@@ -1635,6 +1621,8 @@ src/documents/views.py:0: error: Function is missing a type annotation [no-unty
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/views.py:0: error: Incompatible type for lookup 'owner': (got "User | AnonymousUser", expected "User | int | None") [misc]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "dict[Any, Any]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "list[Any]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "list[Document]") [assignment]
@@ -1700,11 +1688,11 @@ src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Any]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[CustomField]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Group]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[MailAccount]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[MailRule]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[SavedView]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[User]" is not indexable [index]
src/documents/views.py:0: error: Value of type "Iterable[Workflow]" is not indexable [index]
src/documents/views.py:0: error: Value of type "dict[str, _PingReply] | None" is not indexable [index]
@@ -2200,34 +2188,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 "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[message2@426]" 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@426]" 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@419]" 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@531]" has no attribute "attachments" [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@427]" 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@427]" has no attribute "from_values" [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@420]" has no attribute "from_values" [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@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 "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@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[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@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@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@419]"; 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@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@478]"; 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@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@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@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@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@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@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@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@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 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@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@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@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@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: 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]

View File

@@ -784,9 +784,17 @@ below.
### Document Splitting {#document-splitting}
When enabled, Paperless will look for a barcode with the configured value and create a new document
starting from the next page. The page with the barcode on it will _not_ be retained. It
is expected to be a page existing only for triggering the split.
If document splitting is enabled, Paperless splits _after_ a separator barcode by default.
This means:
- 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
@@ -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
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
one which holds data to keep in the document.
barcode _will_ be retained. Each detected ASN barcode starts a new document _starting with
that page_. This allows placing ASN barcodes on content pages that should remain part of
the document.
### Tag Assignment

View File

@@ -451,8 +451,3 @@ Initial API version.
- The document `created` field is now a date, not a datetime. The
`created_date` field is considered deprecated and will be removed in a
future version.
#### Version 10
- The `show_on_dashboard` and `show_in_sidebar` fields of saved views have been
removed. Relevant settings are now stored in the UISettings model.

View File

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

View File

@@ -564,6 +564,18 @@ For security reasons, webhooks can be limited to specific ports and disallowed f
[configuration settings](configuration.md#workflow-webhooks) to change this behavior. If you are allowing non-admins to create workflows,
you may want to adjust these settings to prevent abuse.
##### Move to Trash {#workflow-action-move-to-trash}
"Move to Trash" actions move the document to the trash. The document can be restored
from the trash until the trash is emptied (after the configured delay or manually).
The "Move to Trash" action will always be executed at the end of the workflow run,
regardless of its position in the action list. After a "Move to Trash" action is executed
no other workflow will be executed on the document.
If a "Move to Trash" action is executed in a consume pipeline, the consumption
will be aborted and the file will be deleted.
#### Workflow placeholders
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).

View File

@@ -1,6 +1,6 @@
[project]
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"
readme = "README.md"
requires-python = ">=3.10"

View File

@@ -58,7 +58,7 @@
"content": {
"size": -1,
"mimeType": "application/json",
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true,\"dashboard_views_visible_ids\":[7,4],\"sidebar_views_visible_ids\":[7,4,11]},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
},
"headersSize": -1,
"bodySize": -1,

View File

@@ -58,7 +58,7 @@
"content": {
"size": -1,
"mimeType": "application/json",
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true,\"dashboard_views_visible_ids\":[7,4],\"sidebar_views_visible_ids\":[7,4,11]},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
},
"headersSize": -1,
"bodySize": -1,

View File

@@ -58,7 +58,7 @@
"content": {
"size": -1,
"mimeType": "application/json",
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true,\"dashboard_views_visible_ids\":[7,4],\"sidebar_views_visible_ids\":[7,4,11]},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
},
"headersSize": -1,
"bodySize": -1,

View File

@@ -58,7 +58,7 @@
"content": {
"size": -1,
"mimeType": "application/json",
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true,\"dashboard_views_visible_ids\":[7,4],\"sidebar_views_visible_ids\":[7,4,11]},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
},
"headersSize": -1,
"bodySize": -1,

View File

@@ -1781,11 +1781,15 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">216</context>
<context context-type="linenumber">156</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">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>
</trans-unit>
<trans-unit id="2991443309752293110" datatype="html">
@@ -3113,21 +3117,21 @@
<source>Sidebar views updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">329</context>
<context context-type="linenumber">343</context>
</context-group>
</trans-unit>
<trans-unit id="3547923076537026828" datatype="html">
<source>Error updating sidebar views</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">332</context>
<context context-type="linenumber">346</context>
</context-group>
</trans-unit>
<trans-unit id="2526035785704676448" datatype="html">
<source>An error occurred while saving update checking settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">353</context>
<context context-type="linenumber">367</context>
</context-group>
</trans-unit>
<trans-unit id="4580988005648117665" datatype="html">
@@ -5351,6 +5355,13 @@
<context context-type="linenumber">445</context>
</context-group>
</trans-unit>
<trans-unit id="7902569198692046993" datatype="html">
<source>The document will be moved to the trash at the end of the workflow run.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">454</context>
</context-group>
</trans-unit>
<trans-unit id="4626030417479279989" datatype="html">
<source>Consume Folder</source>
<context-group purpose="location">
@@ -5453,109 +5464,124 @@
<context context-type="linenumber">144</context>
</context-group>
</trans-unit>
<trans-unit id="2048798344356757326" datatype="html">
<source>Move to trash</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">148</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1087</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">760</context>
</context-group>
</trans-unit>
<trans-unit id="4522609911791833187" datatype="html">
<source>Has any of these tags</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">213</context>
<context context-type="linenumber">217</context>
</context-group>
</trans-unit>
<trans-unit id="4166903555074156852" datatype="html">
<source>Has all of these tags</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">220</context>
<context context-type="linenumber">224</context>
</context-group>
</trans-unit>
<trans-unit id="6624363795312783141" datatype="html">
<source>Does not have these tags</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">227</context>
<context context-type="linenumber">231</context>
</context-group>
</trans-unit>
<trans-unit id="7168528512669831184" datatype="html">
<source>Has any of these correspondents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">234</context>
<context context-type="linenumber">238</context>
</context-group>
</trans-unit>
<trans-unit id="5281365940563983618" datatype="html">
<source>Has correspondent</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">242</context>
<context context-type="linenumber">246</context>
</context-group>
</trans-unit>
<trans-unit id="6884498632428600393" datatype="html">
<source>Does not have correspondents</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">250</context>
<context context-type="linenumber">254</context>
</context-group>
</trans-unit>
<trans-unit id="4806713133917046341" datatype="html">
<source>Has document type</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">258</context>
<context context-type="linenumber">262</context>
</context-group>
</trans-unit>
<trans-unit id="8801397520369995032" datatype="html">
<source>Has any of these document types</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">266</context>
<context context-type="linenumber">270</context>
</context-group>
</trans-unit>
<trans-unit id="1507843981661822403" datatype="html">
<source>Does not have document types</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">274</context>
<context context-type="linenumber">278</context>
</context-group>
</trans-unit>
<trans-unit id="4277260190522078330" datatype="html">
<source>Has storage path</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">282</context>
<context context-type="linenumber">286</context>
</context-group>
</trans-unit>
<trans-unit id="8858580062214623097" datatype="html">
<source>Has any of these storage paths</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">290</context>
<context context-type="linenumber">294</context>
</context-group>
</trans-unit>
<trans-unit id="6070943364927280151" datatype="html">
<source>Does not have storage paths</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">298</context>
<context context-type="linenumber">302</context>
</context-group>
</trans-unit>
<trans-unit id="6250799006816371860" datatype="html">
<source>Matches custom field query</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">306</context>
<context context-type="linenumber">310</context>
</context-group>
</trans-unit>
<trans-unit id="3138206142174978019" datatype="html">
<source>Create new workflow</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">535</context>
<context context-type="linenumber">539</context>
</context-group>
</trans-unit>
<trans-unit id="5996779210524133604" datatype="html">
<source>Edit workflow</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">539</context>
<context context-type="linenumber">543</context>
</context-group>
</trans-unit>
<trans-unit id="5457837313196342910" datatype="html">
@@ -7769,17 +7795,6 @@
<context context-type="linenumber">758</context>
</context-group>
</trans-unit>
<trans-unit id="2048798344356757326" datatype="html">
<source>Move to trash</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1087</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
<context context-type="linenumber">760</context>
</context-group>
</trans-unit>
<trans-unit id="7295637485862454066" datatype="html">
<source>Error deleting document</source>
<context-group purpose="location">
@@ -8486,7 +8501,7 @@
</context-group>
<context-group purpose="location">
<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 purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
@@ -8501,7 +8516,7 @@
</context-group>
<context-group purpose="location">
<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 purpose="location">
<context context-type="sourcefile">src/app/components/manage/document-attributes/document-attributes.component.html</context>
@@ -8767,49 +8782,49 @@
<source>Reset filters / selection</source>
<context-group purpose="location">
<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>
</trans-unit>
<trans-unit id="4135055128446167640" datatype="html">
<source>Open first [selected] document</source>
<context-group purpose="location">
<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>
</trans-unit>
<trans-unit id="3629960544875360046" datatype="html">
<source>Previous page</source>
<context-group purpose="location">
<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>
</trans-unit>
<trans-unit id="3337301694210287595" datatype="html">
<source>Next page</source>
<context-group purpose="location">
<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>
</trans-unit>
<trans-unit id="2155249406916744630" datatype="html">
<source>View &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot; saved successfully.</source>
<context-group purpose="location">
<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>
</trans-unit>
<trans-unit id="4646273665293421938" datatype="html">
<source>Failed to save view &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot;.</source>
<context-group purpose="location">
<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>
</trans-unit>
<trans-unit id="6837554170707123455" datatype="html">
<source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully.</source>
<context-group purpose="location">
<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>
</trans-unit>
<trans-unit id="739880801667335279" datatype="html">

View File

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

View File

@@ -99,7 +99,12 @@
</ul>
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
@if (savedViewService.sidebarViews?.length > 0) {
@if (savedViewService.loading) {
<h6 class="sidebar-heading px-3 text-muted">
<span i18n>Saved views</span>
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
</h6>
} @else if (savedViewService.sidebarViews?.length > 0) {
<h6 class="sidebar-heading px-3 text-muted">
<span i18n>Saved views</span>
</h6>
@@ -129,11 +134,6 @@
</li>
}
</ul>
} @else if (savedViewService.loading) {
<h6 class="sidebar-heading px-3 text-muted">
<span i18n>Saved views</span>
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
</h6>
}
</div>

View File

@@ -243,9 +243,19 @@ describe('AppFrameComponent', () => {
it('should support toggling slim sidebar and saving', fakeAsync(() => {
const saveSettingSpy = jest.spyOn(settingsService, 'set')
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
expect(component.slimSidebarEnabled).toBeFalsy()
expect(component.slimSidebarAnimating).toBeFalsy()
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()
tick(200)
expect(component.slimSidebarAnimating).toBeFalsy()
@@ -254,6 +264,10 @@ describe('AppFrameComponent', () => {
SETTINGS_KEYS.SLIM_SIDEBAR,
true
)
expect(saveSettingSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
['attributes']
)
}))
it('should show error on toggle slim sidebar if store settings fails', () => {

View File

@@ -140,10 +140,24 @@ export class AppFrameComponent
toggleSlimSidebar(): void {
this.slimSidebarAnimating = true
this.slimSidebarEnabled = !this.slimSidebarEnabled
if (this.slimSidebarEnabled) {
this.attributesSectionsCollapsed = true
const slimSidebarEnabled = !this.slimSidebarEnabled
this.settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, slimSidebarEnabled)
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(() => {
this.slimSidebarAnimating = false
}, 200) // slightly longer than css animation for slim sidebar

View File

@@ -448,6 +448,13 @@
</div>
</div>
}
@case (WorkflowActionType.MoveToTrash) {
<div class="row">
<div class="col">
<p class="text-muted small" i18n>The document will be moved to the trash at the end of the workflow run.</p>
</div>
</div>
}
}
</div>
</ng-template>

View File

@@ -143,6 +143,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.PasswordRemoval,
name: $localize`Password removal`,
},
{
id: WorkflowActionType.MoveToTrash,
name: $localize`Move to trash`,
},
]
export enum TriggerFilterType {

View File

@@ -16,12 +16,6 @@
</div>
</form>
@if (note) {
<div class="small text-muted fst-italic mt-2">
{{ note }}
</div>
}
</div>
<div class="modal-footer">
@if (!buttonsEnabled) {

View File

@@ -40,9 +40,6 @@ export class PermissionsDialogComponent {
@Input()
title = $localize`Set permissions`
@Input()
note: string = null
@Input()
set object(o: ObjectWithPermissions) {
this.o = o

View File

@@ -104,9 +104,11 @@
}
}
@if (list.activeSavedViewId && activeSavedViewCanChange) {
<button ngbDropdownItem (click)="saveViewConfig()" [disabled]="!savedViewIsModified" i18n>Save "{{list.activeSavedViewTitle}}"</button>
}
<div *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.SavedView }">
@if (list.activeSavedViewId) {
<button ngbDropdownItem (click)="saveViewConfig()" [disabled]="!savedViewIsModified" i18n>Save "{{list.activeSavedViewTitle}}"</button>
}
</div>
<button ngbDropdownItem (click)="saveViewConfigAs()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.SavedView }" i18n>Save as...</button>
<a ngbDropdownItem routerLink="/savedviews" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" i18n>All saved views</a>
</div>
@@ -115,7 +117,7 @@
</pngx-page-header>
<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>
</div>

View File

@@ -147,39 +147,35 @@ describe('DocumentListComponent', () => {
})
it('should show score sort fields on fulltext queries', () => {
documentListService.filterRules = [
documentListService.setFilterRules([
{
rule_type: FILTER_HAS_TAGS_ANY,
value: '10',
},
]
])
fixture.detectChanges()
expect(component.getSortFields()).toEqual(documentListService.sortFields)
documentListService.filterRules = [
documentListService.setFilterRules([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo',
},
]
])
fixture.detectChanges()
expect(component.getSortFields()).toEqual(
documentListService.sortFieldsFullText
)
})
it('should not allow changing a saved view when none is active', () => {
expect(component.activeSavedViewCanChange).toBeFalsy()
})
it('should determine if filtered, support reset', () => {
fixture.detectChanges()
documentListService.filterRules = [
documentListService.setFilterRules([
{
rule_type: FILTER_HAS_TAGS_ANY,
value: '10',
},
]
])
documentListService.isReloading = false
fixture.detectChanges()
expect(component.isFiltered).toBeTruthy()
@@ -189,6 +185,20 @@ describe('DocumentListComponent', () => {
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', () => {
const view: SavedView = {
id: 10,
@@ -221,7 +231,7 @@ describe('DocumentListComponent', () => {
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams)))
activatedRoute.snapshot.queryParams = queryParams
fixture.detectChanges()
component.ngOnInit()
expect(getSavedViewSpy).toHaveBeenCalledWith(view.id)
expect(activateSavedViewSpy).toHaveBeenCalledWith(
view,
@@ -289,19 +299,6 @@ describe('DocumentListComponent', () => {
expect(setCountSpy).toHaveBeenCalledWith(expect.any(Object), 3)
})
it('should reset active saved view when loading unknown view config', () => {
component['activeSavedView'] = { id: 1 } as SavedView
const activateSpy = jest.spyOn(documentListService, 'activateSavedView')
const reloadSpy = jest.spyOn(documentListService, 'reload')
jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(null))
component.loadViewConfig(10)
expect(component['activeSavedView']).toBeNull()
expect(activateSpy).not.toHaveBeenCalled()
expect(reloadSpy).not.toHaveBeenCalled()
})
it('should support 3 different display modes', () => {
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
fixture.detectChanges()
@@ -469,7 +466,7 @@ describe('DocumentListComponent', () => {
})
it('should handle error on view saving', () => {
const view: SavedView = {
component.list.activateSavedView({
id: 10,
name: 'Saved View 10',
sort_field: 'added',
@@ -480,16 +477,7 @@ describe('DocumentListComponent', () => {
value: '20',
},
],
}
jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
const queryParams = { view: view.id.toString() }
jest
.spyOn(activatedRoute, 'queryParamMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams)))
activatedRoute.snapshot.queryParams = queryParams
router.routerState.snapshot.url = '/view/10/'
fixture.detectChanges()
})
const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(savedViewService, 'patch')
@@ -501,40 +489,6 @@ describe('DocumentListComponent', () => {
)
})
it('should not save a view without object change permissions', () => {
const view: SavedView = {
id: 10,
name: 'Saved View 10',
sort_field: 'added',
sort_reverse: true,
filter_rules: [
{
rule_type: FILTER_HAS_TAGS_ANY,
value: '20',
},
],
owner: 999,
user_can_change: false,
}
jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
jest
.spyOn(permissionService, 'currentUserHasObjectPermissions')
.mockReturnValue(false)
const queryParams = { view: view.id.toString() }
jest
.spyOn(activatedRoute, 'queryParamMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams)))
activatedRoute.snapshot.queryParams = queryParams
router.routerState.snapshot.url = '/view/10/'
fixture.detectChanges()
const patchSpy = jest.spyOn(savedViewService, 'patch')
component.saveViewConfig()
expect(patchSpy).not.toHaveBeenCalled()
})
it('should support edited view saving as', () => {
const view: SavedView = {
id: 10,
@@ -566,107 +520,21 @@ describe('DocumentListComponent', () => {
const modalSpy = jest.spyOn(modalService, 'open')
const toastSpy = jest.spyOn(toastService, 'showInfo')
const savedViewServiceCreate = jest.spyOn(savedViewService, 'create')
jest
.spyOn(savedViewService, 'dashboardViews', 'get')
.mockReturnValue([{ id: 77 } as SavedView])
jest
.spyOn(savedViewService, 'sidebarViews', 'get')
.mockReturnValue([{ id: 88 } as SavedView])
const updateVisibilitySpy = jest
.spyOn(settingsService, 'updateSavedViewsVisibility')
.mockReturnValue(of({ success: true }))
savedViewServiceCreate.mockReturnValueOnce(of(modifiedView))
component.saveViewConfigAs()
const modalCloseSpy = jest.spyOn(openModal, 'close')
const permissions = {
owner: 5,
set_permissions: {
view: {
users: [4],
groups: [3],
},
change: {
users: [2],
groups: [1],
},
},
}
openModal.componentInstance.saveClicked.next({
name: 'Foo Bar',
showOnDashboard: true,
showInSideBar: true,
permissions_form: permissions,
show_on_dashboard: true,
show_in_sidebar: true,
})
expect(savedViewServiceCreate).toHaveBeenCalled()
expect(savedViewServiceCreate).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Foo Bar',
owner: permissions.owner,
set_permissions: permissions.set_permissions,
})
)
expect(updateVisibilitySpy).toHaveBeenCalledWith(
expect.arrayContaining([77, modifiedView.id]),
expect.arrayContaining([88, modifiedView.id])
)
expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled()
})
it('should show error when visibility update fails after creating a view', () => {
const view: SavedView = {
id: 10,
name: 'Saved View 10',
sort_field: 'added',
sort_reverse: true,
filter_rules: [
{
rule_type: FILTER_HAS_TAGS_ANY,
value: '20',
},
],
}
jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
const queryParams = { view: view.id.toString() }
jest
.spyOn(activatedRoute, 'queryParamMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams)))
activatedRoute.snapshot.queryParams = queryParams
router.routerState.snapshot.url = '/view/10/'
fixture.detectChanges()
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
jest
.spyOn(savedViewService, 'create')
.mockReturnValueOnce(of({ ...view, id: 42, name: 'Foo Bar' }))
jest.spyOn(savedViewService, 'dashboardViews', 'get').mockReturnValue([])
jest.spyOn(savedViewService, 'sidebarViews', 'get').mockReturnValue([])
jest
.spyOn(settingsService, 'updateSavedViewsVisibility')
.mockReturnValueOnce(
throwError(() => new Error('unable to save visibility settings'))
)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
component.saveViewConfigAs()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.saveClicked.next({
name: 'Foo Bar',
showOnDashboard: true,
showInSideBar: false,
})
expect(modalCloseSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalledWith(
'View "Foo Bar" created successfully, but could not update visibility settings.',
expect.any(Error)
)
})
it('should handle error on edited view saving as', () => {
const view: SavedView = {
id: 10,
@@ -695,10 +563,6 @@ describe('DocumentListComponent', () => {
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const updateVisibilitySpy = jest.spyOn(
settingsService,
'updateSavedViewsVisibility'
)
jest.spyOn(savedViewService, 'create').mockReturnValueOnce(
throwError(
() =>
@@ -711,10 +575,9 @@ describe('DocumentListComponent', () => {
openModal.componentInstance.saveClicked.next({
name: 'Foo Bar',
showOnDashboard: true,
showInSideBar: true,
show_on_dashboard: true,
show_in_sidebar: true,
})
expect(updateVisibilitySpy).not.toHaveBeenCalled()
expect(openModal.componentInstance.error).toEqual({ filter_rules: ['11'] })
})

View File

@@ -47,10 +47,7 @@ import { UsernamePipe } from 'src/app/pipes/username.pipe'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { HotKeyService } from 'src/app/services/hot-key.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import {
PermissionAction,
PermissionsService,
} from 'src/app/services/permissions.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -151,18 +148,12 @@ export class DocumentListComponent
unmodifiedFilterRules: FilterRule[] = []
private unmodifiedSavedView: SavedView
private activeSavedView: SavedView | null = null
private unsubscribeNotifier: Subject<any> = new Subject()
get savedViewIsModified(): boolean {
if (
!this.list.activeSavedViewId ||
!this.unmodifiedSavedView ||
!this.activeSavedViewCanChange
) {
return false
} else {
if (!this.list.activeSavedViewId || !this.unmodifiedSavedView) return false
else {
return (
this.unmodifiedSavedView.sort_field !== this.list.sortField ||
this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse ||
@@ -189,16 +180,6 @@ export class DocumentListComponent
}
}
get activeSavedViewCanChange(): boolean {
if (!this.activeSavedView) {
return false
}
return this.permissionService.currentUserHasObjectPermissions(
PermissionAction.Change,
this.activeSavedView
)
}
get isFiltered() {
return !!this.filterEditor?.rulesModified
}
@@ -231,6 +212,14 @@ export class DocumentListComponent
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 {
return this.list.selected.size > 0
}
@@ -275,13 +264,11 @@ export class DocumentListComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ view }) => {
if (!view) {
this.activeSavedView = null
this.router.navigate(['404'], {
replaceUrl: true,
})
return
}
this.activeSavedView = view
this.unmodifiedSavedView = view
this.list.activateSavedViewWithQueryParams(
view,
@@ -305,7 +292,6 @@ export class DocumentListComponent
// loading a saved view on /documents
this.loadViewConfig(parseInt(queryParams.get('view')))
} else {
this.activeSavedView = null
this.list.activateSavedView(null)
this.list.loadFromQueryParams(queryParams)
this.unmodifiedFilterRules = []
@@ -322,7 +308,7 @@ export class DocumentListComponent
if (this.list.selected.size > 0) {
this.list.selectNone()
} else if (this.isFiltered) {
this.filterEditor.resetSelected()
this.resetFilters()
}
})
@@ -388,7 +374,7 @@ export class DocumentListComponent
}
saveViewConfig() {
if (this.list.activeSavedViewId != null && this.activeSavedViewCanChange) {
if (this.list.activeSavedViewId != null) {
let savedView: SavedView = {
id: this.list.activeSavedViewId,
filter_rules: this.list.filterRules,
@@ -402,7 +388,6 @@ export class DocumentListComponent
.pipe(first())
.subscribe({
next: (view) => {
this.activeSavedView = view
this.unmodifiedSavedView = view
this.toastService.showInfo(
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
@@ -424,11 +409,6 @@ export class DocumentListComponent
.getCached(viewID)
.pipe(first())
.subscribe((view) => {
if (!view) {
this.activeSavedView = null
return
}
this.activeSavedView = view
this.unmodifiedSavedView = view
this.list.activateSavedView(view)
this.list.reload(() => {
@@ -446,48 +426,24 @@ export class DocumentListComponent
modal.componentInstance.buttonsEnabled = false
let savedView: SavedView = {
name: formValue.name,
show_on_dashboard: formValue.showOnDashboard,
show_in_sidebar: formValue.showInSideBar,
filter_rules: this.list.filterRules,
sort_reverse: this.list.sortReverse,
sort_field: this.list.sortField,
display_mode: this.list.displayMode,
display_fields: this.activeDisplayFields,
}
const permissions = formValue.permissions_form
if (permissions) {
if (permissions.owner !== null && permissions.owner !== undefined) {
savedView.owner = permissions.owner
}
if (permissions.set_permissions) {
savedView['set_permissions'] = permissions.set_permissions
}
}
this.savedViewService
.create(savedView)
.pipe(first())
.subscribe({
next: (createdView) => {
this.saveCreatedViewVisibility(
createdView,
formValue.showOnDashboard,
formValue.showInSideBar
next: () => {
modal.close()
this.toastService.showInfo(
$localize`View "${savedView.name}" created successfully.`
)
.pipe(first())
.subscribe({
next: () => {
modal.close()
this.toastService.showInfo(
$localize`View "${savedView.name}" created successfully.`
)
},
error: (error) => {
modal.close()
this.toastService.showError(
$localize`View "${savedView.name}" created successfully, but could not update visibility settings.`,
error
)
},
})
},
error: (httpError) => {
let error = httpError.error
@@ -501,28 +457,6 @@ export class DocumentListComponent
})
}
private saveCreatedViewVisibility(
createdView: SavedView,
showOnDashboard: boolean,
showInSideBar: boolean
) {
const dashboardViewIds = this.savedViewService.dashboardViews.map(
(v) => v.id
)
const sidebarViewIds = this.savedViewService.sidebarViews.map((v) => v.id)
if (showOnDashboard) {
dashboardViewIds.push(createdView.id)
}
if (showInSideBar) {
sidebarViewIds.push(createdView.id)
}
return this.settingsService.updateSavedViewsVisibility(
dashboardViewIds,
sidebarViewIds
)
}
openDocumentDetail(document: Document | number) {
this.router.navigate([
'documents',

View File

@@ -2107,6 +2107,22 @@ describe('FilterEditorComponent', () => {
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', () => {
component.textFilter = 'foo'
component.resetTextField()

View File

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

View File

@@ -8,7 +8,6 @@
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
<pngx-permissions-form [users]="users" accordion="true" formControlName="permissions_form"></pngx-permissions-form>
@if (error?.filter_rules) {
<div class="alert alert-danger" role="alert">
<h6 class="alert-heading" i18n>Filter rules error occurred while saving this view</h6>

View File

@@ -7,13 +7,7 @@ import {
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { NgbActiveModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
import { of } from 'rxjs'
import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { CheckComponent } from '../../common/input/check/check.component'
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
import { TextComponent } from '../../common/input/text/text.component'
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
@@ -24,21 +18,7 @@ describe('SaveViewConfigDialogComponent', () => {
beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
providers: [
NgbActiveModal,
{
provide: UserService,
useValue: {
listAll: () => of({ results: [] }),
},
},
{
provide: GroupService,
useValue: {
listAll: () => of({ results: [] }),
},
},
],
providers: [NgbActiveModal],
imports: [
NgbModalModule,
FormsModule,
@@ -46,9 +26,6 @@ describe('SaveViewConfigDialogComponent', () => {
SaveViewConfigDialogComponent,
TextComponent,
CheckComponent,
PermissionsFormComponent,
PermissionsUserComponent,
PermissionsGroupComponent,
],
}).compileComponents()
@@ -104,26 +81,6 @@ describe('SaveViewConfigDialogComponent', () => {
})
})
it('should support permissions input', () => {
const permissions = {
owner: 10,
set_permissions: {
view: { users: [2], groups: [3] },
change: { users: [4], groups: [5] },
},
}
let result
component.saveClicked.subscribe((saveResult) => (result = saveResult))
component.saveViewConfigForm.get('permissions_form').patchValue(permissions)
component.save()
expect(result).toEqual({
name: '',
showInSideBar: false,
showOnDashboard: false,
permissions_form: permissions,
})
})
it('should support default name', () => {
const saveClickedSpy = jest.spyOn(component.saveClicked, 'emit')
const modalCloseSpy = jest.spyOn(modal, 'close')

View File

@@ -13,27 +13,17 @@ import {
ReactiveFormsModule,
} from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { User } from 'src/app/data/user'
import { UserService } from 'src/app/services/rest/user.service'
import { CheckComponent } from '../../common/input/check/check.component'
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
import { TextComponent } from '../../common/input/text/text.component'
@Component({
selector: 'pngx-save-view-config-dialog',
templateUrl: './save-view-config-dialog.component.html',
styleUrls: ['./save-view-config-dialog.component.scss'],
imports: [
CheckComponent,
TextComponent,
PermissionsFormComponent,
FormsModule,
ReactiveFormsModule,
],
imports: [CheckComponent, TextComponent, FormsModule, ReactiveFormsModule],
})
export class SaveViewConfigDialogComponent implements OnInit {
private modal = inject(NgbActiveModal)
private readonly userService = inject(UserService)
@Output()
public saveClicked = new EventEmitter()
@@ -46,8 +36,6 @@ export class SaveViewConfigDialogComponent implements OnInit {
closeEnabled = false
users: User[]
_defaultName = ''
get defaultName() {
@@ -64,7 +52,6 @@ export class SaveViewConfigDialogComponent implements OnInit {
name: new FormControl(''),
showInSideBar: new FormControl(false),
showOnDashboard: new FormControl(false),
permissions_form: new FormControl(null),
})
ngOnInit(): void {
@@ -72,22 +59,10 @@ export class SaveViewConfigDialogComponent implements OnInit {
setTimeout(() => {
this.closeEnabled = true
})
this.userService.listAll().subscribe((r) => {
this.users = r.results
})
}
save() {
const formValue = this.saveViewConfigForm.value
const saveViewConfig = {
name: formValue.name,
showInSideBar: formValue.showInSideBar,
showOnDashboard: formValue.showOnDashboard,
}
if (formValue.permissions_form) {
saveViewConfig['permissions_form'] = formValue.permissions_form
}
this.saveClicked.emit(saveViewConfig)
this.saveClicked.emit(this.saveViewConfigForm.value)
}
cancel() {

View File

@@ -25,23 +25,15 @@
</div>
</div>
<div class="col-auto">
@if (canDeleteSavedView(view)) {
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
<button
class="btn btn-sm btn-outline-secondary form-control mb-2"
type="button"
(click)="editPermissions(view)"
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.SavedView }"
i18n><i-bs class="me-1" name="person-fill-lock"></i-bs>Permissions</button>
<pngx-confirm-button
label="Delete"
i18n-label
(confirm)="deleteSavedView(view)"
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
buttonClasses="btn-sm btn-outline-danger form-control"
iconName="trash">
</pngx-confirm-button>
}
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
<pngx-confirm-button
label="Delete"
i18n-label
(confirm)="deleteSavedView(view)"
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
buttonClasses="btn-sm btn-outline-danger form-control"
iconName="trash">
</pngx-confirm-button>
</div>
</div>
<div class="row">

View File

@@ -3,16 +3,16 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { By } from '@angular/platform-browser'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs'
import { of, throwError } from 'rxjs'
import { SavedView } from 'src/app/data/saved-view'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { CheckComponent } from '../../common/input/check/check.component'
@@ -32,9 +32,7 @@ describe('SavedViewsComponent', () => {
let component: SavedViewsComponent
let fixture: ComponentFixture<SavedViewsComponent>
let savedViewService: SavedViewService
let settingsService: SettingsService
let toastService: ToastService
let modalService: NgbModal
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -59,8 +57,6 @@ describe('SavedViewsComponent', () => {
provide: PermissionsService,
useValue: {
currentUserCan: () => true,
currentUserHasObjectPermissions: () => true,
currentUserOwnsObject: () => true,
},
},
{
@@ -81,13 +77,11 @@ describe('SavedViewsComponent', () => {
}).compileComponents()
savedViewService = TestBed.inject(SavedViewService)
settingsService = TestBed.inject(SettingsService)
toastService = TestBed.inject(ToastService)
modalService = TestBed.inject(NgbModal)
fixture = TestBed.createComponent(SavedViewsComponent)
component = fixture.componentInstance
jest.spyOn(savedViewService, 'list').mockReturnValue(
jest.spyOn(savedViewService, 'listAll').mockReturnValue(
of({
all: savedViews.map((v) => v.id),
count: savedViews.length,
@@ -100,13 +94,14 @@ describe('SavedViewsComponent', () => {
it('should support save saved views, show error', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSpy = jest.spyOn(toastService, 'show')
const savedViewPatchSpy = jest.spyOn(savedViewService, 'patchMany')
const control = component.savedViewsForm
.get('savedViews')
.get(savedViews[0].id.toString())
.get('name')
control.setValue(`${savedViews[0].name}-changed`)
control.markAsDirty()
const toggle = fixture.debugElement.query(
By.css('.form-check.form-switch input')
)
toggle.nativeElement.checked = true
toggle.nativeElement.dispatchEvent(new Event('change'))
// saved views error first
savedViewPatchSpy.mockReturnValueOnce(
@@ -115,13 +110,12 @@ describe('SavedViewsComponent', () => {
component.save()
expect(toastErrorSpy).toHaveBeenCalled()
expect(savedViewPatchSpy).toHaveBeenCalled()
toastSpy.mockClear()
toastErrorSpy.mockClear()
savedViewPatchSpy.mockClear()
// succeed saved views
savedViewPatchSpy.mockReturnValueOnce(of(savedViews as SavedView[]))
control.setValue(savedViews[0].name)
control.markAsDirty()
component.save()
expect(toastErrorSpy).not.toHaveBeenCalled()
expect(savedViewPatchSpy).toHaveBeenCalled()
@@ -133,65 +127,26 @@ describe('SavedViewsComponent', () => {
expect(patchSpy).not.toHaveBeenCalled()
const view = savedViews[0]
component.savedViewsForm
.get('savedViews')
.get(view.id.toString())
.get('name')
.setValue('changed-view-name')
component.savedViewsForm
.get('savedViews')
.get(view.id.toString())
.get('name')
.markAsDirty()
const toggle = fixture.debugElement.query(
By.css('.form-check.form-switch input')
)
toggle.nativeElement.checked = true
toggle.nativeElement.dispatchEvent(new Event('change'))
// register change
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
'show_on_dashboard'
] = !view.show_on_dashboard
fixture.detectChanges()
component.save()
expect(patchSpy).toHaveBeenCalled()
const patchBody = patchSpy.mock.calls[0][0][0]
expect(patchBody).toMatchObject({
id: view.id,
name: 'changed-view-name',
})
expect(patchBody.show_on_dashboard).toBeUndefined()
expect(patchBody.show_in_sidebar).toBeUndefined()
})
it('should persist visibility changes to user settings', () => {
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
const updateVisibilitySpy = jest
.spyOn(settingsService, 'updateSavedViewsVisibility')
.mockReturnValue(of({ success: true }))
const dashboardControl = component.savedViewsForm
.get('savedViews')
.get(savedViews[0].id.toString())
.get('show_on_dashboard')
dashboardControl.setValue(false)
dashboardControl.markAsDirty()
component.save()
expect(patchSpy).not.toHaveBeenCalled()
expect(updateVisibilitySpy).toHaveBeenCalledWith([], [savedViews[0].id])
})
it('should skip model updates for views that cannot be edited', () => {
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
const updateVisibilitySpy = jest.spyOn(
settingsService,
'updateSavedViewsVisibility'
)
const nameControl = component.savedViewsForm
.get('savedViews')
.get(savedViews[0].id.toString())
.get('name')
nameControl.disable()
component.save()
expect(patchSpy).not.toHaveBeenCalled()
expect(updateVisibilitySpy).not.toHaveBeenCalled()
expect(patchSpy).toHaveBeenCalledWith([
{
id: view.id,
name: view.name,
show_in_sidebar: view.show_in_sidebar,
show_on_dashboard: !view.show_on_dashboard,
},
])
})
it('should support delete saved view', () => {
@@ -207,55 +162,14 @@ describe('SavedViewsComponent', () => {
it('should support reset', () => {
const view = savedViews[0]
component.savedViewsForm
.get('savedViews')
.get(view.id.toString())
.get('show_on_dashboard')
.setValue(!view.show_on_dashboard)
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
'show_on_dashboard'
] = !view.show_on_dashboard
component.reset()
expect(
component.savedViewsForm
.get('savedViews')
.get(view.id.toString())
.get('show_on_dashboard').value
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
'show_on_dashboard'
]
).toEqual(view.show_on_dashboard)
})
it('should support editing permissions', () => {
const confirmClicked = new Subject<any>()
const modalRef = {
componentInstance: {
confirmClicked,
buttonsEnabled: true,
},
close: jest.fn(),
} as any
jest.spyOn(modalService, 'open').mockReturnValue(modalRef)
const patchSpy = jest.spyOn(savedViewService, 'patch')
patchSpy.mockReturnValue(of(savedViews[0] as SavedView))
component.editPermissions(savedViews[0] as SavedView)
confirmClicked.next({
permissions: {
owner: 1,
set_permissions: {
view: { users: [2], groups: [] },
change: { users: [], groups: [3] },
},
},
merge: true,
})
expect(patchSpy).toHaveBeenCalledWith(
expect.objectContaining({
id: savedViews[0].id,
owner: 1,
set_permissions: {
view: { users: [2], groups: [] },
change: { users: [], groups: [3] },
},
})
)
expect(modalRef.close).toHaveBeenCalled()
})
})

View File

@@ -6,18 +6,11 @@ import {
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { dirtyCheck } from '@ngneat/dirty-check-forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { BehaviorSubject, Observable, of, switchMap, takeUntil } from 'rxjs'
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
import { BehaviorSubject, Observable, takeUntil } from 'rxjs'
import { DisplayMode } from 'src/app/data/document'
import { SavedView } from 'src/app/data/saved-view'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
PermissionAction,
PermissionsService,
} from 'src/app/services/permissions.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -41,18 +34,15 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
FormsModule,
ReactiveFormsModule,
AsyncPipe,
NgxBootstrapIconsModule,
],
})
export class SavedViewsComponent
extends LoadingComponentWithPermissions
implements OnInit, OnDestroy
{
private readonly savedViewService = inject(SavedViewService)
private readonly permissionsService = inject(PermissionsService)
private readonly settings = inject(SettingsService)
private readonly toastService = inject(ToastService)
private readonly modalService = inject(NgbModal)
private savedViewService = inject(SavedViewService)
private settings = inject(SettingsService)
private toastService = inject(ToastService)
DisplayMode = DisplayMode
@@ -75,17 +65,11 @@ export class SavedViewsComponent
}
ngOnInit(): void {
this.reloadViews()
}
private reloadViews(): void {
this.loading = true
this.savedViewService
.listAll(null, null, { full_perms: true })
.subscribe((r) => {
this.savedViews = r.results
this.initialize()
})
this.savedViewService.listAll().subscribe((r) => {
this.savedViews = r.results
this.initialize()
})
}
ngOnDestroy(): void {
@@ -111,20 +95,16 @@ export class SavedViewsComponent
display_mode: view.display_mode,
display_fields: view.display_fields,
}
const canEdit = this.canEditSavedView(view)
this.savedViewsGroup.addControl(
view.id.toString(),
new FormGroup({
id: new FormControl({ value: null, disabled: !canEdit }),
name: new FormControl({ value: null, disabled: !canEdit }),
show_on_dashboard: new FormControl({
value: null,
disabled: false,
}),
show_in_sidebar: new FormControl({ value: null, disabled: false }),
page_size: new FormControl({ value: null, disabled: !canEdit }),
display_mode: new FormControl({ value: null, disabled: !canEdit }),
display_fields: new FormControl({ value: [], disabled: !canEdit }),
id: new FormControl(null),
name: new FormControl(null),
show_on_dashboard: new FormControl(null),
show_in_sidebar: new FormControl(null),
page_size: new FormControl(null),
display_mode: new FormControl(null),
display_fields: new FormControl([]),
})
)
}
@@ -153,7 +133,10 @@ export class SavedViewsComponent
$localize`Saved view "${savedView.name}" deleted.`
)
this.savedViewService.clearCache()
this.reloadViews()
this.savedViewService.listAll().subscribe((r) => {
this.savedViews = r.results
this.initialize()
})
})
}
@@ -162,120 +145,26 @@ export class SavedViewsComponent
}
public save() {
// Save only changed views, then save the visibility changes into user settings.
const groups = Object.values(this.savedViewsGroup.controls) as FormGroup[]
const visibilityChanged = groups.some(
(group) =>
group.get('show_on_dashboard')?.dirty ||
group.get('show_in_sidebar')?.dirty
)
// only patch views that have actually changed
const changed: SavedView[] = []
const dashboardVisibleIds: number[] = []
const sidebarVisibleIds: number[] = []
groups.forEach((group) => {
const value = group.getRawValue()
if (value.show_on_dashboard) {
dashboardVisibleIds.push(value.id)
}
if (value.show_in_sidebar) {
sidebarVisibleIds.push(value.id)
}
// Would be fine to send, but no longer stored on the model
delete value.show_on_dashboard
delete value.show_in_sidebar
if (!group.get('name')?.enabled) {
// Quick check for user doesn't have permissions, then bail
return
}
const modelFieldsChanged =
group.get('name')?.dirty ||
group.get('page_size')?.dirty ||
group.get('display_mode')?.dirty ||
group.get('display_fields')?.dirty
if (!modelFieldsChanged) {
return
}
changed.push(value)
})
if (!changed.length && !visibilityChanged) {
return
}
let saveOperation = of([])
Object.values(this.savedViewsGroup.controls)
.filter((g: FormGroup) => !g.pristine)
.forEach((group: FormGroup) => {
changed.push(group.value)
})
if (changed.length) {
saveOperation = saveOperation.pipe(
switchMap(() => this.savedViewService.patchMany(changed))
)
}
if (visibilityChanged) {
saveOperation = saveOperation.pipe(
switchMap(() =>
this.settings.updateSavedViewsVisibility(
dashboardVisibleIds,
sidebarVisibleIds
)
)
)
}
saveOperation.subscribe({
next: () => {
this.toastService.showInfo($localize`Views saved successfully.`)
this.savedViewService.clearCache()
this.reloadViews()
},
error: (error) => {
this.toastService.showError($localize`Error while saving views.`, error)
},
})
}
public canEditSavedView(view: SavedView): boolean {
return this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
view
)
}
public canDeleteSavedView(view: SavedView): boolean {
return this.permissionsService.currentUserOwnsObject(view)
}
public editPermissions(savedView: SavedView): void {
const modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static',
})
const dialog = modal.componentInstance as PermissionsDialogComponent
dialog.object = savedView
dialog.note = $localize`Note: Sharing saved views does not share the underlying documents.`
modal.componentInstance.confirmClicked.subscribe(({ permissions }) => {
modal.componentInstance.buttonsEnabled = false
const view = {
id: savedView.id,
owner: permissions.owner,
}
view['set_permissions'] = permissions.set_permissions
this.savedViewService.patch(view as SavedView).subscribe({
this.savedViewService.patchMany(changed).subscribe({
next: () => {
this.toastService.showInfo($localize`Permissions updated`)
modal.close()
this.reloadViews()
this.toastService.showInfo($localize`Views saved successfully.`)
this.store.next(this.savedViewsForm.value)
},
error: (error) => {
this.toastService.showError(
$localize`Error updating permissions`,
$localize`Error while saving views.`,
error
)
},
})
})
}
}
}

View File

@@ -62,10 +62,6 @@ export const SETTINGS_KEYS = {
'general-settings:update-checking:backend-setting',
SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE:
'general-settings:saved-views:warn-on-unsaved-change',
DASHBOARD_VIEWS_VISIBLE_IDS:
'general-settings:saved-views:dashboard-views-visible-ids',
SIDEBAR_VIEWS_VISIBLE_IDS:
'general-settings:saved-views:sidebar-views-visible-ids',
DASHBOARD_VIEWS_SORT_ORDER:
'general-settings:saved-views:dashboard-views-sort-order',
SIDEBAR_VIEWS_SORT_ORDER:
@@ -252,16 +248,6 @@ export const SETTINGS: UiSetting[] = [
type: 'array',
default: [],
},
{
key: SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS,
type: 'array',
default: [],
},
{
key: SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS,
type: 'array',
default: [],
},
{
key: SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER,
type: 'array',

View File

@@ -6,6 +6,7 @@ export enum WorkflowActionType {
Email = 3,
Webhook = 4,
PasswordRemoval = 5,
MoveToTrash = 6,
}
export interface WorkflowActionEmail extends ObjectWithId {

View File

@@ -164,7 +164,7 @@ describe('DocumentListViewService', () => {
value: tags__id__in,
},
]
documentListViewService.filterRules = filterRulesAny
documentListViewService.setFilterRules(filterRulesAny)
let req = httpTestingController.expectOne(
`${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')
// reset the list
documentListViewService.filterRules = []
documentListViewService.setFilterRules([])
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
)
@@ -210,7 +210,7 @@ describe('DocumentListViewService', () => {
value: tags__id__in,
},
]
documentListViewService.filterRules = filterRulesAny
documentListViewService.setFilterRules(filterRulesAny)
let req = httpTestingController.expectOne(
`${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' })
expect(documentListViewService.error).toEqual('Generic error')
// reset the list
documentListViewService.filterRules = []
documentListViewService.setFilterRules([])
req = httpTestingController.expectOne(
`${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', () => {
documentListViewService.filterRules = filterRules
documentListViewService.setFilterRules(filterRules)
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}`
)
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', () => {
documentListViewService.quickFilter(filterRules)
const req = httpTestingController.expectOne(
@@ -336,7 +364,7 @@ describe('DocumentListViewService', () => {
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&tags__id__all=9`
)
documentListViewService.filterRules = []
documentListViewService.setFilterRules([])
req = httpTestingController.expectOne(
`${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', () => {
documentListViewService.filterRules = []
documentListViewService.setFilterRules([])
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
)
@@ -558,7 +586,7 @@ describe('DocumentListViewService', () => {
req.flush(full_results)
expect(documentListViewService.selected.size).toEqual(6)
documentListViewService.filterRules = filterRules
documentListViewService.setFilterRules(filterRules)
httpTestingController.expectOne(
`${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)
expect(documentListViewService.sortField).toEqual('score')
documentListViewService.filterRules = []
documentListViewService.setFilterRules([])
expect(documentListViewService.sortField).toEqual('created')
httpTestingController.expectOne(
`${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 (
!isFullTextFilterRule(filterRules) &&
this.activeListViewState.sortField == 'score'
@@ -350,6 +350,9 @@ export class DocumentListViewService {
this.activeListViewState.sortField = 'created'
}
this.activeListViewState.filterRules = filterRules
if (resetPage) {
this.activeListViewState.currentPage = 1
}
this.reload()
this.reduceSelectionToFilter()
this.saveDocumentListView()
@@ -479,7 +482,7 @@ export class DocumentListViewService {
quickFilter(filterRules: FilterRule[]) {
this._activeSavedViewId = null
this.filterRules = filterRules
this.setFilterRules(filterRules)
this.router.navigate(['documents'])
}

View File

@@ -57,11 +57,6 @@ describe(`Additional service tests for SavedViewService`, () => {
let settingsService
it('should retrieve saved views and sort them', () => {
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS) return [1, 2, 3]
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS) return [1, 2, 3]
return []
})
service.reload()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
@@ -98,9 +93,7 @@ describe(`Additional service tests for SavedViewService`, () => {
it('should sort dashboard views', () => {
service['savedViews'] = saved_views
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS) return [1, 2, 3]
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return [3, 1, 2]
return []
})
expect(service.dashboardViews).toEqual([
saved_views[2],
@@ -109,21 +102,10 @@ describe(`Additional service tests for SavedViewService`, () => {
])
})
it('should use user-specific dashboard visibility when configured', () => {
service['savedViews'] = saved_views
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS) return [4, 2]
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return []
})
expect(service.dashboardViews).toEqual([saved_views[1], saved_views[3]])
})
it('should sort sidebar views', () => {
service['savedViews'] = saved_views
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS) return [1, 2, 3]
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER) return [3, 1, 2]
return []
})
expect(service.sidebarViews).toEqual([
saved_views[2],
@@ -132,15 +114,6 @@ describe(`Additional service tests for SavedViewService`, () => {
])
})
it('should use user-specific sidebar visibility when configured', () => {
service['savedViews'] = saved_views
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS) return [4, 2]
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER) return []
})
expect(service.sidebarViews).toEqual([saved_views[1], saved_views[3]])
})
it('should treat empty display_fields as null', () => {
subscription = service
.patch({

View File

@@ -36,9 +36,7 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
return super.list(page, pageSize, sortField, sortReverse, extraParams).pipe(
tap({
next: (r) => {
const views = r.results.map((view) => this.withUserVisibility(view))
this.savedViews = views
r.results = views
this.savedViews = r.results
this._loading = false
this.settingsService.dashboardIsEmpty =
this.dashboardViews.length === 0
@@ -67,35 +65,8 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
return this.savedViews
}
private getVisibleViewIds(setting: string): number[] {
const configured = this.settingsService.get(setting)
return Array.isArray(configured) ? configured : []
}
private withUserVisibility(view: SavedView): SavedView {
return {
...view,
show_on_dashboard: this.isDashboardVisible(view),
show_in_sidebar: this.isSidebarVisible(view),
}
}
private isDashboardVisible(view: SavedView): boolean {
const visibleIds = this.getVisibleViewIds(
SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS
)
return visibleIds.includes(view.id)
}
private isSidebarVisible(view: SavedView): boolean {
const visibleIds = this.getVisibleViewIds(
SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS
)
return visibleIds.includes(view.id)
}
get sidebarViews(): SavedView[] {
const sidebarViews = this.savedViews.filter((v) => this.isSidebarVisible(v))
const sidebarViews = this.savedViews.filter((v) => v.show_in_sidebar)
const sorted: number[] = this.settingsService.get(
SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER
@@ -110,9 +81,7 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
}
get dashboardViews(): SavedView[] {
const dashboardViews = this.savedViews.filter((v) =>
this.isDashboardVisible(v)
)
const dashboardViews = this.savedViews.filter((v) => v.show_on_dashboard)
const sorted: number[] = this.settingsService.get(
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER

View File

@@ -320,7 +320,7 @@ describe('SettingsService', () => {
expect(req.request.method).toEqual('POST')
})
it('should update saved view sorting and visibility', () => {
it('should update saved view sorting', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)
@@ -341,15 +341,6 @@ describe('SettingsService', () => {
SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER,
[1, 4]
)
settingsService.updateSavedViewsVisibility([1, 4], [4, 1])
expect(setSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS,
[1, 4]
)
expect(setSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS,
[4, 1]
)
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)

View File

@@ -699,17 +699,4 @@ export class SettingsService {
])
return this.storeSettings()
}
updateSavedViewsVisibility(
dashboardVisibleViewIds: number[],
sidebarVisibleViewIds: number[]
): Observable<any> {
this.set(SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS, [
...new Set(dashboardVisibleViewIds),
])
this.set(SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS, [
...new Set(sidebarVisibleViewIds),
])
return this.storeSettings()
}
}

View File

@@ -3,10 +3,10 @@ const base_url = new URL(document.baseURI)
export const environment = {
production: true,
apiBaseUrl: document.baseURI + 'api/',
apiVersion: '10', // match src/paperless/settings.py
apiVersion: '9', // match src/paperless/settings.py
appTitle: 'Paperless-ngx',
tag: 'prod',
version: '2.20.7',
version: '2.20.8',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.11 on 2026-02-14 19:19
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0011_optimize_integer_field_sizes"),
]
operations = [
migrations.AlterField(
model_name="workflowaction",
name="type",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Assignment"),
(2, "Removal"),
(3, "Email"),
(4, "Webhook"),
(5, "Password removal"),
(6, "Move to trash"),
],
default=1,
verbose_name="Workflow Action Type",
),
),
]

View File

@@ -1,134 +0,0 @@
# Generated by Django 5.2.11 on 2026-02-20 22:05
from django.db import migrations
from django.db import models
# from src-ui/src/app/data/ui-settings.ts
DASHBOARD_VIEWS_VISIBLE_IDS_KEY = (
"general-settings:saved-views:dashboard-views-visible-ids"
)
SIDEBAR_VIEWS_VISIBLE_IDS_KEY = "general-settings:saved-views:sidebar-views-visible-ids"
def _parse_visible_ids(raw_value) -> set[int]:
if not isinstance(raw_value, list):
return set()
parsed_ids = set()
for raw_id in raw_value:
raw_id_string = str(raw_id)
if raw_id_string.isdigit():
parsed_ids.add(int(raw_id_string))
return parsed_ids
def _set_default_visibility_ids(apps, schema_editor):
SavedView = apps.get_model("documents", "SavedView")
UiSettings = apps.get_model("documents", "UiSettings")
User = apps.get_model("auth", "User")
dashboard_visible_ids_by_owner: dict[int, list[int]] = {}
for owner_id, view_id in SavedView.objects.filter(
owner__isnull=False,
show_on_dashboard=True,
).values_list("owner_id", "id"):
dashboard_visible_ids_by_owner.setdefault(owner_id, []).append(view_id)
sidebar_visible_ids_by_owner: dict[int, list[int]] = {}
for owner_id, view_id in SavedView.objects.filter(
owner__isnull=False,
show_in_sidebar=True,
).values_list("owner_id", "id"):
sidebar_visible_ids_by_owner.setdefault(owner_id, []).append(view_id)
for user in User.objects.all():
ui_settings, _ = UiSettings.objects.get_or_create(
user=user,
defaults={"settings": {}},
)
current_settings = ui_settings.settings
if not isinstance(current_settings, dict):
current_settings = {}
changed = False
if current_settings.get(DASHBOARD_VIEWS_VISIBLE_IDS_KEY) is None:
current_settings[DASHBOARD_VIEWS_VISIBLE_IDS_KEY] = (
dashboard_visible_ids_by_owner.get(user.id, [])
)
changed = True
if current_settings.get(SIDEBAR_VIEWS_VISIBLE_IDS_KEY) is None:
current_settings[SIDEBAR_VIEWS_VISIBLE_IDS_KEY] = (
sidebar_visible_ids_by_owner.get(user.id, [])
)
changed = True
if changed:
ui_settings.settings = current_settings
ui_settings.save(update_fields=["settings"])
def _restore_visibility_fields(apps, schema_editor):
SavedView = apps.get_model("documents", "SavedView")
UiSettings = apps.get_model("documents", "UiSettings")
dashboard_visible_ids_by_owner: dict[int, set[int]] = {}
sidebar_visible_ids_by_owner: dict[int, set[int]] = {}
for ui_settings in UiSettings.objects.all():
current_settings = ui_settings.settings
if not isinstance(current_settings, dict):
continue
dashboard_visible_ids_by_owner[ui_settings.user_id] = _parse_visible_ids(
current_settings.get(DASHBOARD_VIEWS_VISIBLE_IDS_KEY),
)
sidebar_visible_ids_by_owner[ui_settings.user_id] = _parse_visible_ids(
current_settings.get(SIDEBAR_VIEWS_VISIBLE_IDS_KEY),
)
SavedView.objects.update(show_on_dashboard=False, show_in_sidebar=False)
for owner_id, dashboard_visible_ids in dashboard_visible_ids_by_owner.items():
if not dashboard_visible_ids:
continue
SavedView.objects.filter(
owner_id=owner_id,
id__in=dashboard_visible_ids,
).update(
show_on_dashboard=True,
)
for owner_id, sidebar_visible_ids in sidebar_visible_ids_by_owner.items():
if not sidebar_visible_ids:
continue
SavedView.objects.filter(owner_id=owner_id, id__in=sidebar_visible_ids).update(
show_in_sidebar=True,
)
class Migration(migrations.Migration):
dependencies = [
("documents", "0011_optimize_integer_field_sizes"),
]
operations = [
migrations.AlterField(
model_name="savedview",
name="show_on_dashboard",
field=models.BooleanField(default=False, verbose_name="show on dashboard"),
),
migrations.AlterField(
model_name="savedview",
name="show_in_sidebar",
field=models.BooleanField(default=False, verbose_name="show in sidebar"),
),
migrations.RunPython(
_set_default_visibility_ids,
reverse_code=_restore_visibility_fields,
),
migrations.RemoveField(
model_name="savedview",
name="show_on_dashboard",
),
migrations.RemoveField(
model_name="savedview",
name="show_in_sidebar",
),
]

View File

@@ -443,6 +443,13 @@ class SavedView(ModelWithOwner):
name = models.CharField(_("name"), max_length=128)
show_on_dashboard = models.BooleanField(
_("show on dashboard"),
)
show_in_sidebar = models.BooleanField(
_("show in sidebar"),
)
sort_field = models.CharField(
_("sort field"),
max_length=128,
@@ -1402,6 +1409,10 @@ class WorkflowAction(models.Model):
5,
_("Password removal"),
)
MOVE_TO_TRASH = (
6,
_("Move to trash"),
)
type = models.PositiveSmallIntegerField(
_("Workflow Action Type"),

View File

@@ -1383,6 +1383,8 @@ class SavedViewSerializer(OwnedObjectSerializer):
fields = [
"id",
"name",
"show_on_dashboard",
"show_in_sidebar",
"sort_field",
"sort_reverse",
"filter_rules",
@@ -1392,7 +1394,6 @@ class SavedViewSerializer(OwnedObjectSerializer):
"owner",
"permissions",
"user_can_change",
"set_permissions",
]
def validate(self, attrs):
@@ -1430,7 +1431,7 @@ class SavedViewSerializer(OwnedObjectSerializer):
and len(validated_data["display_fields"]) == 0
):
validated_data["display_fields"] = None
instance = super().update(instance, validated_data)
super().update(instance, validated_data)
if rules_data is not None:
SavedViewFilterRule.objects.filter(saved_view=instance).delete()
for rule_data in rules_data:
@@ -1442,7 +1443,7 @@ class SavedViewSerializer(OwnedObjectSerializer):
if "user" in validated_data:
# backwards compatibility
validated_data["owner"] = validated_data.pop("user")
saved_view = super().create(validated_data)
saved_view = SavedView.objects.create(**validated_data)
for rule_data in rules_data:
SavedViewFilterRule.objects.create(saved_view=saved_view, **rule_data)
return saved_view

View File

@@ -48,6 +48,7 @@ from documents.permissions import get_objects_for_user_owner_aware
from documents.templating.utils import convert_format_str_to_template_format
from documents.workflows.actions import build_workflow_action_context
from documents.workflows.actions import execute_email_action
from documents.workflows.actions import execute_move_to_trash_action
from documents.workflows.actions import execute_password_removal_action
from documents.workflows.actions import execute_webhook_action
from documents.workflows.mutations import apply_assignment_to_document
@@ -58,6 +59,8 @@ from documents.workflows.utils import get_workflows_for_trigger
from paperless.config import AIConfig
if TYPE_CHECKING:
import uuid
from documents.classifier import DocumentClassifier
from documents.data_models import ConsumableDocument
from documents.data_models import DocumentMetadataOverrides
@@ -727,7 +730,7 @@ def add_to_index(sender, document, **kwargs) -> None:
def run_workflows_added(
sender,
document: Document,
logging_group=None,
logging_group: uuid.UUID | None = None,
original_file=None,
**kwargs,
) -> None:
@@ -743,7 +746,7 @@ def run_workflows_added(
def run_workflows_updated(
sender,
document: Document,
logging_group=None,
logging_group: uuid.UUID | None = None,
**kwargs,
) -> None:
run_workflows(
@@ -757,7 +760,7 @@ def run_workflows(
trigger_type: WorkflowTrigger.WorkflowTriggerType,
document: Document | ConsumableDocument,
workflow_to_run: Workflow | None = None,
logging_group=None,
logging_group: uuid.UUID | None = None,
overrides: DocumentMetadataOverrides | None = None,
original_file: Path | None = None,
) -> tuple[DocumentMetadataOverrides, str] | None:
@@ -783,14 +786,33 @@ def run_workflows(
for workflow in workflows:
if not use_overrides:
# This can be called from bulk_update_documents, which may be running multiple times
# Refresh this so the matching data is fresh and instance fields are re-freshed
# Otherwise, this instance might be behind and overwrite the work another process did
document.refresh_from_db()
doc_tag_ids = list(document.tags.values_list("pk", flat=True))
if TYPE_CHECKING:
assert isinstance(document, Document)
try:
# This can be called from bulk_update_documents, which may be running multiple times
# Refresh this so the matching data is fresh and instance fields are re-freshed
# Otherwise, this instance might be behind and overwrite the work another process did
document.refresh_from_db()
doc_tag_ids = list(document.tags.values_list("pk", flat=True))
except Document.DoesNotExist:
# Document was hard deleted by a previous workflow or another process
logger.info(
"Document no longer exists, skipping remaining workflows",
extra={"group": logging_group},
)
break
# Check if document was soft deleted (moved to trash)
if document.is_deleted:
logger.info(
"Document was moved to trash, skipping remaining workflows",
extra={"group": logging_group},
)
break
if matching.document_matches_workflow(document, workflow, trigger_type):
action: WorkflowAction
has_move_to_trash_action = False
for action in workflow.actions.order_by("order", "pk"):
message = f"Applying {action} from {workflow}"
if not use_overrides:
@@ -834,6 +856,8 @@ def run_workflows(
)
elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL:
execute_password_removal_action(action, document, logging_group)
elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
has_move_to_trash_action = True
if not use_overrides:
# limit title to 128 characters
@@ -848,7 +872,12 @@ def run_workflows(
document=document if not use_overrides else None,
)
if has_move_to_trash_action:
execute_move_to_trash_action(action, document, logging_group)
if use_overrides:
if TYPE_CHECKING:
assert overrides is not None
return overrides, "\n".join(messages)

View File

@@ -2014,93 +2014,69 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
mock_get_date_parser.assert_not_called()
def test_saved_views(self) -> None:
u1 = User.objects.create_user("user1")
u2 = User.objects.create_user("user2")
u3 = User.objects.create_user("user3")
view_perm = Permission.objects.get(codename="view_savedview")
change_perm = Permission.objects.get(codename="change_savedview")
for user in [u1, u2, u3]:
user.user_permissions.add(view_perm, change_perm)
u1 = User.objects.create_superuser("user1")
u2 = User.objects.create_superuser("user2")
v1 = SavedView.objects.create(
owner=u1,
name="test1",
sort_field="",
show_on_dashboard=False,
show_in_sidebar=False,
)
v2 = SavedView.objects.create(
SavedView.objects.create(
owner=u2,
name="test2",
sort_field="",
show_on_dashboard=False,
show_in_sidebar=False,
)
v3 = SavedView.objects.create(
SavedView.objects.create(
owner=u2,
name="test3",
sort_field="",
show_on_dashboard=False,
show_in_sidebar=False,
)
assign_perm("view_savedview", u1, v2)
assign_perm("change_savedview", u1, v2)
assign_perm("view_savedview", u1, v3)
response = self.client.get("/api/saved_views/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 0)
self.assertEqual(
self.client.get(f"/api/saved_views/{v1.id}/").status_code,
status.HTTP_404_NOT_FOUND,
)
self.client.force_authenticate(user=u1)
response = self.client.get("/api/saved_views/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 3)
self.assertEqual(response.data["count"], 1)
for view_id in [v1.id, v2.id, v3.id]:
self.assertEqual(
self.client.get(f"/api/saved_views/{view_id}/").status_code,
status.HTTP_200_OK,
)
response = self.client.patch(
f"/api/saved_views/{v2.id}/",
{"sort_field": "added"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self.client.patch(
f"/api/saved_views/{v3.id}/",
{"sort_field": "added"},
format="json",
)
self.assertEqual(
response.status_code,
status.HTTP_403_FORBIDDEN,
self.client.get(f"/api/saved_views/{v1.id}/").status_code,
status.HTTP_200_OK,
)
response = self.client.patch(
f"/api/saved_views/{v2.id}/",
{
"set_permissions": {
"view": {"users": [u3.id]},
},
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
response = self.client.patch(
f"/api/saved_views/{v2.id}/",
{"owner": u1.id},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.client.force_authenticate(user=u3)
self.client.force_authenticate(user=u2)
response = self.client.get("/api/saved_views/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 0)
self.assertEqual(response.data["count"], 2)
self.assertEqual(
self.client.get(f"/api/saved_views/{v1.id}/").status_code,
status.HTTP_404_NOT_FOUND,
)
def test_saved_view_create_update_patch(self) -> None:
User.objects.create_user("user1")
view = {
"name": "test",
"show_on_dashboard": True,
"show_in_sidebar": True,
"sort_field": "created2",
"filter_rules": [{"rule_type": 4, "value": "test"}],
}
@@ -2115,13 +2091,13 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
response = self.client.patch(
f"/api/saved_views/{v1.id}/",
{"sort_reverse": True},
{"show_in_sidebar": False},
format="json",
)
v1 = SavedView.objects.get(id=v1.id)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(v1.sort_reverse)
self.assertFalse(v1.show_in_sidebar)
self.assertEqual(v1.filter_rules.count(), 1)
view["filter_rules"] = [{"rule_type": 12, "value": "secret"}]
@@ -2155,6 +2131,8 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
view = {
"name": "test",
"show_on_dashboard": True,
"show_in_sidebar": True,
"sort_field": "created2",
"filter_rules": [{"rule_type": 4, "value": "test"}],
"page_size": 20,
@@ -2242,6 +2220,8 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
"""
view = {
"name": "test",
"show_on_dashboard": True,
"show_in_sidebar": True,
"sort_field": "created2",
"filter_rules": [{"rule_type": 4, "value": "test"}],
"page_size": 20,
@@ -2317,6 +2297,8 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
owner=self.user,
name="test",
sort_field=SavedView.DisplayFields.CUSTOM_FIELD % custom_field.id,
show_on_dashboard=True,
show_in_sidebar=True,
display_fields=[
SavedView.DisplayFields.TITLE,
SavedView.DisplayFields.CREATED,

View File

@@ -1307,12 +1307,13 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
tag1 = Tag.objects.create(name="bank tag1")
Tag.objects.create(name="tag2")
shared_view = SavedView.objects.create(
SavedView.objects.create(
name="bank view",
show_on_dashboard=True,
show_in_sidebar=True,
sort_field="",
owner=user2,
owner=user1,
)
assign_perm("view_savedview", user1, shared_view)
mail_account1 = MailAccount.objects.create(name="bank mail account 1")
mail_account2 = MailAccount.objects.create(name="mail account 2")
mail_rule1 = MailRule.objects.create(

View File

@@ -896,3 +896,210 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
"Passwords are required",
str(response.data["non_field_errors"][0]),
)
def test_trash_action_validation(self) -> None:
"""
GIVEN:
- API request to create a workflow with a trash action
WHEN:
- API is called
THEN:
- Correct HTTP response
"""
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow 2",
"order": 1,
"triggers": [
{
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"sources": [DocumentSource.ApiUpload],
"filter_filename": "*",
},
],
"actions": [
{
"type": WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow 3",
"order": 2,
"triggers": [
{
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"sources": [DocumentSource.ApiUpload],
"filter_filename": "*",
},
],
"actions": [
{
"type": WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_trash_action_as_last_action_valid(self) -> None:
"""
GIVEN:
- API request to create a workflow with multiple actions
- Move to trash action is the last action
WHEN:
- API is called
THEN:
- Workflow is created successfully
"""
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow with Move to Trash Last",
"order": 1,
"triggers": [
{
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"sources": [DocumentSource.ApiUpload],
"filter_filename": "*",
},
],
"actions": [
{
"type": WorkflowAction.WorkflowActionType.ASSIGNMENT,
"assign_title": "Assigned Title",
},
{
"type": WorkflowAction.WorkflowActionType.REMOVAL,
"remove_all_tags": True,
},
{
"type": WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_update_workflow_add_trash_at_end_valid(self) -> None:
"""
GIVEN:
- Existing workflow without trash action
WHEN:
- PATCH to add trash action at end
THEN:
- HTTP 200 success
"""
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow to Add Move to Trash",
"order": 1,
"triggers": [
{
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"sources": [DocumentSource.ApiUpload],
"filter_filename": "*",
},
],
"actions": [
{
"type": WorkflowAction.WorkflowActionType.ASSIGNMENT,
"assign_title": "First Action",
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
workflow_id = response.data["id"]
response = self.client.patch(
f"{self.ENDPOINT}{workflow_id}/",
json.dumps(
{
"actions": [
{
"type": WorkflowAction.WorkflowActionType.ASSIGNMENT,
"assign_title": "First Action",
},
{
"type": WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_update_workflow_remove_trash_action_valid(self) -> None:
"""
GIVEN:
- Existing workflow with trash action
WHEN:
- PATCH to remove trash action
THEN:
- HTTP 200 success
"""
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow to Remove move to trash",
"order": 1,
"triggers": [
{
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"sources": [DocumentSource.ApiUpload],
"filter_filename": "*",
},
],
"actions": [
{
"type": WorkflowAction.WorkflowActionType.ASSIGNMENT,
"assign_title": "First Action",
},
{
"type": WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
workflow_id = response.data["id"]
response = self.client.patch(
f"{self.ENDPOINT}{workflow_id}/",
json.dumps(
{
"actions": [
{
"type": WorkflowAction.WorkflowActionType.ASSIGNMENT,
"assign_title": "Only Action",
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)

View File

@@ -1,211 +0,0 @@
from documents.tests.utils import TestMigrations
DASHBOARD_VIEWS_VISIBLE_IDS_KEY = (
"general-settings:saved-views:dashboard-views-visible-ids"
)
SIDEBAR_VIEWS_VISIBLE_IDS_KEY = "general-settings:saved-views:sidebar-views-visible-ids"
class TestMigrateSavedViewVisibilityToUiSettings(TestMigrations):
migrate_from = "0011_optimize_integer_field_sizes"
migrate_to = "0012_savedview_visibility_to_ui_settings"
def setUpBeforeMigration(self, apps) -> None:
User = apps.get_model("auth", "User")
SavedView = apps.get_model("documents", "SavedView")
UiSettings = apps.get_model("documents", "UiSettings")
self.user_with_empty_settings = User.objects.create(username="user1")
self.user_with_existing_settings = User.objects.create(username="user2")
self.user_with_owned_views = User.objects.create(username="user3")
self.user_with_invalid_settings = User.objects.create(username="user4")
self.user_with_empty_settings_id = self.user_with_empty_settings.id
self.user_with_existing_settings_id = self.user_with_existing_settings.id
self.user_with_owned_views_id = self.user_with_owned_views.id
self.user_with_invalid_settings_id = self.user_with_invalid_settings.id
self.dashboard_view = SavedView.objects.create(
owner=self.user_with_empty_settings,
name="dashboard",
show_on_dashboard=True,
show_in_sidebar=True,
sort_field="created",
)
self.sidebar_only_view = SavedView.objects.create(
owner=self.user_with_empty_settings,
name="sidebar-only",
show_on_dashboard=False,
show_in_sidebar=True,
sort_field="created",
)
self.hidden_view = SavedView.objects.create(
owner=self.user_with_empty_settings,
name="hidden",
show_on_dashboard=False,
show_in_sidebar=False,
sort_field="created",
)
self.other_owner_visible_view = SavedView.objects.create(
owner=self.user_with_owned_views,
name="other-owner-visible",
show_on_dashboard=True,
show_in_sidebar=True,
sort_field="created",
)
self.invalid_settings_owner_view = SavedView.objects.create(
owner=self.user_with_invalid_settings,
name="invalid-settings-owner-visible",
show_on_dashboard=True,
show_in_sidebar=False,
sort_field="created",
)
UiSettings.objects.create(user=self.user_with_empty_settings, settings={})
UiSettings.objects.create(
user=self.user_with_existing_settings,
settings={
DASHBOARD_VIEWS_VISIBLE_IDS_KEY: [self.sidebar_only_view.id],
SIDEBAR_VIEWS_VISIBLE_IDS_KEY: [self.dashboard_view.id],
"preserve": "value",
},
)
UiSettings.objects.create(
user=self.user_with_invalid_settings,
settings=[],
)
def test_visibility_defaults_are_seeded_and_existing_values_preserved(self) -> None:
UiSettings = self.apps.get_model("documents", "UiSettings")
seeded_settings = UiSettings.objects.get(
user_id=self.user_with_empty_settings_id,
).settings
self.assertCountEqual(
seeded_settings[DASHBOARD_VIEWS_VISIBLE_IDS_KEY],
[self.dashboard_view.id],
)
self.assertCountEqual(
seeded_settings[SIDEBAR_VIEWS_VISIBLE_IDS_KEY],
[self.dashboard_view.id, self.sidebar_only_view.id],
)
existing_settings = UiSettings.objects.get(
user_id=self.user_with_existing_settings_id,
).settings
self.assertEqual(
existing_settings[DASHBOARD_VIEWS_VISIBLE_IDS_KEY],
[self.sidebar_only_view.id],
)
self.assertEqual(
existing_settings[SIDEBAR_VIEWS_VISIBLE_IDS_KEY],
[self.dashboard_view.id],
)
self.assertEqual(existing_settings["preserve"], "value")
created_settings = UiSettings.objects.get(
user_id=self.user_with_owned_views_id,
).settings
self.assertCountEqual(
created_settings[DASHBOARD_VIEWS_VISIBLE_IDS_KEY],
[self.other_owner_visible_view.id],
)
self.assertCountEqual(
created_settings[SIDEBAR_VIEWS_VISIBLE_IDS_KEY],
[self.other_owner_visible_view.id],
)
invalid_settings = UiSettings.objects.get(
user_id=self.user_with_invalid_settings_id,
).settings
self.assertIsInstance(invalid_settings, dict)
self.assertCountEqual(
invalid_settings[DASHBOARD_VIEWS_VISIBLE_IDS_KEY],
[self.invalid_settings_owner_view.id],
)
self.assertEqual(
invalid_settings[SIDEBAR_VIEWS_VISIBLE_IDS_KEY],
[],
)
class TestReverseMigrateSavedViewVisibilityFromUiSettings(TestMigrations):
migrate_from = "0012_savedview_visibility_to_ui_settings"
migrate_to = "0011_optimize_integer_field_sizes"
def setUpBeforeMigration(self, apps) -> None:
User = apps.get_model("auth", "User")
SavedView = apps.get_model("documents", "SavedView")
UiSettings = apps.get_model("documents", "UiSettings")
user1 = User.objects.create(username="user1")
user2 = User.objects.create(username="user2")
user3 = User.objects.create(username="user3")
user4 = User.objects.create(username="user4")
self.view1 = SavedView.objects.create(
owner=user1,
name="view-1",
sort_field="created",
)
self.view2 = SavedView.objects.create(
owner=user1,
name="view-2",
sort_field="created",
)
self.view3 = SavedView.objects.create(
owner=user1,
name="view-3",
sort_field="created",
)
self.view4 = SavedView.objects.create(
owner=user2,
name="view-4",
sort_field="created",
)
self.view5 = SavedView.objects.create(
owner=user4,
name="view-5",
sort_field="created",
)
UiSettings.objects.create(
user=user1,
settings={
DASHBOARD_VIEWS_VISIBLE_IDS_KEY: [str(self.view1.id)],
SIDEBAR_VIEWS_VISIBLE_IDS_KEY: [self.view2.id],
},
)
UiSettings.objects.create(
user=user2,
settings={
DASHBOARD_VIEWS_VISIBLE_IDS_KEY: [
self.view2.id,
self.view3.id,
self.view4.id,
],
SIDEBAR_VIEWS_VISIBLE_IDS_KEY: [self.view4.id],
},
)
UiSettings.objects.create(user=user3, settings={})
UiSettings.objects.create(user=user4, settings=[])
def test_visibility_fields_restored_from_owner_visibility(self) -> None:
SavedView = self.apps.get_model("documents", "SavedView")
restored_view1 = SavedView.objects.get(pk=self.view1.id)
restored_view2 = SavedView.objects.get(pk=self.view2.id)
restored_view3 = SavedView.objects.get(pk=self.view3.id)
restored_view4 = SavedView.objects.get(pk=self.view4.id)
restored_view5 = SavedView.objects.get(pk=self.view5.id)
self.assertTrue(restored_view1.show_on_dashboard)
self.assertFalse(restored_view2.show_on_dashboard)
self.assertFalse(restored_view3.show_on_dashboard)
self.assertTrue(restored_view4.show_on_dashboard)
self.assertFalse(restored_view1.show_in_sidebar)
self.assertTrue(restored_view2.show_in_sidebar)
self.assertFalse(restored_view3.show_in_sidebar)
self.assertTrue(restored_view4.show_in_sidebar)
self.assertFalse(restored_view5.show_on_dashboard)
self.assertFalse(restored_view5.show_in_sidebar)

View File

@@ -3,9 +3,11 @@ import json
import shutil
import socket
import tempfile
from collections.abc import Callable
from datetime import timedelta
from pathlib import Path
from typing import TYPE_CHECKING
from typing import Any
from unittest import mock
import pytest
@@ -55,6 +57,7 @@ from documents.models import WorkflowActionEmail
from documents.models import WorkflowActionWebhook
from documents.models import WorkflowRun
from documents.models import WorkflowTrigger
from documents.plugins.base import StopConsumeTaskError
from documents.serialisers import WorkflowTriggerSerializer
from documents.signals import document_consumption_finished
from documents.tests.utils import DirectoriesMixin
@@ -3914,6 +3917,427 @@ class TestWorkflows(
)
assert mock_remove_password.call_count == 2
def test_workflow_trash_action_soft_delete(self):
"""
GIVEN:
- Document updated workflow with delete action
WHEN:
- Document that matches is updated
THEN:
- Document is moved to trash (soft deleted)
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
)
w = Workflow.objects.create(
name="Workflow 1",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
self.assertEqual(Document.objects.count(), 1)
self.assertEqual(Document.deleted_objects.count(), 0)
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
self.assertEqual(Document.objects.count(), 0)
self.assertEqual(Document.deleted_objects.count(), 1)
@override_settings(
PAPERLESS_EMAIL_HOST="localhost",
EMAIL_ENABLED=True,
PAPERLESS_URL="http://localhost:8000",
)
@mock.patch("django.core.mail.message.EmailMessage.send")
def test_workflow_trash_with_email_action(self, mock_email_send):
"""
GIVEN:
- Workflow with email action, then move to trash action
WHEN:
- Document matches and workflow runs
THEN:
- Email is sent first
- Document is moved to trash (soft deleted)
"""
mock_email_send.return_value = 1
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
email_action = WorkflowActionEmail.objects.create(
subject="Document deleted: {doc_title}",
body="Document {doc_title} will be deleted",
to="user@example.com",
include_document=False,
)
email_workflow_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.EMAIL,
email=email_action,
)
trash_workflow_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
)
w = Workflow.objects.create(
name="Workflow with email then move to trash",
order=0,
)
w.triggers.add(trigger)
w.actions.add(email_workflow_action, trash_workflow_action)
w.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
self.assertEqual(Document.objects.count(), 1)
self.assertEqual(Document.deleted_objects.count(), 0)
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
mock_email_send.assert_called_once()
self.assertEqual(Document.objects.count(), 0)
self.assertEqual(Document.deleted_objects.count(), 1)
@override_settings(
PAPERLESS_URL="http://localhost:8000",
)
@mock.patch("documents.workflows.webhooks.send_webhook.delay")
def test_workflow_trash_with_webhook_action(self, mock_webhook_delay):
"""
GIVEN:
- Workflow with webhook action (include_document=True), then move to trash action
WHEN:
- Document matches and workflow runs
THEN:
- Webhook .delay() is called with complete data including file bytes
- Document is moved to trash (soft deleted)
- Webhook task has all necessary data and doesn't rely on document existence
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
webhook_action = WorkflowActionWebhook.objects.create(
use_params=True,
params={
"title": "{{doc_title}}",
"message": "Document being deleted",
},
url="https://paperless-ngx.com/webhook",
include_document=True,
)
webhook_workflow_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.WEBHOOK,
webhook=webhook_action,
)
trash_workflow_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
)
w = Workflow.objects.create(
name="Workflow with webhook then move to trash",
order=0,
)
w.triggers.add(trigger)
w.actions.add(webhook_workflow_action, trash_workflow_action)
w.save()
test_file = shutil.copy(
self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf",
)
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="simple.pdf",
filename=test_file,
mime_type="application/pdf",
)
self.assertEqual(Document.objects.count(), 1)
self.assertEqual(Document.deleted_objects.count(), 0)
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
mock_webhook_delay.assert_called_once()
call_kwargs = mock_webhook_delay.call_args[1]
self.assertEqual(call_kwargs["url"], "https://paperless-ngx.com/webhook")
self.assertEqual(
call_kwargs["data"],
{"title": "sample test", "message": "Document being deleted"},
)
self.assertIsNotNone(call_kwargs["files"])
self.assertIn("file", call_kwargs["files"])
self.assertEqual(call_kwargs["files"]["file"][0], "simple.pdf")
self.assertEqual(call_kwargs["files"]["file"][2], "application/pdf")
self.assertIsInstance(call_kwargs["files"]["file"][1], bytes)
self.assertEqual(Document.objects.count(), 0)
self.assertEqual(Document.deleted_objects.count(), 1)
@override_settings(
PAPERLESS_EMAIL_HOST="localhost",
EMAIL_ENABLED=True,
PAPERLESS_URL="http://localhost:8000",
)
@mock.patch("django.core.mail.message.EmailMessage.send")
def test_workflow_trash_after_email_failure(self, mock_email_send) -> None:
"""
GIVEN:
- Workflow with email action (that fails), then move to trash action
WHEN:
- Document matches and workflow runs
- Email action raises exception
THEN:
- Email failure is logged
- Move to Trash still executes successfully (soft delete)
"""
mock_email_send.side_effect = Exception("Email server error")
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
email_action = WorkflowActionEmail.objects.create(
subject="Document deleted: {doc_title}",
body="Document {doc_title} will be deleted",
to="user@example.com",
include_document=False,
)
email_workflow_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.EMAIL,
email=email_action,
)
trash_workflow_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
)
w = Workflow.objects.create(
name="Workflow with failing email then move to trash",
order=0,
)
w.triggers.add(trigger)
w.actions.add(email_workflow_action, trash_workflow_action)
w.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
self.assertEqual(Document.objects.count(), 1)
self.assertEqual(Document.deleted_objects.count(), 0)
with self.assertLogs("paperless.workflows.actions", level="ERROR") as cm:
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
expected_str = "Error occurred sending notification email"
self.assertIn(expected_str, cm.output[0])
self.assertEqual(Document.objects.count(), 0)
self.assertEqual(Document.deleted_objects.count(), 1)
def test_multiple_workflows_trash_then_assignment(self):
"""
GIVEN:
- Workflow 1 (order=0) with move to trash action
- Workflow 2 (order=1) with assignment action
- Both workflows match the same document
WHEN:
- Workflows run sequentially
THEN:
- First workflow runs and deletes document (soft delete)
- Second workflow does not trigger (document no longer exists)
- Logs confirm move to trash and skipping of remaining workflows
"""
trigger1 = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
trash_workflow_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
)
w1 = Workflow.objects.create(
name="Workflow 1 - Move to Trash",
order=0,
)
w1.triggers.add(trigger1)
w1.actions.add(trash_workflow_action)
w1.save()
trigger2 = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
)
assignment_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
assign_correspondent=self.c2,
)
w2 = Workflow.objects.create(
name="Workflow 2 - Assignment",
order=1,
)
w2.triggers.add(trigger2)
w2.actions.add(assignment_action)
w2.save()
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
)
self.assertEqual(Document.objects.count(), 1)
self.assertEqual(Document.deleted_objects.count(), 0)
with self.assertLogs("paperless", level="DEBUG") as cm:
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
self.assertEqual(Document.objects.count(), 0)
self.assertEqual(Document.deleted_objects.count(), 1)
# We check logs instead of WorkflowRun.objects.count() because when the document
# is soft-deleted, the WorkflowRun is cascade-deleted (hard delete) since it does
# not inherit from the SoftDeleteModel. The logs confirm that the first workflow
# executed the move to trash and remaining workflows were skipped.
log_output = "\n".join(cm.output)
self.assertIn("Moved document", log_output)
self.assertIn("to trash", log_output)
self.assertIn(
"Document was moved to trash, skipping remaining workflows",
log_output,
)
def test_workflow_delete_action_during_consumption(self):
"""
GIVEN:
- Workflow with consumption trigger and delete action
WHEN:
- Document is being consumed and workflow runs
THEN:
- StopConsumeTaskError is raised to halt consumption
- Original file is deleted
- No document is created
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
sources=f"{DocumentSource.ConsumeFolder}",
filter_filename="*",
)
action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
)
w = Workflow.objects.create(
name="Workflow Delete During Consumption",
order=0,
)
w.triggers.add(trigger)
w.actions.add(action)
w.save()
# Create a test file to be consumed
test_file = shutil.copy(
self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple.pdf",
)
test_file_path = Path(test_file)
self.assertTrue(test_file_path.exists())
# Create a ConsumableDocument
consumable_doc = ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=test_file_path,
)
self.assertEqual(Document.objects.count(), 0)
# Run workflows with overrides (consumption flow)
with self.assertRaises(StopConsumeTaskError) as context:
run_workflows(
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
consumable_doc,
overrides=DocumentMetadataOverrides(),
)
self.assertIn("deleted by workflow action", str(context.exception))
# File should be deleted
self.assertFalse(test_file_path.exists())
# No document should be created
self.assertEqual(Document.objects.count(), 0)
def test_workflow_delete_action_during_consumption_with_assignment(self):
"""
GIVEN:
- Workflow with consumption trigger, assignment action, then delete action
WHEN:
- Document is being consumed and workflow runs
THEN:
- StopConsumeTaskError is raised to halt consumption
- Original file is deleted
- No document is created (even though assignment would have worked)
"""
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
sources=f"{DocumentSource.ConsumeFolder}",
filter_filename="*",
)
assignment_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
assign_title="This should not be applied",
assign_correspondent=self.c,
)
trash_workflow_action = WorkflowAction.objects.create(
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
)
w = Workflow.objects.create(
name="Workflow Assignment then Delete During Consumption",
order=0,
)
w.triggers.add(trigger)
w.actions.add(assignment_action, trash_workflow_action)
w.save()
# Create a test file to be consumed
test_file = shutil.copy(
self.SAMPLE_DIR / "simple.pdf",
self.dirs.scratch_dir / "simple2.pdf",
)
test_file_path = Path(test_file)
self.assertTrue(test_file_path.exists())
# Create a ConsumableDocument
consumable_doc = ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=test_file_path,
)
self.assertEqual(Document.objects.count(), 0)
# Run workflows with overrides (consumption flow)
with self.assertRaises(StopConsumeTaskError):
run_workflows(
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
consumable_doc,
overrides=DocumentMetadataOverrides(),
)
# File should be deleted
self.assertFalse(test_file_path.exists())
# No document should be created
self.assertEqual(Document.objects.count(), 0)
class TestWebhookSend:
def test_send_webhook_data_or_json(
@@ -3956,13 +4380,17 @@ class TestWebhookSend:
@pytest.fixture
def resolve_to(monkeypatch):
def resolve_to(monkeypatch: pytest.MonkeyPatch) -> Callable[[str], None]:
"""
Force DNS resolution to a specific IP for any hostname.
"""
def _set(ip: str):
def fake_getaddrinfo(host, *_args, **_kwargs):
def _set(ip: str) -> None:
def fake_getaddrinfo(
host: str,
*_args: object,
**_kwargs: object,
) -> list[tuple[Any, ...]]:
return [(socket.AF_INET, None, None, "", (ip, 0))]
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
@@ -4103,7 +4531,7 @@ class TestWebhookSecurity:
def test_strips_user_supplied_host_header(
self,
httpx_mock: HTTPXMock,
resolve_to,
resolve_to: Callable[[str], None],
) -> None:
"""
GIVEN:
@@ -4169,7 +4597,7 @@ class TestDateWorkflowLocalization(
self,
title_template: str,
expected_title: str,
):
) -> None:
"""
GIVEN:
- Document added workflow with title template using localize_date filter
@@ -4234,7 +4662,7 @@ class TestDateWorkflowLocalization(
self,
title_template: str,
expected_title: str,
):
) -> None:
"""
GIVEN:
- Document updated workflow with title template using localize_date filter
@@ -4310,7 +4738,7 @@ class TestDateWorkflowLocalization(
settings: SettingsWrapper,
title_template: str,
expected_title: str,
):
) -> None:
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
sources=f"{DocumentSource.ApiUpload}",

View File

@@ -1660,21 +1660,24 @@ class LogViewSet(ViewSet):
return Response(existing_logs)
@extend_schema_view(**generate_object_with_permissions_schema(SavedViewSerializer))
class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet):
class SavedViewViewSet(ModelViewSet, PassUserMixin):
model = SavedView
queryset = SavedView.objects.select_related("owner").prefetch_related(
"filter_rules",
)
queryset = SavedView.objects.all()
serializer_class = SavedViewSerializer
pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
ordering_fields = ("name",)
def get_queryset(self):
user = self.request.user
return (
SavedView.objects.filter(owner=user)
.select_related("owner")
.prefetch_related("filter_rules")
)
def perform_create(self, serializer) -> None:
serializer.save(owner=self.request.user)
@extend_schema_view(
@@ -2198,11 +2201,7 @@ class GlobalSearchView(PassUserMixin):
)
docs = docs[:OBJECT_LIMIT]
saved_views = (
get_objects_for_user_owner_aware(
request.user,
"view_savedview",
SavedView,
).filter(name__icontains=query)
SavedView.objects.filter(owner=request.user, name__icontains=query)
if request.user.has_perm("documents.view_savedview")
else []
)

View File

@@ -1,5 +1,6 @@
import logging
import re
import uuid
from pathlib import Path
from django.conf import settings
@@ -15,6 +16,7 @@ from documents.models import Document
from documents.models import DocumentType
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.plugins.base import StopConsumeTaskError
from documents.signals import document_consumption_finished
from documents.templating.workflows import parse_w_workflow_placeholders
from documents.workflows.webhooks import send_webhook
@@ -338,3 +340,33 @@ def execute_password_removal_action(
document.pk,
extra={"group": logging_group},
)
def execute_move_to_trash_action(
action: WorkflowAction,
document: Document | ConsumableDocument,
logging_group: uuid.UUID | None,
) -> None:
"""
Execute a move to trash action for a workflow on an existing document or a
document in consumption. In case of an existing document it soft-deletes
the document. In case of consumption it aborts consumption and deletes the
file.
"""
if isinstance(document, Document):
document.delete()
logger.debug(
f"Moved document {document} to trash",
extra={"group": logging_group},
)
else:
if document.original_file.exists():
document.original_file.unlink()
logger.info(
f"Workflow move to trash action triggered during consumption, "
f"deleting file {document.original_file}",
extra={"group": logging_group},
)
raise StopConsumeTaskError(
"Document deleted by workflow action during consumption",
)

View File

@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-16 17:32+0000\n"
"POT-Creation-Date: 2026-02-24 00:43+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -89,7 +89,7 @@ msgstr ""
msgid "Automatic"
msgstr ""
#: documents/models.py:66 documents/models.py:444 documents/models.py:1659
#: documents/models.py:66 documents/models.py:444 documents/models.py:1663
#: paperless_mail/models.py:23 paperless_mail/models.py:143
msgid "name"
msgstr ""
@@ -252,7 +252,7 @@ msgid "The position of this document in your physical document archive."
msgstr ""
#: documents/models.py:313 documents/models.py:688 documents/models.py:742
#: documents/models.py:1702
#: documents/models.py:1706
msgid "document"
msgstr ""
@@ -1093,193 +1093,197 @@ msgid "Password removal"
msgstr ""
#: documents/models.py:1414
msgid "Move to trash"
msgstr ""
#: documents/models.py:1418
msgid "Workflow Action Type"
msgstr ""
#: documents/models.py:1419 documents/models.py:1661
#: documents/models.py:1423 documents/models.py:1665
#: paperless_mail/models.py:145
msgid "order"
msgstr ""
#: documents/models.py:1422
#: documents/models.py:1426
msgid "assign title"
msgstr ""
#: documents/models.py:1426
#: documents/models.py:1430
msgid "Assign a document title, must be a Jinja2 template, see documentation."
msgstr ""
#: documents/models.py:1434 paperless_mail/models.py:274
#: documents/models.py:1438 paperless_mail/models.py:274
msgid "assign this tag"
msgstr ""
#: documents/models.py:1443 paperless_mail/models.py:282
#: documents/models.py:1447 paperless_mail/models.py:282
msgid "assign this document type"
msgstr ""
#: documents/models.py:1452 paperless_mail/models.py:296
#: documents/models.py:1456 paperless_mail/models.py:296
msgid "assign this correspondent"
msgstr ""
#: documents/models.py:1461
#: documents/models.py:1465
msgid "assign this storage path"
msgstr ""
#: documents/models.py:1470
#: documents/models.py:1474
msgid "assign this owner"
msgstr ""
#: documents/models.py:1477
#: documents/models.py:1481
msgid "grant view permissions to these users"
msgstr ""
#: documents/models.py:1484
#: documents/models.py:1488
msgid "grant view permissions to these groups"
msgstr ""
#: documents/models.py:1491
#: documents/models.py:1495
msgid "grant change permissions to these users"
msgstr ""
#: documents/models.py:1498
#: documents/models.py:1502
msgid "grant change permissions to these groups"
msgstr ""
#: documents/models.py:1505
#: documents/models.py:1509
msgid "assign these custom fields"
msgstr ""
#: documents/models.py:1509
#: documents/models.py:1513
msgid "custom field values"
msgstr ""
#: documents/models.py:1513
#: documents/models.py:1517
msgid "Optional values to assign to the custom fields."
msgstr ""
#: documents/models.py:1522
#: documents/models.py:1526
msgid "remove these tag(s)"
msgstr ""
#: documents/models.py:1527
#: documents/models.py:1531
msgid "remove all tags"
msgstr ""
#: documents/models.py:1534
#: documents/models.py:1538
msgid "remove these document type(s)"
msgstr ""
#: documents/models.py:1539
#: documents/models.py:1543
msgid "remove all document types"
msgstr ""
#: documents/models.py:1546
#: documents/models.py:1550
msgid "remove these correspondent(s)"
msgstr ""
#: documents/models.py:1551
#: documents/models.py:1555
msgid "remove all correspondents"
msgstr ""
#: documents/models.py:1558
#: documents/models.py:1562
msgid "remove these storage path(s)"
msgstr ""
#: documents/models.py:1563
#: documents/models.py:1567
msgid "remove all storage paths"
msgstr ""
#: documents/models.py:1570
#: documents/models.py:1574
msgid "remove these owner(s)"
msgstr ""
#: documents/models.py:1575
#: documents/models.py:1579
msgid "remove all owners"
msgstr ""
#: documents/models.py:1582
#: documents/models.py:1586
msgid "remove view permissions for these users"
msgstr ""
#: documents/models.py:1589
#: documents/models.py:1593
msgid "remove view permissions for these groups"
msgstr ""
#: documents/models.py:1596
#: documents/models.py:1600
msgid "remove change permissions for these users"
msgstr ""
#: documents/models.py:1603
#: documents/models.py:1607
msgid "remove change permissions for these groups"
msgstr ""
#: documents/models.py:1608
#: documents/models.py:1612
msgid "remove all permissions"
msgstr ""
#: documents/models.py:1615
#: documents/models.py:1619
msgid "remove these custom fields"
msgstr ""
#: documents/models.py:1620
#: documents/models.py:1624
msgid "remove all custom fields"
msgstr ""
#: documents/models.py:1629
#: documents/models.py:1633
msgid "email"
msgstr ""
#: documents/models.py:1638
#: documents/models.py:1642
msgid "webhook"
msgstr ""
#: documents/models.py:1642
#: documents/models.py:1646
msgid "passwords"
msgstr ""
#: documents/models.py:1646
#: documents/models.py:1650
msgid ""
"Passwords to try when removing PDF protection. Separate with commas or new "
"lines."
msgstr ""
#: documents/models.py:1651
#: documents/models.py:1655
msgid "workflow action"
msgstr ""
#: documents/models.py:1652
#: documents/models.py:1656
msgid "workflow actions"
msgstr ""
#: documents/models.py:1667
#: documents/models.py:1671
msgid "triggers"
msgstr ""
#: documents/models.py:1674
#: documents/models.py:1678
msgid "actions"
msgstr ""
#: documents/models.py:1677 paperless_mail/models.py:154
#: documents/models.py:1681 paperless_mail/models.py:154
msgid "enabled"
msgstr ""
#: documents/models.py:1688
#: documents/models.py:1692
msgid "workflow"
msgstr ""
#: documents/models.py:1692
#: documents/models.py:1696
msgid "workflow trigger type"
msgstr ""
#: documents/models.py:1706
#: documents/models.py:1710
msgid "date run"
msgstr ""
#: documents/models.py:1712
#: documents/models.py:1716
msgid "workflow run"
msgstr ""
#: documents/models.py:1713
#: documents/models.py:1717
msgid "workflow runs"
msgstr ""

View File

@@ -374,10 +374,10 @@ REST_FRAMEWORK = {
"rest_framework.authentication.SessionAuthentication",
],
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning",
"DEFAULT_VERSION": "10", # match src-ui/src/environments/environment.prod.ts
"DEFAULT_VERSION": "9", # match src-ui/src/environments/environment.prod.ts
# Make sure these are ordered and that the most recent version appears
# last. See api.md#api-versioning when adding new versions.
"ALLOWED_VERSIONS": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
"ALLOWED_VERSIONS": ["1", "2", "3", "4", "5", "6", "7", "8", "9"],
# DRF Spectacular default schema
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}

View File

@@ -1,6 +1,6 @@
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
__full_version_str__: Final[str] = ".".join(map(str, __version__))
# Version string like X.Y

View File

@@ -23,6 +23,7 @@ def get_embedding_model() -> BaseEmbedding:
return OpenAIEmbedding(
model=config.llm_embedding_model or "text-embedding-3-small",
api_key=config.llm_api_key,
api_base=config.llm_endpoint or None,
)
case LLMEmbeddingBackend.HUGGINGFACE:
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_model = "text-embedding-3-small"
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:
model = get_embedding_model()
MockOpenAIEmbedding.assert_called_once_with(
model="text-embedding-3-small",
api_key="test_api_key",
api_base="http://test-url",
)
assert model == MockOpenAIEmbedding.return_value

View File

@@ -536,6 +536,7 @@ class MailAccountHandler(LoggingMixin):
self.log.debug(f"Processing mail account {account}")
total_processed_files = 0
consumed_messages: set[tuple[str, str]] = set()
try:
with get_mailbox(
account.imap_server,
@@ -574,6 +575,7 @@ class MailAccountHandler(LoggingMixin):
M,
rule,
supports_gmail_labels=supports_gmail_labels,
consumed_messages=consumed_messages,
)
if total_processed_files > 0 and rule.stop_processing:
self.log.debug(
@@ -605,6 +607,7 @@ class MailAccountHandler(LoggingMixin):
rule: MailRule,
*,
supports_gmail_labels: bool,
consumed_messages: set[tuple[str, str]],
):
folders = [rule.folder]
# In case of MOVE, make sure also the destination exists
@@ -652,11 +655,26 @@ class MailAccountHandler(LoggingMixin):
mails_processed = 0
total_processed_files = 0
rule_seen_messages: set[tuple[str, str]] = set()
for message in messages:
if TYPE_CHECKING:
assert isinstance(message, MailMessage)
message_key = (rule.folder, message.uid)
if message_key in rule_seen_messages:
self.log.debug(
f"Skipping duplicate fetched mail '{message.uid}' subject '{message.subject}' from '{message.from_}'.",
)
continue
rule_seen_messages.add(message_key)
if message_key in consumed_messages:
self.log.debug(
f"Skipping mail '{message.uid}' subject '{message.subject}' from '{message.from_}', already queued by a previous rule in this run.",
)
continue
if ProcessedMail.objects.filter(
rule=rule,
uid=message.uid,
@@ -669,6 +687,8 @@ class MailAccountHandler(LoggingMixin):
try:
processed_files = self._handle_message(message, rule)
if processed_files > 0:
consumed_messages.add(message_key)
total_processed_files += processed_files
mails_processed += 1

View File

@@ -15,7 +15,7 @@ class Migration(migrations.Migration):
name="stop_processing",
field=models.BooleanField(
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",
),
),

View File

@@ -272,6 +272,24 @@ class TestAPIMailAccounts(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
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:
"""
GIVEN:

View File

@@ -8,6 +8,7 @@ from datetime import timedelta
from unittest import mock
import pytest
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.core.management import call_command
from django.db import DatabaseError
@@ -862,6 +863,66 @@ class TestMail(
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 0)
def test_handle_mail_account_overlapping_rules_only_first_consumes(self):
account = MailAccount.objects.create(
name="test",
imap_server="",
username="admin",
password="secret",
)
first_rule = MailRule.objects.create(
name="testrule-first",
account=account,
action=MailRule.MailAction.DELETE,
filter_subject="Claim",
order=1,
)
_ = MailRule.objects.create(
name="testrule-second",
account=account,
action=MailRule.MailAction.DELETE,
filter_subject="Claim",
order=2,
)
self.mail_account_handler.handle_mail_account(account)
self.mailMocker.apply_mail_actions()
self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 1)
queued_rule = self.mailMocker._queue_consumption_tasks_mock.call_args.kwargs[
"rule"
]
self.assertEqual(queued_rule.id, first_rule.id)
def test_handle_mail_account_skip_duplicate_uids_from_fetch(self):
account = MailAccount.objects.create(
name="test",
imap_server="",
username="admin",
password="secret",
)
_ = MailRule.objects.create(
name="testrule",
account=account,
action=MailRule.MailAction.DELETE,
filter_subject="Duplicated mail",
)
duplicated_message = self.mailMocker.messageBuilder.create_message(
subject="Duplicated mail",
)
self.mailMocker.bogus_mailbox.messages = [
duplicated_message,
duplicated_message,
]
self.mailMocker.bogus_mailbox.updateClient()
self.mail_account_handler.handle_mail_account(account)
self.mailMocker.apply_mail_actions()
self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 1)
@pytest.mark.flaky(reruns=4)
def test_handle_mail_account_flag(self) -> None:
account = MailAccount.objects.create(
@@ -1734,6 +1795,10 @@ class TestMailAccountTestView(APITestCase):
username="testuser",
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.url = "/api/mail_accounts/test/"
@@ -1850,6 +1915,56 @@ class TestMailAccountTestView(APITestCase):
expected_str = "Unable to refresh oauth token"
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):
def setUp(self) -> None:

View File

@@ -86,13 +86,34 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
request.data["name"] = datetime.datetime.now().isoformat()
serializer = self.get_serializer(data=request.data)
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 (
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["account_type"] = existing_account.account_type
serializer.validated_data["refresh_token"] = existing_account.refresh_token
@@ -106,7 +127,8 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
) as M:
try:
if (
account.is_token
existing_account is not None
and account.is_token
and account.expiration is not None
and account.expiration < timezone.now()
):
@@ -248,6 +270,7 @@ class OauthCallbackView(GenericAPIView):
imap_server=imap_server,
refresh_token=refresh_token,
expiration=timezone.now() + timedelta(seconds=expires_in),
owner=request.user,
defaults=defaults,
)
return HttpResponseRedirect(

2
uv.lock generated
View File

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