Compare commits

..

19 Commits

Author SHA1 Message Date
shamoon
b7331a4ec3 Fix e2e tests 2026-02-20 16:21:59 -08:00
shamoon
da0ca837ea Oh we need to respect owner for migration 2026-02-20 16:12:36 -08:00
shamoon
e92352384f Cleanup 2026-02-20 16:06:22 -08:00
shamoon
e588321ea3 Update .mypy-baseline.txt 2026-02-20 16:06:21 -08:00
shamoon
a7ff3fac6b Bump api version 2026-02-20 16:06:21 -08:00
shamoon
7bcfcf1e49 Frontend handle this change 2026-02-20 16:06:20 -08:00
shamoon
34bdf41931 Actually lets remove the old fields 2026-02-20 16:06:20 -08:00
shamoon
773f0b2d1b Sync mypy baseline 2026-02-20 13:53:50 -08:00
shamoon
accd5cd574 Reverse this conditional to prevent layout shifting 2026-02-20 13:06:42 -08:00
shamoon
4b4c2766e6 Unify these 2026-02-20 12:51:46 -08:00
shamoon
16767b1433 Use UISettings on create too 2026-02-20 11:54:03 -08:00
shamoon
45456042cb Make views use UISettings visibility instead of model 2026-02-20 11:45:14 -08:00
shamoon
258777cac9 Add UIsettings for dashboard / sidebar view setting 2026-02-20 11:31:47 -08:00
shamoon
32ef2913b1 Prevent non-owners from changing db models 2026-02-20 11:02:04 -08:00
shamoon
ef723a5c93 Add to dialog 2026-02-20 10:35:29 -08:00
shamoon
77554f6b36 Add permissions ui stuff for saved views 2026-02-20 10:22:47 -08:00
shamoon
e69e543ba2 fix this test 2026-02-20 08:45:43 -08:00
shamoon
e9d87ef049 Basic frontend respect saved view perms 2026-02-20 08:45:11 -08:00
shamoon
e0e517358d First backend bits, savedviews fully permissions-capable 2026-02-20 08:38:09 -08:00
45 changed files with 1074 additions and 636 deletions

View File

@@ -344,6 +344,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/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/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: Argument 1 to "Path" has incompatible type "Path | None"; expected "str | PathLike[str]" [arg-type]
src/documents/models.py:0: error: Could not resolve manager type for "documents.models.Document.deleted_objects" [django-manager-missing] src/documents/models.py:0: error: Could not resolve manager type for "documents.models.Document.deleted_objects" [django-manager-missing]
src/documents/models.py:0: error: Could not resolve manager type for "documents.models.Document.global_objects" [django-manager-missing] src/documents/models.py:0: error: Could not resolve manager type for "documents.models.Document.global_objects" [django-manager-missing]
@@ -450,6 +453,9 @@ src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | Qu
src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exists" [union-attr] src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exists" [union-attr]
src/documents/permissions.py:0: error: Missing type parameters for generic type "QuerySet" [type-arg] src/documents/permissions.py:0: error: Missing type parameters for generic type "QuerySet" [type-arg]
src/documents/permissions.py:0: error: Missing type parameters for generic type "dict" [type-arg] src/documents/permissions.py:0: error: Missing type parameters for generic type "dict" [type-arg]
src/documents/plugins/helpers.py:0: error: "Collection[str]" has no attribute "update" [attr-defined]
src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type]
src/documents/plugins/helpers.py:0: error: Argument 1 to "send" of "BaseStatusManager" has incompatible type "dict[str, Collection[str]]"; expected "dict[str, str | int | None]" [arg-type]
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/plugins/helpers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/plugins/helpers.py:0: error: Skipping analyzing "channels_redis.pubsub": module is installed, but missing library stubs or py.typed marker [import-untyped] src/documents/plugins/helpers.py:0: error: Skipping analyzing "channels_redis.pubsub": module is installed, but missing library stubs or py.typed marker [import-untyped]
@@ -673,6 +679,7 @@ src/documents/signals/handlers.py:0: error: Argument 3 to "validate_move" has in
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type] src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type] src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type] src/documents/signals/handlers.py:0: error: Argument 5 to "_suggestion_printer" has incompatible type "Any | None"; expected "MatchingModel" [arg-type]
src/documents/signals/handlers.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/signals/handlers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def] src/documents/signals/handlers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
@@ -1189,6 +1196,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_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_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_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: 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] 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]
@@ -1565,7 +1580,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 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] 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]
@@ -1621,8 +1635,6 @@ src/documents/views.py:0: error: Function is missing a type annotation [no-unty
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def] src/documents/views.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/views.py:0: error: Incompatible type for lookup 'owner': (got "User | AnonymousUser", expected "User | int | None") [misc]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "dict[Any, Any]") [assignment] src/documents/views.py:0: error: Incompatible types in assignment (expression has type "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[Any]") [assignment]
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "list[Document]") [assignment] src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "list[Document]") [assignment]
@@ -1688,11 +1700,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[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[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[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[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[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[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 "Iterable[Workflow]" is not indexable [index]
src/documents/views.py:0: error: Value of type "dict[str, _PingReply] | None" is not indexable [index] src/documents/views.py:0: error: Value of type "dict[str, _PingReply] | None" is not indexable [index]

View File

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

View File

@@ -58,7 +58,7 @@
"content": { "content": {
"size": -1, "size": -1,
"mimeType": "application/json", "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, "headersSize": -1,
"bodySize": -1, "bodySize": -1,

View File

@@ -58,7 +58,7 @@
"content": { "content": {
"size": -1, "size": -1,
"mimeType": "application/json", "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, "headersSize": -1,
"bodySize": -1, "bodySize": -1,

View File

@@ -58,7 +58,7 @@
"content": { "content": {
"size": -1, "size": -1,
"mimeType": "application/json", "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, "headersSize": -1,
"bodySize": -1, "bodySize": -1,

View File

@@ -58,7 +58,7 @@
"content": { "content": {
"size": -1, "size": -1,
"mimeType": "application/json", "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, "headersSize": -1,
"bodySize": -1, "bodySize": -1,

View File

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

View File

@@ -95,6 +95,7 @@
<div class="col-md-6 col-xl-5 mb-4"> <div class="col-md-6 col-xl-5 mb-4">
<form [formGroup]='documentForm' (ngSubmit)="save()"> <form [formGroup]='documentForm' (ngSubmit)="save()">
<div class="btn-toolbar mb-1 border-bottom"> <div class="btn-toolbar mb-1 border-bottom">
<div class="btn-group pb-3"> <div class="btn-group pb-3">
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()"> <button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">

View File

@@ -83,9 +83,9 @@ const doc: Document = {
storage_path: 31, storage_path: 31,
tags: [41, 42, 43], tags: [41, 42, 43],
content: 'text content', content: 'text content',
added: new Date('May 4, 2014 03:24:00').toISOString(), added: new Date('May 4, 2014 03:24:00'),
created: new Date('May 4, 2014 03:24:00').toISOString(), created: new Date('May 4, 2014 03:24:00'),
modified: new Date('May 4, 2014 03:24:00').toISOString(), modified: new Date('May 4, 2014 03:24:00'),
archive_serial_number: null, archive_serial_number: null,
original_file_name: 'file.pdf', original_file_name: 'file.pdf',
owner: null, owner: null,
@@ -392,7 +392,7 @@ describe('DocumentDetailComponent', () => {
jest.spyOn(documentService, 'get').mockReturnValue( jest.spyOn(documentService, 'get').mockReturnValue(
of({ of({
...doc, ...doc,
modified: '2024-01-02T00:00:00Z', modified: new Date('2024-01-02T00:00:00Z'),
duplicate_documents: updatedDuplicates, duplicate_documents: updatedDuplicates,
}) })
) )
@@ -1205,21 +1205,17 @@ describe('DocumentDetailComponent', () => {
expect(errorSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled()
}) })
it('should show incoming update modal when open local draft is older than backend on init', () => { it('should warn when open document does not match doc retrieved from backend on init', () => {
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modals) => (openModal = modals[0])) modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
const openDoc = Object.assign({}, doc, { const openDoc = Object.assign({}, doc)
__changedFields: ['title'],
})
// simulate a document being modified elsewhere and db updated // simulate a document being modified elsewhere and db updated
const remoteDoc = Object.assign({}, doc, { doc.modified = new Date()
modified: new Date(new Date(doc.modified).getTime() + 1000).toISOString(),
})
jest jest
.spyOn(activatedRoute, 'paramMap', 'get') .spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' }))) .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(remoteDoc)) jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc) jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
jest.spyOn(customFieldsService, 'listAll').mockReturnValue( jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({ of({
@@ -1229,52 +1225,11 @@ describe('DocumentDetailComponent', () => {
}) })
) )
fixture.detectChanges() // calls ngOnInit fixture.detectChanges() // calls ngOnInit
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent, { expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent)
backdrop: 'static', const closeSpy = jest.spyOn(openModal, 'close')
})
const confirmDialog = openModal.componentInstance as ConfirmDialogComponent const confirmDialog = openModal.componentInstance as ConfirmDialogComponent
expect(confirmDialog.messageBold).toContain('Document was updated at') confirmDialog.confirmClicked.next(confirmDialog)
}) expect(closeSpy).toHaveBeenCalled()
it('should queue incoming update while network is active and flush after', () => {
initNormally()
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const toastSpy = jest.spyOn(toastService, 'showInfo')
component.networkActive = true
;(component as any).handleIncomingDocumentUpdated({
document_id: component.documentId,
modified: '2026-02-17T00:00:00Z',
})
expect(loadSpy).not.toHaveBeenCalled()
component.networkActive = false
;(component as any).flushPendingIncomingUpdate()
expect(loadSpy).toHaveBeenCalledWith(component.documentId, true)
expect(toastSpy).toHaveBeenCalledWith(
'Document reloaded with latest changes.'
)
})
it('should ignore queued incoming update matching local save modified', () => {
initNormally()
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const toastSpy = jest.spyOn(toastService, 'showInfo')
component.networkActive = true
;(component as any).lastLocalSaveModified = '2026-02-17T00:00:00+00:00'
;(component as any).handleIncomingDocumentUpdated({
document_id: component.documentId,
modified: '2026-02-17T00:00:00+00:00',
})
component.networkActive = false
;(component as any).flushPendingIncomingUpdate()
expect(loadSpy).not.toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
}) })
it('should change preview element by render type', () => { it('should change preview element by render type', () => {

View File

@@ -13,7 +13,6 @@ import {
NgbDateStruct, NgbDateStruct,
NgbDropdownModule, NgbDropdownModule,
NgbModal, NgbModal,
NgbModalRef,
NgbNav, NgbNav,
NgbNavChangeEvent, NgbNavChangeEvent,
NgbNavModule, NgbNavModule,
@@ -81,7 +80,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { getFilenameFromContentDisposition } from 'src/app/utils/http' import { getFilenameFromContentDisposition } from 'src/app/utils/http'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif' import * as UTIF from 'utif'
@@ -144,11 +142,6 @@ enum ContentRenderType {
TIFF = 'tiff', TIFF = 'tiff',
} }
interface IncomingDocumentUpdate {
document_id: number
modified?: string
}
@Component({ @Component({
selector: 'pngx-document-detail', selector: 'pngx-document-detail',
templateUrl: './document-detail.component.html', templateUrl: './document-detail.component.html',
@@ -212,7 +205,6 @@ export class DocumentDetailComponent
private componentRouterService = inject(ComponentRouterService) private componentRouterService = inject(ComponentRouterService)
private deviceDetectorService = inject(DeviceDetectorService) private deviceDetectorService = inject(DeviceDetectorService)
private savedViewService = inject(SavedViewService) private savedViewService = inject(SavedViewService)
private websocketStatusService = inject(WebsocketStatusService)
@ViewChild('inputTitle') @ViewChild('inputTitle')
titleInput: TextComponent titleInput: TextComponent
@@ -269,9 +261,6 @@ export class DocumentDetailComponent
isDirty$: Observable<boolean> isDirty$: Observable<boolean>
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
docChangeNotifier: Subject<any> = new Subject() docChangeNotifier: Subject<any> = new Subject()
private incomingUpdateModal: NgbModalRef
private pendingIncomingUpdate: IncomingDocumentUpdate
private lastLocalSaveModified: string | null = null
requiresPassword: boolean = false requiresPassword: boolean = false
password: string password: string
@@ -443,64 +432,7 @@ export class DocumentDetailComponent
) )
} }
private hasLocalEdits(doc: Document): boolean { private loadDocument(documentId: number): void {
return (
this.openDocumentService.isDirty(doc) || !!doc.__changedFields?.length
)
}
private showIncomingUpdateModal(modified?: string): void {
if (this.incomingUpdateModal) return
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
this.incomingUpdateModal = modal
let formattedModified = null
if (modified) {
const parsed = new Date(modified)
if (!isNaN(parsed.getTime())) {
formattedModified = parsed.toLocaleString()
}
}
modal.componentInstance.title = $localize`Document was updated.`
modal.componentInstance.messageBold = formattedModified
? $localize`Document was updated at ${formattedModified}.`
: $localize`This document was updated elsewhere.`
modal.componentInstance.message = $localize`Reload to discard your local unsaved edits and load the latest remote version.`
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Reload`
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled = false
modal.close()
this.reloadRemoteVersion()
})
modal.result.finally(() => {
this.incomingUpdateModal = null
})
}
private closeIncomingUpdateModal() {
if (!this.incomingUpdateModal) return
this.incomingUpdateModal.close()
this.incomingUpdateModal = null
}
private flushPendingIncomingUpdate() {
if (!this.pendingIncomingUpdate || this.networkActive) return
const pendingUpdate = this.pendingIncomingUpdate
this.pendingIncomingUpdate = null
this.handleIncomingDocumentUpdated(pendingUpdate)
}
private loadDocument(documentId: number, forceRemote: boolean = false): void {
this.closeIncomingUpdateModal()
this.pendingIncomingUpdate = null
this.lastLocalSaveModified = null
this.previewUrl = this.documentsService.getPreviewUrl(documentId) this.previewUrl = this.documentsService.getPreviewUrl(documentId)
this.updatePdfSource() this.updatePdfSource()
this.http this.http
@@ -545,25 +477,21 @@ export class DocumentDetailComponent
openDocument.duplicate_documents = doc.duplicate_documents openDocument.duplicate_documents = doc.duplicate_documents
this.openDocumentService.save() this.openDocumentService.save()
} }
let useDoc = openDocument || doc const useDoc = openDocument || doc
if (openDocument && forceRemote) { if (openDocument) {
Object.assign(openDocument, doc) if (
openDocument.__changedFields = [] new Date(doc.modified) > new Date(openDocument.modified) &&
this.openDocumentService.setDirty(openDocument, false) !this.modalService.hasOpenModals()
this.openDocumentService.save() ) {
useDoc = openDocument const modal = this.modalService.open(ConfirmDialogComponent)
} else if (openDocument) { modal.componentInstance.title = $localize`Document changes detected`
if (new Date(doc.modified) > new Date(openDocument.modified)) { modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
if (this.hasLocalEdits(openDocument)) { modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
this.showIncomingUpdateModal(doc.modified) modal.componentInstance.cancelBtnClass = 'visually-hidden'
} else { modal.componentInstance.btnCaption = $localize`Ok`
// No local edits to preserve, so keep the tab in sync automatically. modal.componentInstance.confirmClicked.subscribe(() =>
Object.assign(openDocument, doc) modal.close()
openDocument.__changedFields = [] )
this.openDocumentService.setDirty(openDocument, false)
this.openDocumentService.save()
useDoc = openDocument
}
} }
} else { } else {
this.openDocumentService this.openDocumentService
@@ -594,50 +522,6 @@ export class DocumentDetailComponent
}) })
} }
private handleIncomingDocumentUpdated(data: IncomingDocumentUpdate): void {
if (
!this.documentId ||
!this.document ||
data.document_id !== this.documentId
)
return
if (this.networkActive) {
this.pendingIncomingUpdate = data
return
}
// If modified timestamp of the incoming update is the same as the last local save,
// we assume this update is from our own save and dont notify
const incomingModified = data.modified
if (
incomingModified &&
this.lastLocalSaveModified &&
incomingModified === this.lastLocalSaveModified
) {
this.lastLocalSaveModified = null
return
}
this.lastLocalSaveModified = null
if (this.openDocumentService.isDirty(this.document)) {
this.showIncomingUpdateModal(data.modified)
} else {
this.docChangeNotifier.next(this.documentId)
this.loadDocument(this.documentId, true)
this.toastService.showInfo(
$localize`Document reloaded with latest changes.`
)
}
}
private reloadRemoteVersion() {
if (!this.documentId) return
this.closeIncomingUpdateModal()
this.docChangeNotifier.next(this.documentId)
this.loadDocument(this.documentId, true)
this.toastService.showInfo($localize`Document reloaded.`)
}
ngOnInit(): void { ngOnInit(): void {
this.setZoom( this.setZoom(
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
@@ -696,11 +580,6 @@ export class DocumentDetailComponent
this.getCustomFields() this.getCustomFields()
this.websocketStatusService
.onDocumentUpdated()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((data) => this.handleIncomingDocumentUpdated(data))
this.route.paramMap this.route.paramMap
.pipe( .pipe(
filter( filter(
@@ -1035,7 +914,6 @@ export class DocumentDetailComponent
) )
.subscribe({ .subscribe({
next: (doc) => { next: (doc) => {
this.closeIncomingUpdateModal()
Object.assign(this.document, doc) Object.assign(this.document, doc)
doc['permissions_form'] = { doc['permissions_form'] = {
owner: doc.owner, owner: doc.owner,
@@ -1082,8 +960,6 @@ export class DocumentDetailComponent
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: (docValues) => { next: (docValues) => {
this.closeIncomingUpdateModal()
this.lastLocalSaveModified = docValues.modified ?? null
// in case data changed while saving eg removing inbox_tags // in case data changed while saving eg removing inbox_tags
this.documentForm.patchValue(docValues) this.documentForm.patchValue(docValues)
const newValues = Object.assign({}, this.documentForm.value) const newValues = Object.assign({}, this.documentForm.value)
@@ -1098,19 +974,16 @@ export class DocumentDetailComponent
this.networkActive = false this.networkActive = false
this.error = null this.error = null
if (close) { if (close) {
this.pendingIncomingUpdate = null
this.close(() => this.close(() =>
this.openDocumentService.refreshDocument(this.documentId) this.openDocumentService.refreshDocument(this.documentId)
) )
} else { } else {
this.openDocumentService.refreshDocument(this.documentId) this.openDocumentService.refreshDocument(this.documentId)
this.flushPendingIncomingUpdate()
} }
this.savedViewService.maybeRefreshDocumentCounts() this.savedViewService.maybeRefreshDocumentCounts()
}, },
error: (error) => { error: (error) => {
this.networkActive = false this.networkActive = false
this.lastLocalSaveModified = null
const canEdit = const canEdit =
this.permissionsService.currentUserHasObjectPermissions( this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change, PermissionAction.Change,
@@ -1130,7 +1003,6 @@ export class DocumentDetailComponent
error error
) )
} }
this.flushPendingIncomingUpdate()
}, },
}) })
} }
@@ -1167,11 +1039,8 @@ export class DocumentDetailComponent
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: ({ updateResult, nextDocId, closeResult }) => { next: ({ updateResult, nextDocId, closeResult }) => {
this.closeIncomingUpdateModal()
this.error = null this.error = null
this.networkActive = false this.networkActive = false
this.pendingIncomingUpdate = null
this.lastLocalSaveModified = null
if (closeResult && updateResult && nextDocId) { if (closeResult && updateResult && nextDocId) {
this.router.navigate(['documents', nextDocId]) this.router.navigate(['documents', nextDocId])
this.titleInput?.focus() this.titleInput?.focus()
@@ -1179,10 +1048,8 @@ export class DocumentDetailComponent
}, },
error: (error) => { error: (error) => {
this.networkActive = false this.networkActive = false
this.lastLocalSaveModified = null
this.error = error.error this.error = error.error
this.toastService.showError($localize`Error saving document`, error) this.toastService.showError($localize`Error saving document`, error)
this.flushPendingIncomingUpdate()
}, },
}) })
} }
@@ -1268,7 +1135,7 @@ export class DocumentDetailComponent
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showInfo(
$localize`Reprocess operation for "${this.document.title}" will begin in the background.` $localize`Reprocess operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.`
) )
if (modal) { if (modal) {
modal.close() modal.close()

View File

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

View File

@@ -452,7 +452,7 @@ describe('DocumentListComponent', () => {
}) })
it('should handle error on view saving', () => { it('should handle error on view saving', () => {
component.list.activateSavedView({ const view: SavedView = {
id: 10, id: 10,
name: 'Saved View 10', name: 'Saved View 10',
sort_field: 'added', sort_field: 'added',
@@ -463,7 +463,16 @@ describe('DocumentListComponent', () => {
value: '20', 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') const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest jest
.spyOn(savedViewService, 'patch') .spyOn(savedViewService, 'patch')
@@ -475,6 +484,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', () => { it('should support edited view saving as', () => {
const view: SavedView = { const view: SavedView = {
id: 10, id: 10,
@@ -506,16 +549,44 @@ describe('DocumentListComponent', () => {
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
const toastSpy = jest.spyOn(toastService, 'showInfo') const toastSpy = jest.spyOn(toastService, 'showInfo')
const savedViewServiceCreate = jest.spyOn(savedViewService, 'create') const savedViewServiceCreate = jest.spyOn(savedViewService, 'create')
const updateVisibilitySpy = jest
.spyOn(settingsService, 'updateSavedViewsVisibility')
.mockReturnValue(of({ success: true }))
savedViewServiceCreate.mockReturnValueOnce(of(modifiedView)) savedViewServiceCreate.mockReturnValueOnce(of(modifiedView))
component.saveViewConfigAs() component.saveViewConfigAs()
const modalCloseSpy = jest.spyOn(openModal, 'close') 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({ openModal.componentInstance.saveClicked.next({
name: 'Foo Bar', name: 'Foo Bar',
show_on_dashboard: true, showOnDashboard: true,
show_in_sidebar: true, showInSideBar: true,
permissions_form: permissions,
}) })
expect(savedViewServiceCreate).toHaveBeenCalled() expect(savedViewServiceCreate).toHaveBeenCalled()
expect(savedViewServiceCreate).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Foo Bar',
owner: permissions.owner,
set_permissions: permissions.set_permissions,
})
)
expect(updateVisibilitySpy).toHaveBeenCalledWith(
expect.arrayContaining([modifiedView.id]),
expect.arrayContaining([modifiedView.id])
)
expect(modalSpy).toHaveBeenCalled() expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled() expect(modalCloseSpy).toHaveBeenCalled()
@@ -549,6 +620,10 @@ describe('DocumentListComponent', () => {
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0])) modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const updateVisibilitySpy = jest.spyOn(
settingsService,
'updateSavedViewsVisibility'
)
jest.spyOn(savedViewService, 'create').mockReturnValueOnce( jest.spyOn(savedViewService, 'create').mockReturnValueOnce(
throwError( throwError(
() => () =>
@@ -561,9 +636,10 @@ describe('DocumentListComponent', () => {
openModal.componentInstance.saveClicked.next({ openModal.componentInstance.saveClicked.next({
name: 'Foo Bar', name: 'Foo Bar',
show_on_dashboard: true, showOnDashboard: true,
show_in_sidebar: true, showInSideBar: true,
}) })
expect(updateVisibilitySpy).not.toHaveBeenCalled()
expect(openModal.componentInstance.error).toEqual({ filter_rules: ['11'] }) expect(openModal.componentInstance.error).toEqual({ filter_rules: ['11'] })
}) })

View File

@@ -22,7 +22,7 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap' import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs' import { filter, first, map, of, Subject, switchMap, takeUntil } from 'rxjs'
import { import {
DEFAULT_DISPLAY_FIELDS, DEFAULT_DISPLAY_FIELDS,
DisplayField, DisplayField,
@@ -47,7 +47,10 @@ import { UsernamePipe } from 'src/app/pipes/username.pipe'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { HotKeyService } from 'src/app/services/hot-key.service' import { HotKeyService } from 'src/app/services/hot-key.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.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 { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@@ -148,12 +151,18 @@ export class DocumentListComponent
unmodifiedFilterRules: FilterRule[] = [] unmodifiedFilterRules: FilterRule[] = []
private unmodifiedSavedView: SavedView private unmodifiedSavedView: SavedView
private activeSavedView: SavedView | null = null
private unsubscribeNotifier: Subject<any> = new Subject() private unsubscribeNotifier: Subject<any> = new Subject()
get savedViewIsModified(): boolean { get savedViewIsModified(): boolean {
if (!this.list.activeSavedViewId || !this.unmodifiedSavedView) return false if (
else { !this.list.activeSavedViewId ||
!this.unmodifiedSavedView ||
!this.activeSavedViewCanChange
) {
return false
} else {
return ( return (
this.unmodifiedSavedView.sort_field !== this.list.sortField || this.unmodifiedSavedView.sort_field !== this.list.sortField ||
this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse || 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() { get isFiltered() {
return !!this.filterEditor?.rulesModified return !!this.filterEditor?.rulesModified
} }
@@ -256,11 +275,13 @@ export class DocumentListComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ view }) => { .subscribe(({ view }) => {
if (!view) { if (!view) {
this.activeSavedView = null
this.router.navigate(['404'], { this.router.navigate(['404'], {
replaceUrl: true, replaceUrl: true,
}) })
return return
} }
this.activeSavedView = view
this.unmodifiedSavedView = view this.unmodifiedSavedView = view
this.list.activateSavedViewWithQueryParams( this.list.activateSavedViewWithQueryParams(
view, view,
@@ -284,6 +305,7 @@ export class DocumentListComponent
// loading a saved view on /documents // loading a saved view on /documents
this.loadViewConfig(parseInt(queryParams.get('view'))) this.loadViewConfig(parseInt(queryParams.get('view')))
} else { } else {
this.activeSavedView = null
this.list.activateSavedView(null) this.list.activateSavedView(null)
this.list.loadFromQueryParams(queryParams) this.list.loadFromQueryParams(queryParams)
this.unmodifiedFilterRules = [] this.unmodifiedFilterRules = []
@@ -366,7 +388,7 @@ export class DocumentListComponent
} }
saveViewConfig() { saveViewConfig() {
if (this.list.activeSavedViewId != null) { if (this.list.activeSavedViewId != null && this.activeSavedViewCanChange) {
let savedView: SavedView = { let savedView: SavedView = {
id: this.list.activeSavedViewId, id: this.list.activeSavedViewId,
filter_rules: this.list.filterRules, filter_rules: this.list.filterRules,
@@ -380,6 +402,7 @@ export class DocumentListComponent
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: (view) => { next: (view) => {
this.activeSavedView = view
this.unmodifiedSavedView = view this.unmodifiedSavedView = view
this.toastService.showInfo( this.toastService.showInfo(
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.` $localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
@@ -401,6 +424,11 @@ export class DocumentListComponent
.getCached(viewID) .getCached(viewID)
.pipe(first()) .pipe(first())
.subscribe((view) => { .subscribe((view) => {
if (!view) {
this.activeSavedView = null
return
}
this.activeSavedView = view
this.unmodifiedSavedView = view this.unmodifiedSavedView = view
this.list.activateSavedView(view) this.list.activateSavedView(view)
this.list.reload(() => { this.list.reload(() => {
@@ -418,24 +446,48 @@ export class DocumentListComponent
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
let savedView: SavedView = { let savedView: SavedView = {
name: formValue.name, name: formValue.name,
show_on_dashboard: formValue.showOnDashboard,
show_in_sidebar: formValue.showInSideBar,
filter_rules: this.list.filterRules, filter_rules: this.list.filterRules,
sort_reverse: this.list.sortReverse, sort_reverse: this.list.sortReverse,
sort_field: this.list.sortField, sort_field: this.list.sortField,
display_mode: this.list.displayMode, display_mode: this.list.displayMode,
display_fields: this.activeDisplayFields, 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 this.savedViewService
.create(savedView) .create(savedView)
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: () => { next: (createdView) => {
modal.close() this.saveCreatedViewVisibility(
this.toastService.showInfo( createdView,
$localize`View "${savedView.name}" created successfully.` 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) => { error: (httpError) => {
let error = httpError.error let error = httpError.error
@@ -449,6 +501,32 @@ export class DocumentListComponent
}) })
} }
private saveCreatedViewVisibility(
createdView: SavedView,
showOnDashboard: boolean,
showInSideBar: boolean
) {
if (!showOnDashboard && !showInSideBar) {
return of(null)
}
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) { openDocumentDetail(document: Document | number) {
this.router.navigate([ this.router.navigate([
'documents', 'documents',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,11 +6,17 @@ import {
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { dirtyCheck } from '@ngneat/dirty-check-forms' import { dirtyCheck } from '@ngneat/dirty-check-forms'
import { BehaviorSubject, Observable, takeUntil } from 'rxjs' 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 { DisplayMode } from 'src/app/data/document'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' 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 { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@@ -41,8 +47,10 @@ export class SavedViewsComponent
implements OnInit, OnDestroy implements OnInit, OnDestroy
{ {
private savedViewService = inject(SavedViewService) private savedViewService = inject(SavedViewService)
private permissionsService = inject(PermissionsService)
private settings = inject(SettingsService) private settings = inject(SettingsService)
private toastService = inject(ToastService) private toastService = inject(ToastService)
private modalService = inject(NgbModal)
DisplayMode = DisplayMode DisplayMode = DisplayMode
@@ -65,11 +73,17 @@ export class SavedViewsComponent
} }
ngOnInit(): void { ngOnInit(): void {
this.reloadViews()
}
private reloadViews(): void {
this.loading = true this.loading = true
this.savedViewService.listAll().subscribe((r) => { this.savedViewService
this.savedViews = r.results .listAll(null, null, { full_perms: true })
this.initialize() .subscribe((r) => {
}) this.savedViews = r.results
this.initialize()
})
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -95,16 +109,20 @@ export class SavedViewsComponent
display_mode: view.display_mode, display_mode: view.display_mode,
display_fields: view.display_fields, display_fields: view.display_fields,
} }
const canEdit = this.canEditSavedView(view)
this.savedViewsGroup.addControl( this.savedViewsGroup.addControl(
view.id.toString(), view.id.toString(),
new FormGroup({ new FormGroup({
id: new FormControl(null), id: new FormControl({ value: null, disabled: !canEdit }),
name: new FormControl(null), name: new FormControl({ value: null, disabled: !canEdit }),
show_on_dashboard: new FormControl(null), show_on_dashboard: new FormControl({
show_in_sidebar: new FormControl(null), value: null,
page_size: new FormControl(null), disabled: false,
display_mode: new FormControl(null), }),
display_fields: new FormControl([]), 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 +151,7 @@ export class SavedViewsComponent
$localize`Saved view "${savedView.name}" deleted.` $localize`Saved view "${savedView.name}" deleted.`
) )
this.savedViewService.clearCache() this.savedViewService.clearCache()
this.savedViewService.listAll().subscribe((r) => { this.reloadViews()
this.savedViews = r.results
this.initialize()
})
}) })
} }
@@ -145,26 +160,119 @@ export class SavedViewsComponent
} }
public save() { 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[] = [] const changed: SavedView[] = []
Object.values(this.savedViewsGroup.controls) const dashboardVisibleIds: number[] = []
.filter((g: FormGroup) => !g.pristine) const sidebarVisibleIds: number[] = []
.forEach((group: FormGroup) => {
changed.push(group.value) 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) { 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
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: () => { next: () => {
this.toastService.showInfo($localize`Views saved successfully.`) this.toastService.showInfo($localize`Permissions updated`)
this.store.next(this.savedViewsForm.value) modal.close()
this.reloadViews()
}, },
error: (error) => { error: (error) => {
this.toastService.showError( this.toastService.showError(
$localize`Error while saving views.`, $localize`Error updating permissions`,
error error
) )
}, },
}) })
} })
} }
} }

View File

@@ -128,15 +128,15 @@ export interface Document extends ObjectWithPermissions {
checksum?: string checksum?: string
// UTC // UTC
created?: string // ISO string created?: Date
modified?: string // ISO string modified?: Date
added?: string // ISO string added?: Date
mime_type?: string mime_type?: string
deleted_at?: string // ISO string deleted_at?: Date
original_file_name?: string original_file_name?: string

View File

@@ -62,6 +62,10 @@ export const SETTINGS_KEYS = {
'general-settings:update-checking:backend-setting', 'general-settings:update-checking:backend-setting',
SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE: SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE:
'general-settings: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: DASHBOARD_VIEWS_SORT_ORDER:
'general-settings:saved-views:dashboard-views-sort-order', 'general-settings:saved-views:dashboard-views-sort-order',
SIDEBAR_VIEWS_SORT_ORDER: SIDEBAR_VIEWS_SORT_ORDER:
@@ -248,6 +252,16 @@ export const SETTINGS: UiSetting[] = [
type: 'array', type: 'array',
default: [], 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, key: SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER,
type: 'array', type: 'array',

View File

@@ -1,7 +0,0 @@
export interface WebsocketDocumentUpdatedMessage {
document_id: number
modified?: string
owner_id?: number
users_can_view?: number[]
groups_can_view?: number[]
}

View File

@@ -57,6 +57,11 @@ describe(`Additional service tests for SavedViewService`, () => {
let settingsService let settingsService
it('should retrieve saved views and sort them', () => { 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() service.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000` `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
@@ -93,7 +98,9 @@ describe(`Additional service tests for SavedViewService`, () => {
it('should sort dashboard views', () => { it('should sort dashboard views', () => {
service['savedViews'] = saved_views service['savedViews'] = saved_views
jest.spyOn(settingsService, 'get').mockImplementation((key) => { 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] if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return [3, 1, 2]
return []
}) })
expect(service.dashboardViews).toEqual([ expect(service.dashboardViews).toEqual([
saved_views[2], 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', () => { it('should sort sidebar views', () => {
service['savedViews'] = saved_views service['savedViews'] = saved_views
jest.spyOn(settingsService, 'get').mockImplementation((key) => { 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] if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER) return [3, 1, 2]
return []
}) })
expect(service.sidebarViews).toEqual([ expect(service.sidebarViews).toEqual([
saved_views[2], 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', () => { it('should treat empty display_fields as null', () => {
subscription = service subscription = service
.patch({ .patch({

View File

@@ -36,7 +36,9 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
return super.list(page, pageSize, sortField, sortReverse, extraParams).pipe( return super.list(page, pageSize, sortField, sortReverse, extraParams).pipe(
tap({ tap({
next: (r) => { 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._loading = false
this.settingsService.dashboardIsEmpty = this.settingsService.dashboardIsEmpty =
this.dashboardViews.length === 0 this.dashboardViews.length === 0
@@ -65,8 +67,35 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
return this.savedViews 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[] { 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( const sorted: number[] = this.settingsService.get(
SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER
@@ -81,7 +110,9 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
} }
get dashboardViews(): SavedView[] { 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( const sorted: number[] = this.settingsService.get(
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER

View File

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

View File

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

View File

@@ -416,25 +416,4 @@ describe('ConsumerStatusService', () => {
websocketStatusService.disconnect() websocketStatusService.disconnect()
expect(deleted).toBeTruthy() expect(deleted).toBeTruthy()
}) })
it('should trigger updated subject on document updated', () => {
let updated = false
websocketStatusService.onDocumentUpdated().subscribe((data) => {
updated = true
expect(data.document_id).toEqual(12)
})
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.DOCUMENT_UPDATED,
data: {
document_id: 12,
modified: '2026-02-17T00:00:00Z',
owner_id: 1,
},
})
websocketStatusService.disconnect()
expect(updated).toBeTruthy()
})
}) })

View File

@@ -2,7 +2,6 @@ import { Injectable, inject } from '@angular/core'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { User } from '../data/user' import { User } from '../data/user'
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message' import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
import { WebsocketProgressMessage } from '../data/websocket-progress-message' import { WebsocketProgressMessage } from '../data/websocket-progress-message'
import { SettingsService } from './settings.service' import { SettingsService } from './settings.service'
@@ -10,7 +9,6 @@ import { SettingsService } from './settings.service'
export enum WebsocketStatusType { export enum WebsocketStatusType {
STATUS_UPDATE = 'status_update', STATUS_UPDATE = 'status_update',
DOCUMENTS_DELETED = 'documents_deleted', DOCUMENTS_DELETED = 'documents_deleted',
DOCUMENT_UPDATED = 'document_updated',
} }
// see ProgressStatusOptions in src/documents/plugins/helpers.py // see ProgressStatusOptions in src/documents/plugins/helpers.py
@@ -105,8 +103,6 @@ export class WebsocketStatusService {
private documentConsumptionFinishedSubject = new Subject<FileStatus>() private documentConsumptionFinishedSubject = new Subject<FileStatus>()
private documentConsumptionFailedSubject = new Subject<FileStatus>() private documentConsumptionFailedSubject = new Subject<FileStatus>()
private documentDeletedSubject = new Subject<boolean>() private documentDeletedSubject = new Subject<boolean>()
private documentUpdatedSubject =
new Subject<WebsocketDocumentUpdatedMessage>()
private connectionStatusSubject = new Subject<boolean>() private connectionStatusSubject = new Subject<boolean>()
private get(taskId: string, filename?: string) { private get(taskId: string, filename?: string) {
@@ -173,10 +169,7 @@ export class WebsocketStatusService {
data: messageData, data: messageData,
}: { }: {
type: WebsocketStatusType type: WebsocketStatusType
data: data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage
| WebsocketProgressMessage
| WebsocketDocumentsDeletedMessage
| WebsocketDocumentUpdatedMessage
} = JSON.parse(ev.data) } = JSON.parse(ev.data)
switch (type) { switch (type) {
@@ -184,12 +177,6 @@ export class WebsocketStatusService {
this.documentDeletedSubject.next(true) this.documentDeletedSubject.next(true)
break break
case WebsocketStatusType.DOCUMENT_UPDATED:
this.handleDocumentUpdated(
messageData as WebsocketDocumentUpdatedMessage
)
break
case WebsocketStatusType.STATUS_UPDATE: case WebsocketStatusType.STATUS_UPDATE:
this.handleProgressUpdate(messageData as WebsocketProgressMessage) this.handleProgressUpdate(messageData as WebsocketProgressMessage)
break break
@@ -197,11 +184,7 @@ export class WebsocketStatusService {
} }
} }
private canViewMessage(messageData: { private canViewMessage(messageData: WebsocketProgressMessage): boolean {
owner_id?: number
users_can_view?: number[]
groups_can_view?: number[]
}): boolean {
// see paperless.consumers.StatusConsumer._can_view // see paperless.consumers.StatusConsumer._can_view
const user: User = this.settingsService.currentUser const user: User = this.settingsService.currentUser
return ( return (
@@ -261,15 +244,6 @@ export class WebsocketStatusService {
} }
} }
handleDocumentUpdated(messageData: WebsocketDocumentUpdatedMessage) {
// fallback if backend didn't restrict message
if (!this.canViewMessage(messageData)) {
return
}
this.documentUpdatedSubject.next(messageData)
}
fail(status: FileStatus, message: string) { fail(status: FileStatus, message: string) {
status.message = message status.message = message
status.phase = FileStatusPhase.FAILED status.phase = FileStatusPhase.FAILED
@@ -323,10 +297,6 @@ export class WebsocketStatusService {
return this.documentDeletedSubject return this.documentDeletedSubject
} }
onDocumentUpdated() {
return this.documentUpdatedSubject
}
onConnectionStatus() { onConnectionStatus() {
return this.connectionStatusSubject.asObservable() return this.connectionStatusSubject.asObservable()
} }

View File

@@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI)
export const environment = { export const environment = {
production: true, production: true,
apiBaseUrl: document.baseURI + 'api/', apiBaseUrl: document.baseURI + 'api/',
apiVersion: '9', // match src/paperless/settings.py apiVersion: '10', // match src/paperless/settings.py
appTitle: 'Paperless-ngx', appTitle: 'Paperless-ngx',
tag: 'prod', tag: 'prod',
version: '2.20.7', version: '2.20.7',

View File

@@ -1,11 +0,0 @@
import pytest
from pytest_django.fixtures import SettingsWrapper
@pytest.fixture(autouse=True)
def in_memory_channel_layers(settings: SettingsWrapper) -> None:
settings.CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
},
}

View File

@@ -15,7 +15,6 @@ class DocumentsConfig(AppConfig):
from documents.signals.handlers import add_to_index from documents.signals.handlers import add_to_index
from documents.signals.handlers import run_workflows_added from documents.signals.handlers import run_workflows_added
from documents.signals.handlers import run_workflows_updated from documents.signals.handlers import run_workflows_updated
from documents.signals.handlers import send_websocket_document_updated
from documents.signals.handlers import set_correspondent from documents.signals.handlers import set_correspondent
from documents.signals.handlers import set_document_type from documents.signals.handlers import set_document_type
from documents.signals.handlers import set_storage_path from documents.signals.handlers import set_storage_path
@@ -30,7 +29,6 @@ class DocumentsConfig(AppConfig):
document_consumption_finished.connect(run_workflows_added) document_consumption_finished.connect(run_workflows_added)
document_consumption_finished.connect(add_or_update_document_in_llm_index) document_consumption_finished.connect(add_or_update_document_in_llm_index)
document_updated.connect(run_workflows_updated) document_updated.connect(run_workflows_updated)
document_updated.connect(send_websocket_document_updated)
import documents.schema # noqa: F401 import documents.schema # noqa: F401

View File

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

View File

@@ -443,13 +443,6 @@ class SavedView(ModelWithOwner):
name = models.CharField(_("name"), max_length=128) 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 = models.CharField(
_("sort field"), _("sort field"),
max_length=128, max_length=128,

View File

@@ -1,5 +1,4 @@
import enum import enum
from collections.abc import Mapping
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
@@ -48,7 +47,7 @@ class BaseStatusManager:
async_to_sync(self._channel.flush) async_to_sync(self._channel.flush)
self._channel = None self._channel = None
def send(self, payload: Mapping[str, object]) -> None: def send(self, payload: dict[str, str | int | None]) -> None:
# Ensure the layer is open # Ensure the layer is open
self.open() self.open()
@@ -74,28 +73,26 @@ class ProgressManager(BaseStatusManager):
max_progress: int, max_progress: int,
extra_args: dict[str, str | int | None] | None = None, extra_args: dict[str, str | int | None] | None = None,
) -> None: ) -> None:
data: dict[str, object] = { payload = {
"filename": self.filename, "type": "status_update",
"task_id": self.task_id, "data": {
"current_progress": current_progress, "filename": self.filename,
"max_progress": max_progress, "task_id": self.task_id,
"status": status, "current_progress": current_progress,
"message": message, "max_progress": max_progress,
"status": status,
"message": message,
},
} }
if extra_args is not None: if extra_args is not None:
data.update(extra_args) payload["data"].update(extra_args)
payload: dict[str, object] = {
"type": "status_update",
"data": data,
}
self.send(payload) self.send(payload)
class DocumentsStatusManager(BaseStatusManager): class DocumentsStatusManager(BaseStatusManager):
def send_documents_deleted(self, documents: list[int]) -> None: def send_documents_deleted(self, documents: list[int]) -> None:
payload: dict[str, object] = { payload = {
"type": "documents_deleted", "type": "documents_deleted",
"data": { "data": {
"documents": documents, "documents": documents,
@@ -103,25 +100,3 @@ class DocumentsStatusManager(BaseStatusManager):
} }
self.send(payload) self.send(payload)
def send_document_updated(
self,
*,
document_id: int,
modified: str | None = None,
owner_id: int | None = None,
users_can_view: list[int] | None = None,
groups_can_view: list[int] | None = None,
) -> None:
payload: dict[str, object] = {
"type": "document_updated",
"data": {
"document_id": document_id,
"modified": modified,
"owner_id": owner_id,
"users_can_view": users_can_view or [],
"groups_can_view": groups_can_view or [],
},
}
self.send(payload)

View File

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

View File

@@ -4,7 +4,6 @@ import logging
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any
from celery import shared_task from celery import shared_task
from celery import states from celery import states
@@ -24,7 +23,6 @@ from django.db.models import Q
from django.dispatch import receiver from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from filelock import FileLock from filelock import FileLock
from rest_framework import serializers
from documents import matching from documents import matching
from documents.caching import clear_document_caches from documents.caching import clear_document_caches
@@ -47,7 +45,6 @@ from documents.models import WorkflowAction
from documents.models import WorkflowRun from documents.models import WorkflowRun
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.permissions import get_objects_for_user_owner_aware from documents.permissions import get_objects_for_user_owner_aware
from documents.plugins.helpers import DocumentsStatusManager
from documents.templating.utils import convert_format_str_to_template_format from documents.templating.utils import convert_format_str_to_template_format
from documents.workflows.actions import build_workflow_action_context from documents.workflows.actions import build_workflow_action_context
from documents.workflows.actions import execute_email_action from documents.workflows.actions import execute_email_action
@@ -66,7 +63,6 @@ if TYPE_CHECKING:
from documents.data_models import DocumentMetadataOverrides from documents.data_models import DocumentMetadataOverrides
logger = logging.getLogger("paperless.handlers") logger = logging.getLogger("paperless.handlers")
DRF_DATETIME_FIELD = serializers.DateTimeField()
def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None: def add_inbox_tags(sender, document: Document, logging_group=None, **kwargs) -> None:
@@ -757,30 +753,6 @@ def run_workflows_updated(
) )
def send_websocket_document_updated(
sender,
document: Document,
**kwargs,
) -> None:
# At this point, workflows may already have applied additional changes.
document.refresh_from_db()
from documents.data_models import DocumentMetadataOverrides
doc_overrides = DocumentMetadataOverrides.from_document(document)
with DocumentsStatusManager() as status_mgr:
status_mgr.send_document_updated(
document_id=document.id,
modified=DRF_DATETIME_FIELD.to_representation(document.modified)
if document.modified
else None,
owner_id=doc_overrides.owner_id,
users_can_view=doc_overrides.view_users,
groups_can_view=doc_overrides.view_groups,
)
def run_workflows( def run_workflows(
trigger_type: WorkflowTrigger.WorkflowTriggerType, trigger_type: WorkflowTrigger.WorkflowTriggerType,
document: Document | ConsumableDocument, document: Document | ConsumableDocument,
@@ -1028,11 +1000,7 @@ def add_or_update_document_in_llm_index(sender, document, **kwargs):
@receiver(models.signals.post_delete, sender=Document) @receiver(models.signals.post_delete, sender=Document)
def delete_document_from_llm_index( def delete_document_from_llm_index(sender, instance: Document, **kwargs):
sender: Any,
instance: Document,
**kwargs: Any,
) -> None:
""" """
Delete a document from the LLM index when it is deleted. Delete a document from the LLM index when it is deleted.
""" """

View File

@@ -60,7 +60,6 @@ from documents.sanity_checker import SanityCheckFailedException
from documents.signals import document_updated from documents.signals import document_updated
from documents.signals.handlers import cleanup_document_deletion from documents.signals.handlers import cleanup_document_deletion
from documents.signals.handlers import run_workflows from documents.signals.handlers import run_workflows
from documents.signals.handlers import send_websocket_document_updated
from documents.workflows.utils import get_workflows_for_trigger from documents.workflows.utils import get_workflows_for_trigger
from paperless.config import AIConfig from paperless.config import AIConfig
from paperless_ai.indexing import llm_index_add_or_update_document from paperless_ai.indexing import llm_index_add_or_update_document
@@ -535,11 +534,6 @@ def check_scheduled_workflows() -> None:
workflow_to_run=workflow, workflow_to_run=workflow,
document=document, document=document,
) )
# Scheduled workflows dont send document_updated signal, so send a websocket update here to ensure clients are updated
send_websocket_document_updated(
sender=None,
document=document,
)
def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None: def update_document_parent_tags(tag: Tag, new_parent: Tag) -> None:

View File

@@ -1206,11 +1206,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
input_doc, overrides = self.get_last_consume_delay_call_args() input_doc, overrides = self.get_last_consume_delay_call_args()
self.assertEqual(input_doc.original_file.name, "simple.pdf") self.assertEqual(input_doc.original_file.name, "simple.pdf")
self.assertTrue( self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents)
input_doc.original_file.resolve(strict=False).is_relative_to(
Path(settings.SCRATCH_DIR).resolve(strict=False),
),
)
self.assertIsNone(overrides.title) self.assertIsNone(overrides.title)
self.assertIsNone(overrides.correspondent_id) self.assertIsNone(overrides.correspondent_id)
self.assertIsNone(overrides.document_type_id) self.assertIsNone(overrides.document_type_id)
@@ -1259,11 +1255,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
input_doc, overrides = self.get_last_consume_delay_call_args() input_doc, overrides = self.get_last_consume_delay_call_args()
self.assertEqual(input_doc.original_file.name, "simple.pdf") self.assertEqual(input_doc.original_file.name, "simple.pdf")
self.assertTrue( self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents)
input_doc.original_file.resolve(strict=False).is_relative_to(
Path(settings.SCRATCH_DIR).resolve(strict=False),
),
)
self.assertIsNone(overrides.title) self.assertIsNone(overrides.title)
self.assertIsNone(overrides.correspondent_id) self.assertIsNone(overrides.correspondent_id)
self.assertIsNone(overrides.document_type_id) self.assertIsNone(overrides.document_type_id)
@@ -2022,69 +2014,93 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
mock_get_date_parser.assert_not_called() mock_get_date_parser.assert_not_called()
def test_saved_views(self) -> None: def test_saved_views(self) -> None:
u1 = User.objects.create_superuser("user1") u1 = User.objects.create_user("user1")
u2 = User.objects.create_superuser("user2") 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( v1 = SavedView.objects.create(
owner=u1, owner=u1,
name="test1", name="test1",
sort_field="", sort_field="",
show_on_dashboard=False,
show_in_sidebar=False,
) )
SavedView.objects.create( v2 = SavedView.objects.create(
owner=u2, owner=u2,
name="test2", name="test2",
sort_field="", sort_field="",
show_on_dashboard=False,
show_in_sidebar=False,
) )
SavedView.objects.create( v3 = SavedView.objects.create(
owner=u2, owner=u2,
name="test3", name="test3",
sort_field="", sort_field="",
show_on_dashboard=False,
show_in_sidebar=False,
) )
response = self.client.get("/api/saved_views/") assign_perm("view_savedview", u1, v2)
self.assertEqual(response.status_code, status.HTTP_200_OK) assign_perm("change_savedview", u1, v2)
self.assertEqual(response.data["count"], 0) assign_perm("view_savedview", u1, v3)
self.assertEqual(
self.client.get(f"/api/saved_views/{v1.id}/").status_code,
status.HTTP_404_NOT_FOUND,
)
self.client.force_authenticate(user=u1) self.client.force_authenticate(user=u1)
response = self.client.get("/api/saved_views/") response = self.client.get("/api/saved_views/")
self.assertEqual(response.status_code, status.HTTP_200_OK) 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.assertEqual(
self.client.get(f"/api/saved_views/{v1.id}/").status_code, response.status_code,
status.HTTP_200_OK, 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/") response = self.client.get("/api/saved_views/")
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 2) self.assertEqual(response.data["count"], 0)
self.assertEqual(
self.client.get(f"/api/saved_views/{v1.id}/").status_code,
status.HTTP_404_NOT_FOUND,
)
def test_saved_view_create_update_patch(self) -> None: def test_saved_view_create_update_patch(self) -> None:
User.objects.create_user("user1") User.objects.create_user("user1")
view = { view = {
"name": "test", "name": "test",
"show_on_dashboard": True,
"show_in_sidebar": True,
"sort_field": "created2", "sort_field": "created2",
"filter_rules": [{"rule_type": 4, "value": "test"}], "filter_rules": [{"rule_type": 4, "value": "test"}],
} }
@@ -2099,13 +2115,13 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
response = self.client.patch( response = self.client.patch(
f"/api/saved_views/{v1.id}/", f"/api/saved_views/{v1.id}/",
{"show_in_sidebar": False}, {"sort_reverse": True},
format="json", format="json",
) )
v1 = SavedView.objects.get(id=v1.id) v1 = SavedView.objects.get(id=v1.id)
self.assertEqual(response.status_code, status.HTTP_200_OK) 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) self.assertEqual(v1.filter_rules.count(), 1)
view["filter_rules"] = [{"rule_type": 12, "value": "secret"}] view["filter_rules"] = [{"rule_type": 12, "value": "secret"}]
@@ -2139,8 +2155,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
view = { view = {
"name": "test", "name": "test",
"show_on_dashboard": True,
"show_in_sidebar": True,
"sort_field": "created2", "sort_field": "created2",
"filter_rules": [{"rule_type": 4, "value": "test"}], "filter_rules": [{"rule_type": 4, "value": "test"}],
"page_size": 20, "page_size": 20,
@@ -2228,8 +2242,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
""" """
view = { view = {
"name": "test", "name": "test",
"show_on_dashboard": True,
"show_in_sidebar": True,
"sort_field": "created2", "sort_field": "created2",
"filter_rules": [{"rule_type": 4, "value": "test"}], "filter_rules": [{"rule_type": 4, "value": "test"}],
"page_size": 20, "page_size": 20,
@@ -2305,8 +2317,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
owner=self.user, owner=self.user,
name="test", name="test",
sort_field=SavedView.DisplayFields.CUSTOM_FIELD % custom_field.id, sort_field=SavedView.DisplayFields.CUSTOM_FIELD % custom_field.id,
show_on_dashboard=True,
show_in_sidebar=True,
display_fields=[ display_fields=[
SavedView.DisplayFields.TITLE, SavedView.DisplayFields.TITLE,
SavedView.DisplayFields.CREATED, SavedView.DisplayFields.CREATED,

View File

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

View File

@@ -0,0 +1,175 @@
from documents.tests.utils import TestMigrations
DASHBOARD_VIEWS_VISIBLE_IDS_KEY = (
"general-settings:saved-views:dashboard-views-visible-ids"
)
SIDEBAR_VIEWS_VISIBLE_IDS_KEY = "general-settings:saved-views:sidebar-views-visible-ids"
class TestMigrateSavedViewVisibilityToUiSettings(TestMigrations):
migrate_from = "0011_optimize_integer_field_sizes"
migrate_to = "0012_savedview_visibility_to_ui_settings"
def setUpBeforeMigration(self, apps) -> None:
User = apps.get_model("auth", "User")
SavedView = apps.get_model("documents", "SavedView")
UiSettings = apps.get_model("documents", "UiSettings")
self.user_with_empty_settings = User.objects.create(username="user1")
self.user_with_existing_settings = User.objects.create(username="user2")
self.user_with_owned_views = User.objects.create(username="user3")
self.user_with_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.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",
)
UiSettings.objects.create(user=self.user_with_empty_settings, settings={})
UiSettings.objects.create(
user=self.user_with_existing_settings,
settings={
DASHBOARD_VIEWS_VISIBLE_IDS_KEY: [self.sidebar_only_view.id],
SIDEBAR_VIEWS_VISIBLE_IDS_KEY: [self.dashboard_view.id],
"preserve": "value",
},
)
def test_visibility_defaults_are_seeded_and_existing_values_preserved(self) -> None:
UiSettings = self.apps.get_model("documents", "UiSettings")
seeded_settings = UiSettings.objects.get(
user_id=self.user_with_empty_settings_id,
).settings
self.assertCountEqual(
seeded_settings[DASHBOARD_VIEWS_VISIBLE_IDS_KEY],
[self.dashboard_view.id],
)
self.assertCountEqual(
seeded_settings[SIDEBAR_VIEWS_VISIBLE_IDS_KEY],
[self.dashboard_view.id, self.sidebar_only_view.id],
)
existing_settings = UiSettings.objects.get(
user_id=self.user_with_existing_settings_id,
).settings
self.assertEqual(
existing_settings[DASHBOARD_VIEWS_VISIBLE_IDS_KEY],
[self.sidebar_only_view.id],
)
self.assertEqual(
existing_settings[SIDEBAR_VIEWS_VISIBLE_IDS_KEY],
[self.dashboard_view.id],
)
self.assertEqual(existing_settings["preserve"], "value")
created_settings = UiSettings.objects.get(
user_id=self.user_with_owned_views_id,
).settings
self.assertCountEqual(
created_settings[DASHBOARD_VIEWS_VISIBLE_IDS_KEY],
[self.other_owner_visible_view.id],
)
self.assertCountEqual(
created_settings[SIDEBAR_VIEWS_VISIBLE_IDS_KEY],
[self.other_owner_visible_view.id],
)
class TestReverseMigrateSavedViewVisibilityFromUiSettings(TestMigrations):
migrate_from = "0012_savedview_visibility_to_ui_settings"
migrate_to = "0011_optimize_integer_field_sizes"
def setUpBeforeMigration(self, apps) -> None:
User = apps.get_model("auth", "User")
SavedView = apps.get_model("documents", "SavedView")
UiSettings = apps.get_model("documents", "UiSettings")
user1 = User.objects.create(username="user1")
user2 = User.objects.create(username="user2")
user3 = User.objects.create(username="user3")
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",
)
UiSettings.objects.create(
user=user1,
settings={
DASHBOARD_VIEWS_VISIBLE_IDS_KEY: [self.view1.id],
SIDEBAR_VIEWS_VISIBLE_IDS_KEY: [self.view2.id],
},
)
UiSettings.objects.create(
user=user2,
settings={
DASHBOARD_VIEWS_VISIBLE_IDS_KEY: [
self.view2.id,
self.view3.id,
self.view4.id,
],
SIDEBAR_VIEWS_VISIBLE_IDS_KEY: [self.view4.id],
},
)
UiSettings.objects.create(user=user3, settings={})
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)
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)

View File

@@ -3,7 +3,6 @@ import json
import shutil import shutil
import socket import socket
import tempfile import tempfile
from collections.abc import Callable
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -641,9 +640,7 @@ class TestWorkflows(
expected_str = f"Document did not match {w}" expected_str = f"Document did not match {w}"
self.assertIn(expected_str, cm.output[0]) self.assertIn(expected_str, cm.output[0])
expected_str = ( expected_str = f"Document path {test_file} does not match"
f"Document path {Path(test_file).resolve(strict=False)} does not match"
)
self.assertIn(expected_str, cm.output[1]) self.assertIn(expected_str, cm.output[1])
def test_workflow_no_match_mail_rule(self) -> None: def test_workflow_no_match_mail_rule(self) -> None:
@@ -1968,36 +1965,6 @@ class TestWorkflows(
doc.refresh_from_db() doc.refresh_from_db()
self.assertEqual(doc.owner, self.user2) self.assertEqual(doc.owner, self.user2)
@mock.patch("documents.tasks.send_websocket_document_updated")
def test_workflow_scheduled_trigger_sends_websocket_update(
self,
mock_send_websocket_document_updated,
) -> None:
trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
schedule_offset_days=1,
schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
)
action = WorkflowAction.objects.create(assign_owner=self.user2)
workflow = Workflow.objects.create(name="Workflow 1", order=0)
workflow.triggers.add(trigger)
workflow.actions.add(action)
doc = Document.objects.create(
title="sample test",
correspondent=self.c,
original_filename="sample.pdf",
created=timezone.now() - timedelta(days=2),
)
tasks.check_scheduled_workflows()
self.assertEqual(mock_send_websocket_document_updated.call_count, 1)
self.assertEqual(
mock_send_websocket_document_updated.call_args.kwargs["document"].pk,
doc.pk,
)
def test_workflow_scheduled_trigger_added(self) -> None: def test_workflow_scheduled_trigger_added(self) -> None:
""" """
GIVEN: GIVEN:
@@ -4136,7 +4103,7 @@ class TestWebhookSecurity:
def test_strips_user_supplied_host_header( def test_strips_user_supplied_host_header(
self, self,
httpx_mock: HTTPXMock, httpx_mock: HTTPXMock,
resolve_to: Callable[[str], None], resolve_to,
) -> None: ) -> None:
""" """
GIVEN: GIVEN:

View File

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

View File

@@ -1,5 +1,4 @@
import json import json
from typing import Any
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.exceptions import AcceptConnection from channels.exceptions import AcceptConnection
@@ -53,10 +52,3 @@ class StatusConsumer(WebsocketConsumer):
self.close() self.close()
else: else:
self.send(json.dumps(event)) self.send(json.dumps(event))
def document_updated(self, event: Any) -> None:
if not self._authenticated():
self.close()
else:
if self._can_view(event["data"]):
self.send(json.dumps(event))

View File

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

View File

@@ -158,40 +158,6 @@ class TestWebSockets(TestCase):
await communicator.disconnect() await communicator.disconnect()
@mock.patch("paperless.consumers.StatusConsumer._can_view")
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
async def test_receive_document_updated(self, _authenticated, _can_view) -> None:
_authenticated.return_value = True
_can_view.return_value = True
communicator = WebsocketCommunicator(application, "/ws/status/")
connected, _ = await communicator.connect()
self.assertTrue(connected)
message = {
"type": "document_updated",
"data": {
"document_id": 10,
"modified": "2026-02-17T00:00:00Z",
"owner_id": 1,
"users_can_view": [1],
"groups_can_view": [],
},
}
channel_layer = get_channel_layer()
assert channel_layer is not None
await channel_layer.group_send(
"status_updates",
message,
)
response = await communicator.receive_json_from()
self.assertEqual(response, message)
await communicator.disconnect()
@mock.patch("channels.layers.InMemoryChannelLayer.group_send") @mock.patch("channels.layers.InMemoryChannelLayer.group_send")
def test_manager_send_progress(self, mock_group_send) -> None: def test_manager_send_progress(self, mock_group_send) -> None:
with ProgressManager(task_id="test") as manager: with ProgressManager(task_id="test") as manager:
@@ -224,10 +190,7 @@ class TestWebSockets(TestCase):
) )
@mock.patch("channels.layers.InMemoryChannelLayer.group_send") @mock.patch("channels.layers.InMemoryChannelLayer.group_send")
def test_manager_send_documents_deleted( def test_manager_send_documents_deleted(self, mock_group_send) -> None:
self,
mock_group_send: mock.MagicMock,
) -> None:
with DocumentsStatusManager() as manager: with DocumentsStatusManager() as manager:
manager.send_documents_deleted([1, 2, 3]) manager.send_documents_deleted([1, 2, 3])