From 85a18e591102480e733004b6c427eb144a1f223e Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:15:43 -0800 Subject: [PATCH] Enhancement: saved view sharing (#12142) --- .mypy-baseline.txt | 20 +- docs/api.md | 5 + .../e2e/dashboard/requests/api-dashboard1.har | 2 +- .../e2e/dashboard/requests/api-dashboard2.har | 2 +- .../e2e/dashboard/requests/api-dashboard3.har | 2 +- .../e2e/dashboard/requests/api-dashboard4.har | 2 +- .../app-frame/app-frame.component.html | 12 +- .../permissions-dialog.component.html | 6 + .../permissions-dialog.component.ts | 3 + .../document-list.component.html | 8 +- .../document-list.component.spec.ts | 169 +++++++++++++- .../document-list/document-list.component.ts | 94 +++++++- .../save-view-config-dialog.component.html | 1 + .../save-view-config-dialog.component.spec.ts | 45 +++- .../save-view-config-dialog.component.ts | 24 +- .../saved-views/saved-views.component.html | 26 ++- .../saved-views/saved-views.component.spec.ts | 156 ++++++++++--- .../saved-views/saved-views.component.ts | 171 +++++++++++--- src-ui/src/app/data/ui-settings.ts | 14 ++ .../services/rest/saved-view.service.spec.ts | 27 +++ .../app/services/rest/saved-view.service.ts | 37 ++- .../src/app/services/settings.service.spec.ts | 11 +- src-ui/src/app/services/settings.service.ts | 13 ++ src-ui/src/environments/environment.prod.ts | 2 +- ...015_savedview_visibility_to_ui_settings.py | 153 ++++++++++++ src/documents/models.py | 7 - src/documents/serialisers.py | 7 +- src/documents/tests/test_api_documents.py | 94 +++++--- src/documents/tests/test_api_search.py | 7 +- .../test_migration_saved_view_visibility.py | 218 ++++++++++++++++++ src/documents/views.py | 29 +-- src/paperless/settings/__init__.py | 4 +- 32 files changed, 1182 insertions(+), 189 deletions(-) create mode 100644 src/documents/migrations/0015_savedview_visibility_to_ui_settings.py create mode 100644 src/documents/tests/test_migration_saved_view_visibility.py diff --git a/.mypy-baseline.txt b/.mypy-baseline.txt index 2daa35236..2700bfc71 100644 --- a/.mypy-baseline.txt +++ b/.mypy-baseline.txt @@ -341,6 +341,9 @@ 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: Couldn't resolve related manager 'documents' for relation 'documents.models.Document.correspondent'. [django-manager-missing] src/documents/models.py:0: error: Couldn't resolve related manager 'documents' for relation 'documents.models.Document.document_type'. [django-manager-missing] @@ -547,6 +550,7 @@ src/documents/serialisers.py:0: error: Function is missing a type annotation [n src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] +src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def] src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def] src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def] @@ -637,6 +641,7 @@ src/documents/serialisers.py:0: error: Missing type parameters for generic type src/documents/serialisers.py:0: error: Missing type parameters for generic type "Serializer" [type-arg] src/documents/serialisers.py:0: error: Missing type parameters for generic type "Serializer" [type-arg] src/documents/serialisers.py:0: error: Missing type parameters for generic type "Serializer" [type-arg] +src/documents/serialisers.py:0: error: Missing type parameters for generic type "Serializer" [type-arg] src/documents/serialisers.py:0: error: Missing type parameters for generic type "dict" [type-arg] src/documents/serialisers.py:0: error: Missing type parameters for generic type "dict" [type-arg] src/documents/serialisers.py:0: error: Need type annotation for "document" [var-annotated] @@ -1171,6 +1176,14 @@ 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] @@ -1530,7 +1543,6 @@ src/documents/views.py:0: error: "get_serializer_context" undefined in superclas src/documents/views.py:0: error: "object" not callable [operator] src/documents/views.py:0: error: "type[Model]" has no attribute "objects" [attr-defined] src/documents/views.py:0: error: Argument "path" to "EmailAttachment" has incompatible type "Path | None"; expected "Path" [arg-type] -src/documents/views.py:0: error: Argument 1 to "int" has incompatible type "str | None"; expected "str | Buffer | SupportsInt | SupportsIndex | SupportsTrunc" [arg-type] src/documents/views.py:0: error: Argument 2 to "match_correspondents" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type] src/documents/views.py:0: error: Argument 2 to "match_document_types" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type] src/documents/views.py:0: error: Argument 2 to "match_storage_paths" has incompatible type "DocumentClassifier | None"; expected "DocumentClassifier" [arg-type] @@ -1548,7 +1560,6 @@ 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] @@ -1605,7 +1616,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 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: 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 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] @@ -1671,11 +1683,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] diff --git a/docs/api.md b/docs/api.md index da802b819..11ce3a8d5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -466,3 +466,8 @@ 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. diff --git a/src-ui/e2e/dashboard/requests/api-dashboard1.har b/src-ui/e2e/dashboard/requests/api-dashboard1.har index a9c2380f2..07ff8ef9e 100644 --- a/src-ui/e2e/dashboard/requests/api-dashboard1.har +++ b/src-ui/e2e/dashboard/requests/api-dashboard1.har @@ -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},\"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,\"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\"]}" }, "headersSize": -1, "bodySize": -1, diff --git a/src-ui/e2e/dashboard/requests/api-dashboard2.har b/src-ui/e2e/dashboard/requests/api-dashboard2.har index eac0bb6ee..912fbf308 100644 --- a/src-ui/e2e/dashboard/requests/api-dashboard2.har +++ b/src-ui/e2e/dashboard/requests/api-dashboard2.har @@ -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},\"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,\"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\"]}" }, "headersSize": -1, "bodySize": -1, diff --git a/src-ui/e2e/dashboard/requests/api-dashboard3.har b/src-ui/e2e/dashboard/requests/api-dashboard3.har index cb1ce67b9..6c441970c 100644 --- a/src-ui/e2e/dashboard/requests/api-dashboard3.har +++ b/src-ui/e2e/dashboard/requests/api-dashboard3.har @@ -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},\"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,\"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\"]}" }, "headersSize": -1, "bodySize": -1, diff --git a/src-ui/e2e/dashboard/requests/api-dashboard4.har b/src-ui/e2e/dashboard/requests/api-dashboard4.har index 3353ea459..33bf46d6a 100644 --- a/src-ui/e2e/dashboard/requests/api-dashboard4.har +++ b/src-ui/e2e/dashboard/requests/api-dashboard4.har @@ -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},\"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,\"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\"]}" }, "headersSize": -1, "bodySize": -1, diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index fbdf40811..d876e28ea 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -99,12 +99,7 @@ diff --git a/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.html b/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.html index fd88002ba..f11fc9760 100644 --- a/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.html +++ b/src-ui/src/app/components/common/permissions-dialog/permissions-dialog.component.html @@ -16,6 +16,12 @@ + @if (note) { +
+ {{ note }} +
+ } + diff --git a/src-ui/src/app/components/document-list/document-list.component.spec.ts b/src-ui/src/app/components/document-list/document-list.component.spec.ts index 5dc9516a7..3ea39ccb0 100644 --- a/src-ui/src/app/components/document-list/document-list.component.spec.ts +++ b/src-ui/src/app/components/document-list/document-list.component.spec.ts @@ -168,6 +168,10 @@ describe('DocumentListComponent', () => { ) }) + 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.setFilterRules([ @@ -299,6 +303,19 @@ 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() @@ -466,7 +483,7 @@ describe('DocumentListComponent', () => { }) it('should handle error on view saving', () => { - component.list.activateSavedView({ + const view: SavedView = { id: 10, name: 'Saved View 10', sort_field: 'added', @@ -477,7 +494,16 @@ 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') @@ -489,6 +515,40 @@ 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, @@ -520,19 +580,105 @@ 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, + }) + 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', - show_on_dashboard: true, - show_in_sidebar: true, + showOnDashboard: true, + showInSideBar: false, }) - expect(savedViewServiceCreate).toHaveBeenCalled() - expect(modalSpy).toHaveBeenCalled() - expect(toastSpy).toHaveBeenCalled() + 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', () => { @@ -563,6 +709,10 @@ describe('DocumentListComponent', () => { let openModal: NgbModalRef modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) + const updateVisibilitySpy = jest.spyOn( + settingsService, + 'updateSavedViewsVisibility' + ) jest.spyOn(savedViewService, 'create').mockReturnValueOnce( throwError( () => @@ -575,9 +725,10 @@ describe('DocumentListComponent', () => { openModal.componentInstance.saveClicked.next({ name: 'Foo Bar', - show_on_dashboard: true, - show_in_sidebar: true, + showOnDashboard: true, + showInSideBar: true, }) + expect(updateVisibilitySpy).not.toHaveBeenCalled() expect(openModal.componentInstance.error).toEqual({ filter_rules: ['11'] }) }) diff --git a/src-ui/src/app/components/document-list/document-list.component.ts b/src-ui/src/app/components/document-list/document-list.component.ts index c0dd8f80a..2cd2ccaf3 100644 --- a/src-ui/src/app/components/document-list/document-list.component.ts +++ b/src-ui/src/app/components/document-list/document-list.component.ts @@ -47,7 +47,10 @@ 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 { PermissionsService } from 'src/app/services/permissions.service' +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' @@ -148,12 +151,18 @@ export class DocumentListComponent unmodifiedFilterRules: FilterRule[] = [] private unmodifiedSavedView: SavedView + private activeSavedView: SavedView | null = null private unsubscribeNotifier: Subject = new Subject() get savedViewIsModified(): boolean { - if (!this.list.activeSavedViewId || !this.unmodifiedSavedView) return false - else { + if ( + !this.list.activeSavedViewId || + !this.unmodifiedSavedView || + !this.activeSavedViewCanChange + ) { + return false + } else { return ( this.unmodifiedSavedView.sort_field !== this.list.sortField || this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse || @@ -180,6 +189,16 @@ 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 } @@ -264,11 +283,13 @@ 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, @@ -292,6 +313,7 @@ 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 = [] @@ -374,7 +396,7 @@ export class DocumentListComponent } saveViewConfig() { - if (this.list.activeSavedViewId != null) { + if (this.list.activeSavedViewId != null && this.activeSavedViewCanChange) { let savedView: SavedView = { id: this.list.activeSavedViewId, filter_rules: this.list.filterRules, @@ -388,6 +410,7 @@ export class DocumentListComponent .pipe(first()) .subscribe({ next: (view) => { + this.activeSavedView = view this.unmodifiedSavedView = view this.toastService.showInfo( $localize`View "${this.list.activeSavedViewTitle}" saved successfully.` @@ -409,6 +432,11 @@ 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(() => { @@ -426,24 +454,48 @@ 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: () => { - modal.close() - this.toastService.showInfo( - $localize`View "${savedView.name}" created successfully.` + next: (createdView) => { + this.saveCreatedViewVisibility( + createdView, + formValue.showOnDashboard, + formValue.showInSideBar ) + .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 @@ -457,6 +509,28 @@ 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', diff --git a/src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html b/src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html index acfb15c84..4e4bf2cd3 100644 --- a/src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html +++ b/src-ui/src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html @@ -8,6 +8,7 @@ + @if (error?.filter_rules) {
- - - + @if (canDeleteSavedView(view)) { + + + + + }
diff --git a/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts b/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts index 10bc5db8e..de6c88f9a 100644 --- a/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts +++ b/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts @@ -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 { By } from '@angular/platform-browser' -import { NgbModule } from '@ng-bootstrap/ng-bootstrap' +import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' -import { of, throwError } from 'rxjs' +import { Subject, 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,7 +32,9 @@ describe('SavedViewsComponent', () => { let component: SavedViewsComponent let fixture: ComponentFixture let savedViewService: SavedViewService + let settingsService: SettingsService let toastService: ToastService + let modalService: NgbModal beforeEach(async () => { TestBed.configureTestingModule({ @@ -57,6 +59,8 @@ describe('SavedViewsComponent', () => { provide: PermissionsService, useValue: { currentUserCan: () => true, + currentUserHasObjectPermissions: () => true, + currentUserOwnsObject: () => true, }, }, { @@ -77,11 +81,13 @@ 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, 'listAll').mockReturnValue( + jest.spyOn(savedViewService, 'list').mockReturnValue( of({ all: savedViews.map((v) => v.id), count: savedViews.length, @@ -94,14 +100,13 @@ 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 toggle = fixture.debugElement.query( - By.css('.form-check.form-switch input') - ) - toggle.nativeElement.checked = true - toggle.nativeElement.dispatchEvent(new Event('change')) + const control = component.savedViewsForm + .get('savedViews') + .get(savedViews[0].id.toString()) + .get('name') + control.setValue(`${savedViews[0].name}-changed`) + control.markAsDirty() // saved views error first savedViewPatchSpy.mockReturnValueOnce( @@ -110,12 +115,13 @@ 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() @@ -127,26 +133,65 @@ describe('SavedViewsComponent', () => { expect(patchSpy).not.toHaveBeenCalled() const view = savedViews[0] - 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 + 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() fixture.detectChanges() component.save() - expect(patchSpy).toHaveBeenCalledWith([ - { - id: view.id, - name: view.name, - show_in_sidebar: view.show_in_sidebar, - show_on_dashboard: !view.show_on_dashboard, - }, - ]) + 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() }) it('should support delete saved view', () => { @@ -162,14 +207,55 @@ describe('SavedViewsComponent', () => { it('should support reset', () => { const view = savedViews[0] - component.savedViewsForm.get('savedViews').get(view.id.toString()).value[ - 'show_on_dashboard' - ] = !view.show_on_dashboard + component.savedViewsForm + .get('savedViews') + .get(view.id.toString()) + .get('show_on_dashboard') + .setValue(!view.show_on_dashboard) component.reset() expect( - component.savedViewsForm.get('savedViews').get(view.id.toString()).value[ - 'show_on_dashboard' - ] + component.savedViewsForm + .get('savedViews') + .get(view.id.toString()) + .get('show_on_dashboard').value ).toEqual(view.show_on_dashboard) }) + + it('should support editing permissions', () => { + const confirmClicked = new Subject() + 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() + }) }) diff --git a/src-ui/src/app/components/manage/saved-views/saved-views.component.ts b/src-ui/src/app/components/manage/saved-views/saved-views.component.ts index 015f9b486..9a0f11ea7 100644 --- a/src-ui/src/app/components/manage/saved-views/saved-views.component.ts +++ b/src-ui/src/app/components/manage/saved-views/saved-views.component.ts @@ -6,11 +6,18 @@ import { FormsModule, ReactiveFormsModule, } from '@angular/forms' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { dirtyCheck } from '@ngneat/dirty-check-forms' -import { BehaviorSubject, Observable, takeUntil } from 'rxjs' +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 { 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' @@ -34,15 +41,18 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading FormsModule, ReactiveFormsModule, AsyncPipe, + NgxBootstrapIconsModule, ], }) export class SavedViewsComponent extends LoadingComponentWithPermissions implements OnInit, OnDestroy { - private savedViewService = inject(SavedViewService) - private settings = inject(SettingsService) - private toastService = inject(ToastService) + 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) DisplayMode = DisplayMode @@ -65,11 +75,17 @@ export class SavedViewsComponent } ngOnInit(): void { + this.reloadViews() + } + + private reloadViews(): void { this.loading = true - this.savedViewService.listAll().subscribe((r) => { - this.savedViews = r.results - this.initialize() - }) + this.savedViewService + .list(null, null, null, false, { full_perms: true }) + .subscribe((r) => { + this.savedViews = r.results + this.initialize() + }) } ngOnDestroy(): void { @@ -95,16 +111,20 @@ 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(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([]), + 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 }), }) ) } @@ -133,10 +153,7 @@ export class SavedViewsComponent $localize`Saved view "${savedView.name}" deleted.` ) this.savedViewService.clearCache() - this.savedViewService.listAll().subscribe((r) => { - this.savedViews = r.results - this.initialize() - }) + this.reloadViews() }) } @@ -145,26 +162,120 @@ export class SavedViewsComponent } public save() { - // only patch views that have actually changed + // 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 + ) + const changed: SavedView[] = [] - Object.values(this.savedViewsGroup.controls) - .filter((g: FormGroup) => !g.pristine) - .forEach((group: FormGroup) => { - changed.push(group.value) - }) + 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([]) if (changed.length) { - this.savedViewService.patchMany(changed).subscribe({ + 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({ next: () => { - this.toastService.showInfo($localize`Views saved successfully.`) - this.store.next(this.savedViewsForm.value) + this.toastService.showInfo($localize`Permissions updated`) + modal.close() + this.reloadViews() }, error: (error) => { this.toastService.showError( - $localize`Error while saving views.`, + $localize`Error updating permissions`, error ) }, }) - } + }) } } diff --git a/src-ui/src/app/data/ui-settings.ts b/src-ui/src/app/data/ui-settings.ts index c47c409d5..cec804f99 100644 --- a/src-ui/src/app/data/ui-settings.ts +++ b/src-ui/src/app/data/ui-settings.ts @@ -62,6 +62,10 @@ 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: @@ -248,6 +252,16 @@ 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', diff --git a/src-ui/src/app/services/rest/saved-view.service.spec.ts b/src-ui/src/app/services/rest/saved-view.service.spec.ts index 585425ecc..c72fb5409 100644 --- a/src-ui/src/app/services/rest/saved-view.service.spec.ts +++ b/src-ui/src/app/services/rest/saved-view.service.spec.ts @@ -57,6 +57,11 @@ 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` @@ -93,7 +98,9 @@ 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], @@ -102,10 +109,21 @@ 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], @@ -114,6 +132,15 @@ 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({ diff --git a/src-ui/src/app/services/rest/saved-view.service.ts b/src-ui/src/app/services/rest/saved-view.service.ts index 7bdb890a0..d9baec22b 100644 --- a/src-ui/src/app/services/rest/saved-view.service.ts +++ b/src-ui/src/app/services/rest/saved-view.service.ts @@ -36,7 +36,9 @@ export class SavedViewService extends AbstractPaperlessService { return super.list(page, pageSize, sortField, sortReverse, extraParams).pipe( tap({ next: (r) => { - this.savedViews = r.results + const views = r.results.map((view) => this.withUserVisibility(view)) + this.savedViews = views + r.results = views this._loading = false this.settingsService.dashboardIsEmpty = this.dashboardViews.length === 0 @@ -65,8 +67,35 @@ export class SavedViewService extends AbstractPaperlessService { 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) => v.show_in_sidebar) + const sidebarViews = this.savedViews.filter((v) => this.isSidebarVisible(v)) const sorted: number[] = this.settingsService.get( SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER @@ -81,7 +110,9 @@ export class SavedViewService extends AbstractPaperlessService { } get dashboardViews(): SavedView[] { - const dashboardViews = this.savedViews.filter((v) => v.show_on_dashboard) + const dashboardViews = this.savedViews.filter((v) => + this.isDashboardVisible(v) + ) const sorted: number[] = this.settingsService.get( SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER diff --git a/src-ui/src/app/services/settings.service.spec.ts b/src-ui/src/app/services/settings.service.spec.ts index df44013f4..22ae3e504 100644 --- a/src-ui/src/app/services/settings.service.spec.ts +++ b/src-ui/src/app/services/settings.service.spec.ts @@ -320,7 +320,7 @@ describe('SettingsService', () => { expect(req.request.method).toEqual('POST') }) - it('should update saved view sorting', () => { + it('should update saved view sorting and visibility', () => { httpTestingController .expectOne(`${environment.apiBaseUrl}ui_settings/`) .flush(ui_settings) @@ -341,6 +341,15 @@ 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) diff --git a/src-ui/src/app/services/settings.service.ts b/src-ui/src/app/services/settings.service.ts index 1cfbdf4a3..f006cae12 100644 --- a/src-ui/src/app/services/settings.service.ts +++ b/src-ui/src/app/services/settings.service.ts @@ -699,4 +699,17 @@ export class SettingsService { ]) return this.storeSettings() } + + updateSavedViewsVisibility( + dashboardVisibleViewIds: number[], + sidebarVisibleViewIds: number[] + ): Observable { + 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() + } } diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index 28a27911f..a5bf6942f 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI) export const environment = { production: true, apiBaseUrl: document.baseURI + 'api/', - apiVersion: '9', // match src/paperless/settings.py + apiVersion: '10', // match src/paperless/settings.py appTitle: 'Paperless-ngx', tag: 'prod', version: '2.20.10', diff --git a/src/documents/migrations/0015_savedview_visibility_to_ui_settings.py b/src/documents/migrations/0015_savedview_visibility_to_ui_settings.py new file mode 100644 index 000000000..bcce863b0 --- /dev/null +++ b/src/documents/migrations/0015_savedview_visibility_to_ui_settings.py @@ -0,0 +1,153 @@ +# Generated by Django 5.2.11 on 2026-02-20 22:05 + +from collections import defaultdict + +from django.db import migrations +from django.db import models + +# from src-ui/src/app/data/ui-settings.ts +SAVED_VIEWS_KEY = "saved_views" +DASHBOARD_VIEWS_VISIBLE_IDS_KEY = "dashboard_views_visible_ids" +SIDEBAR_VIEWS_VISIBLE_IDS_KEY = "sidebar_views_visible_ids" + + +def _parse_visible_ids(raw_value) -> set[int]: + """Return integer SavedView IDs parsed from a JSON list value.""" + 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): + """ + Move SavedView visibility from boolean model props into JSON UiSettings.settings.saved_views, specifically: + settings.saved_views.dashboard_views_visible_ids + settings.saved_views.sidebar_views_visible_ids + """ + SavedView = apps.get_model("documents", "SavedView") + UiSettings = apps.get_model("documents", "UiSettings") + User = apps.get_model("auth", "User") + + dashboard_visible_ids_by_owner: defaultdict[int, list[int]] = defaultdict(list) + 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[owner_id].append(view_id) + + sidebar_visible_ids_by_owner: defaultdict[int, list[int]] = defaultdict(list) + 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[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 + saved_views_settings = current_settings.get(SAVED_VIEWS_KEY) + if not isinstance(saved_views_settings, dict): + saved_views_settings = {} + changed = True + + if saved_views_settings.get(DASHBOARD_VIEWS_VISIBLE_IDS_KEY) is None: + saved_views_settings[DASHBOARD_VIEWS_VISIBLE_IDS_KEY] = ( + dashboard_visible_ids_by_owner.get(user.id, []) + ) + changed = True + + if saved_views_settings.get(SIDEBAR_VIEWS_VISIBLE_IDS_KEY) is None: + saved_views_settings[SIDEBAR_VIEWS_VISIBLE_IDS_KEY] = ( + sidebar_visible_ids_by_owner.get(user.id, []) + ) + changed = True + + current_settings[SAVED_VIEWS_KEY] = saved_views_settings + + 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 + saved_views_settings = current_settings.get(SAVED_VIEWS_KEY) + if not isinstance(saved_views_settings, dict): + saved_views_settings = {} + + dashboard_visible_ids_by_owner[ui_settings.user_id] = _parse_visible_ids( + saved_views_settings.get(DASHBOARD_VIEWS_VISIBLE_IDS_KEY), + ) + + sidebar_visible_ids_by_owner[ui_settings.user_id] = _parse_visible_ids( + saved_views_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", "0014_alter_paperlesstask_task_name"), + ] + + 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", + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 4461a14ce..f0191b47f 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -475,13 +475,6 @@ 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, diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 7df7e2e5e..457fb00ab 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1428,8 +1428,6 @@ class SavedViewSerializer(OwnedObjectSerializer): fields = [ "id", "name", - "show_on_dashboard", - "show_in_sidebar", "sort_field", "sort_reverse", "filter_rules", @@ -1439,6 +1437,7 @@ class SavedViewSerializer(OwnedObjectSerializer): "owner", "permissions", "user_can_change", + "set_permissions", ] def validate(self, attrs): @@ -1476,7 +1475,7 @@ class SavedViewSerializer(OwnedObjectSerializer): and len(validated_data["display_fields"]) == 0 ): validated_data["display_fields"] = None - super().update(instance, validated_data) + instance = super().update(instance, validated_data) if rules_data is not None: SavedViewFilterRule.objects.filter(saved_view=instance).delete() for rule_data in rules_data: @@ -1488,7 +1487,7 @@ class SavedViewSerializer(OwnedObjectSerializer): if "user" in validated_data: # backwards compatibility validated_data["owner"] = validated_data.pop("user") - saved_view = SavedView.objects.create(**validated_data) + saved_view = super().create(validated_data) for rule_data in rules_data: SavedViewFilterRule.objects.create(saved_view=saved_view, **rule_data) return saved_view diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 5ddfc8538..4008b09f2 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -2118,69 +2118,93 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): mock_get_date_parser.assert_not_called() def test_saved_views(self) -> None: - u1 = User.objects.create_superuser("user1") - u2 = User.objects.create_superuser("user2") + 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) v1 = SavedView.objects.create( owner=u1, name="test1", sort_field="", - show_on_dashboard=False, - show_in_sidebar=False, ) - SavedView.objects.create( + v2 = SavedView.objects.create( owner=u2, name="test2", sort_field="", - show_on_dashboard=False, - show_in_sidebar=False, ) - SavedView.objects.create( + v3 = SavedView.objects.create( owner=u2, name="test3", sort_field="", - show_on_dashboard=False, - show_in_sidebar=False, ) - 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, - ) + assign_perm("view_savedview", u1, v2) + assign_perm("change_savedview", u1, v2) + assign_perm("view_savedview", u1, v3) 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"], 1) + self.assertEqual(response.data["count"], 3) + 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( - self.client.get(f"/api/saved_views/{v1.id}/").status_code, - status.HTTP_200_OK, + response.status_code, + status.HTTP_403_FORBIDDEN, ) - self.client.force_authenticate(user=u2) + 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) response = self.client.get("/api/saved_views/") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data["count"], 2) - - self.assertEqual( - self.client.get(f"/api/saved_views/{v1.id}/").status_code, - status.HTTP_404_NOT_FOUND, - ) + self.assertEqual(response.data["count"], 0) 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"}], } @@ -2195,13 +2219,13 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): response = self.client.patch( f"/api/saved_views/{v1.id}/", - {"show_in_sidebar": False}, + {"sort_reverse": True}, format="json", ) v1 = SavedView.objects.get(id=v1.id) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertFalse(v1.show_in_sidebar) + self.assertTrue(v1.sort_reverse) self.assertEqual(v1.filter_rules.count(), 1) view["filter_rules"] = [{"rule_type": 12, "value": "secret"}] @@ -2235,8 +2259,6 @@ 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, @@ -2324,8 +2346,6 @@ 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, @@ -2401,8 +2421,6 @@ 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, diff --git a/src/documents/tests/test_api_search.py b/src/documents/tests/test_api_search.py index 2aa3f1ae7..779576461 100644 --- a/src/documents/tests/test_api_search.py +++ b/src/documents/tests/test_api_search.py @@ -1307,13 +1307,12 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase): tag1 = Tag.objects.create(name="bank tag1") Tag.objects.create(name="tag2") - SavedView.objects.create( + shared_view = SavedView.objects.create( name="bank view", - show_on_dashboard=True, - show_in_sidebar=True, sort_field="", - owner=user1, + owner=user2, ) + 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( diff --git a/src/documents/tests/test_migration_saved_view_visibility.py b/src/documents/tests/test_migration_saved_view_visibility.py new file mode 100644 index 000000000..c6b2fbefd --- /dev/null +++ b/src/documents/tests/test_migration_saved_view_visibility.py @@ -0,0 +1,218 @@ +from documents.tests.utils import TestMigrations + +SAVED_VIEWS_KEY = "saved_views" +DASHBOARD_VIEWS_VISIBLE_IDS_KEY = "dashboard_views_visible_ids" +SIDEBAR_VIEWS_VISIBLE_IDS_KEY = "sidebar_views_visible_ids" + + +class TestMigrateSavedViewVisibilityToUiSettings(TestMigrations): + migrate_from = "0013_document_root_document" + migrate_to = "0015_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={ + SAVED_VIEWS_KEY: { + DASHBOARD_VIEWS_VISIBLE_IDS_KEY: [self.sidebar_only_view.id], + SIDEBAR_VIEWS_VISIBLE_IDS_KEY: [self.dashboard_view.id], + "warn_on_unsaved_change": True, + }, + "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[SAVED_VIEWS_KEY][DASHBOARD_VIEWS_VISIBLE_IDS_KEY], + [self.dashboard_view.id], + ) + self.assertCountEqual( + seeded_settings[SAVED_VIEWS_KEY][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[SAVED_VIEWS_KEY][DASHBOARD_VIEWS_VISIBLE_IDS_KEY], + [self.sidebar_only_view.id], + ) + self.assertEqual( + existing_settings[SAVED_VIEWS_KEY][SIDEBAR_VIEWS_VISIBLE_IDS_KEY], + [self.dashboard_view.id], + ) + self.assertTrue(existing_settings[SAVED_VIEWS_KEY]["warn_on_unsaved_change"]) + self.assertEqual(existing_settings["preserve"], "value") + + created_settings = UiSettings.objects.get( + user_id=self.user_with_owned_views_id, + ).settings + self.assertCountEqual( + created_settings[SAVED_VIEWS_KEY][DASHBOARD_VIEWS_VISIBLE_IDS_KEY], + [self.other_owner_visible_view.id], + ) + self.assertCountEqual( + created_settings[SAVED_VIEWS_KEY][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[SAVED_VIEWS_KEY][DASHBOARD_VIEWS_VISIBLE_IDS_KEY], + [self.invalid_settings_owner_view.id], + ) + self.assertEqual( + invalid_settings[SAVED_VIEWS_KEY][SIDEBAR_VIEWS_VISIBLE_IDS_KEY], + [], + ) + + +class TestReverseMigrateSavedViewVisibilityFromUiSettings(TestMigrations): + migrate_from = "0015_savedview_visibility_to_ui_settings" + migrate_to = "0014_alter_paperlesstask_task_name" + + 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={ + SAVED_VIEWS_KEY: { + DASHBOARD_VIEWS_VISIBLE_IDS_KEY: [str(self.view1.id)], + SIDEBAR_VIEWS_VISIBLE_IDS_KEY: [self.view2.id], + }, + }, + ) + UiSettings.objects.create( + user=user2, + settings={ + SAVED_VIEWS_KEY: { + 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) diff --git a/src/documents/views.py b/src/documents/views.py index 7c6acf79c..9b960a8b4 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -2097,24 +2097,21 @@ class LogViewSet(ViewSet): return Response(existing_logs) -class SavedViewViewSet(ModelViewSet, PassUserMixin): +@extend_schema_view(**generate_object_with_permissions_schema(SavedViewSerializer)) +class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet): model = SavedView - queryset = SavedView.objects.all() + queryset = SavedView.objects.select_related("owner").prefetch_related( + "filter_rules", + ) serializer_class = SavedViewSerializer pagination_class = StandardPagination permission_classes = (IsAuthenticated, PaperlessObjectPermissions) - - 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: serializers.BaseSerializer[Any]) -> None: - serializer.save(owner=self.request.user) + filter_backends = ( + OrderingFilter, + ObjectOwnedOrGrantedPermissionsFilter, + ) + ordering_fields = ("name",) @extend_schema_view( @@ -2649,7 +2646,11 @@ class GlobalSearchView(PassUserMixin): ) docs = docs[:OBJECT_LIMIT] saved_views = ( - SavedView.objects.filter(owner=request.user, name__icontains=query) + get_objects_for_user_owner_aware( + request.user, + "view_savedview", + SavedView, + ).filter(name__icontains=query) if request.user.has_perm("documents.view_savedview") else [] ) diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index 5c6f843fa..d86980165 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -376,10 +376,10 @@ REST_FRAMEWORK = { "rest_framework.authentication.SessionAuthentication", ], "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning", - "DEFAULT_VERSION": "9", # match src-ui/src/environments/environment.prod.ts + "DEFAULT_VERSION": "10", # 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": ["2", "3", "4", "5", "6", "7", "8", "9"], + "ALLOWED_VERSIONS": ["2", "3", "4", "5", "6", "7", "8", "9", "10"], # DRF Spectacular default schema "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", }