mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-22 11:26:24 +00:00
Compare commits
19 Commits
feature-li
...
feature-82
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7331a4ec3 | ||
|
|
da0ca837ea | ||
|
|
e92352384f | ||
|
|
e588321ea3 | ||
|
|
a7ff3fac6b | ||
|
|
7bcfcf1e49 | ||
|
|
34bdf41931 | ||
|
|
773f0b2d1b | ||
|
|
accd5cd574 | ||
|
|
4b4c2766e6 | ||
|
|
16767b1433 | ||
|
|
45456042cb | ||
|
|
258777cac9 | ||
|
|
32ef2913b1 | ||
|
|
ef723a5c93 | ||
|
|
77554f6b36 | ||
|
|
e69e543ba2 | ||
|
|
e9d87ef049 | ||
|
|
e0e517358d |
@@ -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/0008_sharelinkbundle.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/migrations/0008_sharelinkbundle.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/migrations/0012_savedview_visibility_to_ui_settings.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/migrations/0012_savedview_visibility_to_ui_settings.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/migrations/0012_savedview_visibility_to_ui_settings.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/models.py:0: error: Argument 1 to "Path" has incompatible type "Path | None"; expected "str | PathLike[str]" [arg-type]
|
||||
src/documents/models.py:0: error: Could not resolve manager type for "documents.models.Document.deleted_objects" [django-manager-missing]
|
||||
src/documents/models.py:0: error: Could not resolve manager type for "documents.models.Document.global_objects" [django-manager-missing]
|
||||
@@ -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: 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/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: 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: 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 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_retagger.py:0: error: Function is missing a return type annotation [no-untyped-def]
|
||||
src/documents/tests/test_management_superuser.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Cannot determine type of "apps" [has-type]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Cannot determine type of "apps" [has-type]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
|
||||
src/documents/tests/test_migration_saved_view_visibility.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
|
||||
src/documents/tests/test_migration_share_link_bundle.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_migration_share_link_bundle.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/tests/test_migration_share_link_bundle.py:0: error: Incompatible types in assignment (expression has type "str", base class "TestMigrations" defined the type as "None") [assignment]
|
||||
@@ -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 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 for one or more arguments [no-untyped-def]
|
||||
src/documents/views.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/views.py:0: error: Incompatible type for lookup 'owner': (got "User | AnonymousUser", expected "User | int | None") [misc]
|
||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "Any | None", variable has type "dict[Any, Any]") [assignment]
|
||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "list[Any]") [assignment]
|
||||
src/documents/views.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Any, Any]", variable has type "list[Document]") [assignment]
|
||||
@@ -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[CustomField]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[Group]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[MailAccount]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[MailRule]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[SavedView]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[User]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "Iterable[Workflow]" is not indexable [index]
|
||||
src/documents/views.py:0: error: Value of type "dict[str, _PingReply] | None" is not indexable [index]
|
||||
|
||||
@@ -451,3 +451,8 @@ Initial API version.
|
||||
- The document `created` field is now a date, not a datetime. The
|
||||
`created_date` field is considered deprecated and will be removed in a
|
||||
future version.
|
||||
|
||||
#### Version 10
|
||||
|
||||
- The `show_on_dashboard` and `show_in_sidebar` fields of saved views have been
|
||||
removed. Relevant settings are now stored in the UISettings model.
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true,\"dashboard_views_visible_ids\":[7,4],\"sidebar_views_visible_ids\":[7,4,11]},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true,\"dashboard_views_visible_ids\":[7,4],\"sidebar_views_visible_ids\":[7,4,11]},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true,\"dashboard_views_visible_ids\":[7,4],\"sidebar_views_visible_ids\":[7,4,11]},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true,\"dashboard_views_visible_ids\":[7,4],\"sidebar_views_visible_ids\":[7,4,11]},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"delete_schedule\",\"view_note\",\"view_taskattributes\",\"delete_ormq\",\"add_ormq\",\"change_tag\",\"change_chordcounter\",\"delete_savedviewfilterrule\",\"delete_comment\",\"add_session\",\"add_mailrule\",\"add_tokenproxy\",\"delete_taskresult\",\"view_failure\",\"add_tag\",\"view_savedviewfilterrule\",\"view_paperlesstask\",\"change_mailaccount\",\"change_frontendsettings\",\"delete_group\",\"add_userobjectpermission\",\"add_failure\",\"delete_mailrule\",\"view_userobjectpermission\",\"change_schedule\",\"delete_note\",\"view_ormq\",\"add_note\",\"add_chordcounter\",\"delete_token\",\"change_failure\",\"add_savedviewfilterrule\",\"delete_user\",\"view_correspondent\",\"view_schedule\",\"change_tokenproxy\",\"view_user\",\"delete_task\",\"delete_correspondent\",\"delete_chordcounter\",\"delete_document\",\"view_taskresult\",\"change_document\",\"add_frontendsettings\",\"view_mailrule\",\"change_ormq\",\"delete_taskattributes\",\"view_logentry\",\"view_mailaccount\",\"view_log\",\"delete_success\",\"view_frontendsettings\",\"view_documenttype\",\"change_taskresult\",\"view_permission\",\"add_groupobjectpermission\",\"change_user\",\"view_document\",\"change_userobjectpermission\",\"add_user\",\"add_correspondent\",\"add_token\",\"add_mailaccount\",\"change_group\",\"add_group\",\"delete_processedmail\",\"delete_contenttype\",\"add_savedview\",\"view_chordcounter\",\"delete_tokenproxy\",\"change_groupresult\",\"delete_session\",\"view_savedview\",\"view_processedmail\",\"add_comment\",\"view_storagepath\",\"delete_documenttype\",\"add_processedmail\",\"view_group\",\"change_processedmail\",\"view_session\",\"delete_storagepath\",\"delete_paperlesstask\",\"add_groupresult\",\"delete_savedview\",\"delete_userobjectpermission\",\"view_tokenproxy\",\"add_task\",\"view_tag\",\"add_taskresult\",\"change_documenttype\",\"change_mailrule\",\"add_document\",\"change_comment\",\"view_task\",\"view_groupresult\",\"change_contenttype\",\"view_groupobjectpermission\",\"change_task\",\"add_log\",\"add_success\",\"change_savedview\",\"delete_frontendsettings\",\"view_success\",\"add_permission\",\"change_correspondent\",\"add_paperlesstask\",\"change_paperlesstask\",\"add_contenttype\",\"view_comment\",\"change_logentry\",\"delete_logentry\",\"delete_mailaccount\",\"change_session\",\"delete_groupresult\",\"add_logentry\",\"change_savedviewfilterrule\",\"change_success\",\"delete_tag\",\"add_taskattributes\",\"change_groupobjectpermission\",\"delete_failure\",\"add_uisettings\",\"view_token\",\"add_schedule\",\"delete_log\",\"delete_uisettings\",\"change_permission\",\"delete_groupobjectpermission\",\"change_token\",\"view_uisettings\",\"change_uisettings\",\"delete_permission\",\"add_storagepath\",\"change_storagepath\",\"view_contenttype\",\"change_note\",\"change_log\",\"change_taskattributes\",\"add_documenttype\"]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
|
||||
@@ -99,12 +99,7 @@
|
||||
</ul>
|
||||
|
||||
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
@if (savedViewService.loading) {
|
||||
<h6 class="sidebar-heading px-3 text-muted">
|
||||
<span i18n>Saved views</span>
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||
</h6>
|
||||
} @else if (savedViewService.sidebarViews?.length > 0) {
|
||||
@if (savedViewService.sidebarViews?.length > 0) {
|
||||
<h6 class="sidebar-heading px-3 text-muted">
|
||||
<span i18n>Saved views</span>
|
||||
</h6>
|
||||
@@ -134,6 +129,11 @@
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (savedViewService.loading) {
|
||||
<h6 class="sidebar-heading px-3 text-muted">
|
||||
<span i18n>Saved views</span>
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||
</h6>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
<div class="col-md-6 col-xl-5 mb-4">
|
||||
|
||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
||||
|
||||
<div class="btn-toolbar mb-1 border-bottom">
|
||||
<div class="btn-group pb-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
|
||||
|
||||
@@ -83,9 +83,9 @@ const doc: Document = {
|
||||
storage_path: 31,
|
||||
tags: [41, 42, 43],
|
||||
content: 'text content',
|
||||
added: new Date('May 4, 2014 03:24:00').toISOString(),
|
||||
created: new Date('May 4, 2014 03:24:00').toISOString(),
|
||||
modified: 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'),
|
||||
modified: new Date('May 4, 2014 03:24:00'),
|
||||
archive_serial_number: null,
|
||||
original_file_name: 'file.pdf',
|
||||
owner: null,
|
||||
@@ -392,7 +392,7 @@ describe('DocumentDetailComponent', () => {
|
||||
jest.spyOn(documentService, 'get').mockReturnValue(
|
||||
of({
|
||||
...doc,
|
||||
modified: '2024-01-02T00:00:00Z',
|
||||
modified: new Date('2024-01-02T00:00:00Z'),
|
||||
duplicate_documents: updatedDuplicates,
|
||||
})
|
||||
)
|
||||
@@ -1205,21 +1205,17 @@ describe('DocumentDetailComponent', () => {
|
||||
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
|
||||
modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
const openDoc = Object.assign({}, doc, {
|
||||
__changedFields: ['title'],
|
||||
})
|
||||
const openDoc = Object.assign({}, doc)
|
||||
// simulate a document being modified elsewhere and db updated
|
||||
const remoteDoc = Object.assign({}, doc, {
|
||||
modified: new Date(new Date(doc.modified).getTime() + 1000).toISOString(),
|
||||
})
|
||||
doc.modified = new Date()
|
||||
jest
|
||||
.spyOn(activatedRoute, 'paramMap', 'get')
|
||||
.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(customFieldsService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
@@ -1229,52 +1225,11 @@ describe('DocumentDetailComponent', () => {
|
||||
})
|
||||
)
|
||||
fixture.detectChanges() // calls ngOnInit
|
||||
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent)
|
||||
const closeSpy = jest.spyOn(openModal, 'close')
|
||||
const confirmDialog = openModal.componentInstance as ConfirmDialogComponent
|
||||
expect(confirmDialog.messageBold).toContain('Document was updated at')
|
||||
})
|
||||
|
||||
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()
|
||||
confirmDialog.confirmClicked.next(confirmDialog)
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should change preview element by render type', () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
NgbDateStruct,
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbModalRef,
|
||||
NgbNav,
|
||||
NgbNavChangeEvent,
|
||||
NgbNavModule,
|
||||
@@ -81,7 +80,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { getFilenameFromContentDisposition } from 'src/app/utils/http'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
import * as UTIF from 'utif'
|
||||
@@ -144,11 +142,6 @@ enum ContentRenderType {
|
||||
TIFF = 'tiff',
|
||||
}
|
||||
|
||||
interface IncomingDocumentUpdate {
|
||||
document_id: number
|
||||
modified?: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-detail',
|
||||
templateUrl: './document-detail.component.html',
|
||||
@@ -212,7 +205,6 @@ export class DocumentDetailComponent
|
||||
private componentRouterService = inject(ComponentRouterService)
|
||||
private deviceDetectorService = inject(DeviceDetectorService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private websocketStatusService = inject(WebsocketStatusService)
|
||||
|
||||
@ViewChild('inputTitle')
|
||||
titleInput: TextComponent
|
||||
@@ -269,9 +261,6 @@ export class DocumentDetailComponent
|
||||
isDirty$: Observable<boolean>
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
docChangeNotifier: Subject<any> = new Subject()
|
||||
private incomingUpdateModal: NgbModalRef
|
||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||
private lastLocalSaveModified: string | null = null
|
||||
|
||||
requiresPassword: boolean = false
|
||||
password: string
|
||||
@@ -443,64 +432,7 @@ export class DocumentDetailComponent
|
||||
)
|
||||
}
|
||||
|
||||
private hasLocalEdits(doc: Document): boolean {
|
||||
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
|
||||
private loadDocument(documentId: number): void {
|
||||
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
|
||||
this.updatePdfSource()
|
||||
this.http
|
||||
@@ -545,25 +477,21 @@ export class DocumentDetailComponent
|
||||
openDocument.duplicate_documents = doc.duplicate_documents
|
||||
this.openDocumentService.save()
|
||||
}
|
||||
let useDoc = openDocument || doc
|
||||
if (openDocument && forceRemote) {
|
||||
Object.assign(openDocument, doc)
|
||||
openDocument.__changedFields = []
|
||||
this.openDocumentService.setDirty(openDocument, false)
|
||||
this.openDocumentService.save()
|
||||
useDoc = openDocument
|
||||
} else if (openDocument) {
|
||||
if (new Date(doc.modified) > new Date(openDocument.modified)) {
|
||||
if (this.hasLocalEdits(openDocument)) {
|
||||
this.showIncomingUpdateModal(doc.modified)
|
||||
} else {
|
||||
// No local edits to preserve, so keep the tab in sync automatically.
|
||||
Object.assign(openDocument, doc)
|
||||
openDocument.__changedFields = []
|
||||
this.openDocumentService.setDirty(openDocument, false)
|
||||
this.openDocumentService.save()
|
||||
useDoc = openDocument
|
||||
}
|
||||
const useDoc = openDocument || doc
|
||||
if (openDocument) {
|
||||
if (
|
||||
new Date(doc.modified) > new Date(openDocument.modified) &&
|
||||
!this.modalService.hasOpenModals()
|
||||
) {
|
||||
const modal = this.modalService.open(ConfirmDialogComponent)
|
||||
modal.componentInstance.title = $localize`Document changes detected`
|
||||
modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
|
||||
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.`
|
||||
modal.componentInstance.cancelBtnClass = 'visually-hidden'
|
||||
modal.componentInstance.btnCaption = $localize`Ok`
|
||||
modal.componentInstance.confirmClicked.subscribe(() =>
|
||||
modal.close()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
this.setZoom(
|
||||
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
|
||||
@@ -696,11 +580,6 @@ export class DocumentDetailComponent
|
||||
|
||||
this.getCustomFields()
|
||||
|
||||
this.websocketStatusService
|
||||
.onDocumentUpdated()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((data) => this.handleIncomingDocumentUpdated(data))
|
||||
|
||||
this.route.paramMap
|
||||
.pipe(
|
||||
filter(
|
||||
@@ -1035,7 +914,6 @@ export class DocumentDetailComponent
|
||||
)
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
this.closeIncomingUpdateModal()
|
||||
Object.assign(this.document, doc)
|
||||
doc['permissions_form'] = {
|
||||
owner: doc.owner,
|
||||
@@ -1082,8 +960,6 @@ export class DocumentDetailComponent
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (docValues) => {
|
||||
this.closeIncomingUpdateModal()
|
||||
this.lastLocalSaveModified = docValues.modified ?? null
|
||||
// in case data changed while saving eg removing inbox_tags
|
||||
this.documentForm.patchValue(docValues)
|
||||
const newValues = Object.assign({}, this.documentForm.value)
|
||||
@@ -1098,19 +974,16 @@ export class DocumentDetailComponent
|
||||
this.networkActive = false
|
||||
this.error = null
|
||||
if (close) {
|
||||
this.pendingIncomingUpdate = null
|
||||
this.close(() =>
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
)
|
||||
} else {
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
this.flushPendingIncomingUpdate()
|
||||
}
|
||||
this.savedViewService.maybeRefreshDocumentCounts()
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
this.lastLocalSaveModified = null
|
||||
const canEdit =
|
||||
this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
@@ -1130,7 +1003,6 @@ export class DocumentDetailComponent
|
||||
error
|
||||
)
|
||||
}
|
||||
this.flushPendingIncomingUpdate()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1167,11 +1039,8 @@ export class DocumentDetailComponent
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: ({ updateResult, nextDocId, closeResult }) => {
|
||||
this.closeIncomingUpdateModal()
|
||||
this.error = null
|
||||
this.networkActive = false
|
||||
this.pendingIncomingUpdate = null
|
||||
this.lastLocalSaveModified = null
|
||||
if (closeResult && updateResult && nextDocId) {
|
||||
this.router.navigate(['documents', nextDocId])
|
||||
this.titleInput?.focus()
|
||||
@@ -1179,10 +1048,8 @@ export class DocumentDetailComponent
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
this.lastLocalSaveModified = null
|
||||
this.error = error.error
|
||||
this.toastService.showError($localize`Error saving document`, error)
|
||||
this.flushPendingIncomingUpdate()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1268,7 +1135,7 @@ export class DocumentDetailComponent
|
||||
.subscribe({
|
||||
next: () => {
|
||||
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) {
|
||||
modal.close()
|
||||
|
||||
@@ -104,11 +104,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
<div *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.SavedView }">
|
||||
@if (list.activeSavedViewId) {
|
||||
<button ngbDropdownItem (click)="saveViewConfig()" [disabled]="!savedViewIsModified" i18n>Save "{{list.activeSavedViewTitle}}"</button>
|
||||
}
|
||||
</div>
|
||||
@if (list.activeSavedViewId && activeSavedViewCanChange) {
|
||||
<button ngbDropdownItem (click)="saveViewConfig()" [disabled]="!savedViewIsModified" i18n>Save "{{list.activeSavedViewTitle}}"</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>
|
||||
</div>
|
||||
|
||||
@@ -452,7 +452,7 @@ describe('DocumentListComponent', () => {
|
||||
})
|
||||
|
||||
it('should handle error on view saving', () => {
|
||||
component.list.activateSavedView({
|
||||
const view: SavedView = {
|
||||
id: 10,
|
||||
name: 'Saved View 10',
|
||||
sort_field: 'added',
|
||||
@@ -463,7 +463,16 @@ describe('DocumentListComponent', () => {
|
||||
value: '20',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
|
||||
const queryParams = { view: view.id.toString() }
|
||||
jest
|
||||
.spyOn(activatedRoute, 'queryParamMap', 'get')
|
||||
.mockReturnValue(of(convertToParamMap(queryParams)))
|
||||
activatedRoute.snapshot.queryParams = queryParams
|
||||
router.routerState.snapshot.url = '/view/10/'
|
||||
fixture.detectChanges()
|
||||
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
jest
|
||||
.spyOn(savedViewService, 'patch')
|
||||
@@ -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', () => {
|
||||
const view: SavedView = {
|
||||
id: 10,
|
||||
@@ -506,16 +549,44 @@ describe('DocumentListComponent', () => {
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const savedViewServiceCreate = jest.spyOn(savedViewService, 'create')
|
||||
const updateVisibilitySpy = jest
|
||||
.spyOn(settingsService, 'updateSavedViewsVisibility')
|
||||
.mockReturnValue(of({ success: true }))
|
||||
savedViewServiceCreate.mockReturnValueOnce(of(modifiedView))
|
||||
component.saveViewConfigAs()
|
||||
|
||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||
const permissions = {
|
||||
owner: 5,
|
||||
set_permissions: {
|
||||
view: {
|
||||
users: [4],
|
||||
groups: [3],
|
||||
},
|
||||
change: {
|
||||
users: [2],
|
||||
groups: [1],
|
||||
},
|
||||
},
|
||||
}
|
||||
openModal.componentInstance.saveClicked.next({
|
||||
name: 'Foo Bar',
|
||||
show_on_dashboard: true,
|
||||
show_in_sidebar: true,
|
||||
showOnDashboard: true,
|
||||
showInSideBar: true,
|
||||
permissions_form: permissions,
|
||||
})
|
||||
expect(savedViewServiceCreate).toHaveBeenCalled()
|
||||
expect(savedViewServiceCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Foo Bar',
|
||||
owner: permissions.owner,
|
||||
set_permissions: permissions.set_permissions,
|
||||
})
|
||||
)
|
||||
expect(updateVisibilitySpy).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([modifiedView.id]),
|
||||
expect.arrayContaining([modifiedView.id])
|
||||
)
|
||||
expect(modalSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(modalCloseSpy).toHaveBeenCalled()
|
||||
@@ -549,6 +620,10 @@ describe('DocumentListComponent', () => {
|
||||
|
||||
let openModal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||
const updateVisibilitySpy = jest.spyOn(
|
||||
settingsService,
|
||||
'updateSavedViewsVisibility'
|
||||
)
|
||||
jest.spyOn(savedViewService, 'create').mockReturnValueOnce(
|
||||
throwError(
|
||||
() =>
|
||||
@@ -561,9 +636,10 @@ describe('DocumentListComponent', () => {
|
||||
|
||||
openModal.componentInstance.saveClicked.next({
|
||||
name: 'Foo Bar',
|
||||
show_on_dashboard: true,
|
||||
show_in_sidebar: true,
|
||||
showOnDashboard: true,
|
||||
showInSideBar: true,
|
||||
})
|
||||
expect(updateVisibilitySpy).not.toHaveBeenCalled()
|
||||
expect(openModal.componentInstance.error).toEqual({ filter_rules: ['11'] })
|
||||
})
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
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 {
|
||||
DEFAULT_DISPLAY_FIELDS,
|
||||
DisplayField,
|
||||
@@ -47,7 +47,10 @@ import { UsernamePipe } from 'src/app/pipes/username.pipe'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
@@ -148,12 +151,18 @@ export class DocumentListComponent
|
||||
|
||||
unmodifiedFilterRules: FilterRule[] = []
|
||||
private unmodifiedSavedView: SavedView
|
||||
private activeSavedView: SavedView | null = null
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
get savedViewIsModified(): boolean {
|
||||
if (!this.list.activeSavedViewId || !this.unmodifiedSavedView) return false
|
||||
else {
|
||||
if (
|
||||
!this.list.activeSavedViewId ||
|
||||
!this.unmodifiedSavedView ||
|
||||
!this.activeSavedViewCanChange
|
||||
) {
|
||||
return false
|
||||
} else {
|
||||
return (
|
||||
this.unmodifiedSavedView.sort_field !== this.list.sortField ||
|
||||
this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse ||
|
||||
@@ -180,6 +189,16 @@ export class DocumentListComponent
|
||||
}
|
||||
}
|
||||
|
||||
get activeSavedViewCanChange(): boolean {
|
||||
if (!this.activeSavedView) {
|
||||
return false
|
||||
}
|
||||
return this.permissionService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
this.activeSavedView
|
||||
)
|
||||
}
|
||||
|
||||
get isFiltered() {
|
||||
return !!this.filterEditor?.rulesModified
|
||||
}
|
||||
@@ -256,11 +275,13 @@ export class DocumentListComponent
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ view }) => {
|
||||
if (!view) {
|
||||
this.activeSavedView = null
|
||||
this.router.navigate(['404'], {
|
||||
replaceUrl: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
this.activeSavedView = view
|
||||
this.unmodifiedSavedView = view
|
||||
this.list.activateSavedViewWithQueryParams(
|
||||
view,
|
||||
@@ -284,6 +305,7 @@ export class DocumentListComponent
|
||||
// loading a saved view on /documents
|
||||
this.loadViewConfig(parseInt(queryParams.get('view')))
|
||||
} else {
|
||||
this.activeSavedView = null
|
||||
this.list.activateSavedView(null)
|
||||
this.list.loadFromQueryParams(queryParams)
|
||||
this.unmodifiedFilterRules = []
|
||||
@@ -366,7 +388,7 @@ export class DocumentListComponent
|
||||
}
|
||||
|
||||
saveViewConfig() {
|
||||
if (this.list.activeSavedViewId != null) {
|
||||
if (this.list.activeSavedViewId != null && this.activeSavedViewCanChange) {
|
||||
let savedView: SavedView = {
|
||||
id: this.list.activeSavedViewId,
|
||||
filter_rules: this.list.filterRules,
|
||||
@@ -380,6 +402,7 @@ export class DocumentListComponent
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (view) => {
|
||||
this.activeSavedView = view
|
||||
this.unmodifiedSavedView = view
|
||||
this.toastService.showInfo(
|
||||
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
||||
@@ -401,6 +424,11 @@ export class DocumentListComponent
|
||||
.getCached(viewID)
|
||||
.pipe(first())
|
||||
.subscribe((view) => {
|
||||
if (!view) {
|
||||
this.activeSavedView = null
|
||||
return
|
||||
}
|
||||
this.activeSavedView = view
|
||||
this.unmodifiedSavedView = view
|
||||
this.list.activateSavedView(view)
|
||||
this.list.reload(() => {
|
||||
@@ -418,24 +446,48 @@ export class DocumentListComponent
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
let savedView: SavedView = {
|
||||
name: formValue.name,
|
||||
show_on_dashboard: formValue.showOnDashboard,
|
||||
show_in_sidebar: formValue.showInSideBar,
|
||||
filter_rules: this.list.filterRules,
|
||||
sort_reverse: this.list.sortReverse,
|
||||
sort_field: this.list.sortField,
|
||||
display_mode: this.list.displayMode,
|
||||
display_fields: this.activeDisplayFields,
|
||||
}
|
||||
const permissions = formValue.permissions_form
|
||||
if (permissions) {
|
||||
if (permissions.owner !== null && permissions.owner !== undefined) {
|
||||
savedView.owner = permissions.owner
|
||||
}
|
||||
if (permissions.set_permissions) {
|
||||
savedView['set_permissions'] = permissions.set_permissions
|
||||
}
|
||||
}
|
||||
|
||||
this.savedViewService
|
||||
.create(savedView)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo(
|
||||
$localize`View "${savedView.name}" created successfully.`
|
||||
next: (createdView) => {
|
||||
this.saveCreatedViewVisibility(
|
||||
createdView,
|
||||
formValue.showOnDashboard,
|
||||
formValue.showInSideBar
|
||||
)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo(
|
||||
$localize`View "${savedView.name}" created successfully.`
|
||||
)
|
||||
},
|
||||
error: (error) => {
|
||||
modal.close()
|
||||
this.toastService.showError(
|
||||
$localize`View "${savedView.name}" created successfully, but could not update visibility settings.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
},
|
||||
error: (httpError) => {
|
||||
let error = httpError.error
|
||||
@@ -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) {
|
||||
this.router.navigate([
|
||||
'documents',
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
||||
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
|
||||
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
|
||||
<pngx-permissions-form [users]="users" accordion="true" formControlName="permissions_form"></pngx-permissions-form>
|
||||
@if (error?.filter_rules) {
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<h6 class="alert-heading" i18n>Filter rules error occurred while saving this view</h6>
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbActiveModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of } from 'rxjs'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { CheckComponent } from '../../common/input/check/check.component'
|
||||
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
||||
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
|
||||
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
|
||||
import { TextComponent } from '../../common/input/text/text.component'
|
||||
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
|
||||
|
||||
@@ -18,7 +24,21 @@ describe('SaveViewConfigDialogComponent', () => {
|
||||
|
||||
beforeEach(fakeAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [NgbActiveModal],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: {
|
||||
listAll: () => of({ results: [] }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: GroupService,
|
||||
useValue: {
|
||||
listAll: () => of({ results: [] }),
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
NgbModalModule,
|
||||
FormsModule,
|
||||
@@ -26,6 +46,9 @@ describe('SaveViewConfigDialogComponent', () => {
|
||||
SaveViewConfigDialogComponent,
|
||||
TextComponent,
|
||||
CheckComponent,
|
||||
PermissionsFormComponent,
|
||||
PermissionsUserComponent,
|
||||
PermissionsGroupComponent,
|
||||
],
|
||||
}).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', () => {
|
||||
const saveClickedSpy = jest.spyOn(component.saveClicked, 'emit')
|
||||
const modalCloseSpy = jest.spyOn(modal, 'close')
|
||||
|
||||
@@ -13,17 +13,27 @@ import {
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { CheckComponent } from '../../common/input/check/check.component'
|
||||
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
||||
import { TextComponent } from '../../common/input/text/text.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-save-view-config-dialog',
|
||||
templateUrl: './save-view-config-dialog.component.html',
|
||||
styleUrls: ['./save-view-config-dialog.component.scss'],
|
||||
imports: [CheckComponent, TextComponent, FormsModule, ReactiveFormsModule],
|
||||
imports: [
|
||||
CheckComponent,
|
||||
TextComponent,
|
||||
PermissionsFormComponent,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
})
|
||||
export class SaveViewConfigDialogComponent implements OnInit {
|
||||
private modal = inject(NgbActiveModal)
|
||||
private userService = inject(UserService)
|
||||
|
||||
@Output()
|
||||
public saveClicked = new EventEmitter()
|
||||
@@ -36,6 +46,8 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
||||
|
||||
closeEnabled = false
|
||||
|
||||
users: User[]
|
||||
|
||||
_defaultName = ''
|
||||
|
||||
get defaultName() {
|
||||
@@ -52,6 +64,7 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
||||
name: new FormControl(''),
|
||||
showInSideBar: new FormControl(false),
|
||||
showOnDashboard: new FormControl(false),
|
||||
permissions_form: new FormControl(null),
|
||||
})
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -59,10 +72,22 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
||||
setTimeout(() => {
|
||||
this.closeEnabled = true
|
||||
})
|
||||
this.userService.listAll().subscribe((r) => {
|
||||
this.users = r.results
|
||||
})
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -25,15 +25,23 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="deleteSavedView(view)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
|
||||
buttonClasses="btn-sm btn-outline-danger form-control"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
@if (canDeleteSavedView(view)) {
|
||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary form-control mb-2"
|
||||
type="button"
|
||||
(click)="editPermissions(view)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.SavedView }"
|
||||
i18n>Permissions</button>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="deleteSavedView(view)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
|
||||
buttonClasses="btn-sm btn-outline-danger form-control"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
@@ -3,16 +3,16 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||
import { CheckComponent } from '../../common/input/check/check.component'
|
||||
@@ -32,7 +32,9 @@ describe('SavedViewsComponent', () => {
|
||||
let component: SavedViewsComponent
|
||||
let fixture: ComponentFixture<SavedViewsComponent>
|
||||
let savedViewService: SavedViewService
|
||||
let settingsService: SettingsService
|
||||
let toastService: ToastService
|
||||
let modalService: NgbModal
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -57,6 +59,8 @@ describe('SavedViewsComponent', () => {
|
||||
provide: PermissionsService,
|
||||
useValue: {
|
||||
currentUserCan: () => true,
|
||||
currentUserHasObjectPermissions: () => true,
|
||||
currentUserOwnsObject: () => true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -77,11 +81,13 @@ describe('SavedViewsComponent', () => {
|
||||
}).compileComponents()
|
||||
|
||||
savedViewService = TestBed.inject(SavedViewService)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
fixture = TestBed.createComponent(SavedViewsComponent)
|
||||
component = fixture.componentInstance
|
||||
|
||||
jest.spyOn(savedViewService, 'listAll').mockReturnValue(
|
||||
jest.spyOn(savedViewService, 'list').mockReturnValue(
|
||||
of({
|
||||
all: savedViews.map((v) => v.id),
|
||||
count: savedViews.length,
|
||||
@@ -94,14 +100,13 @@ describe('SavedViewsComponent', () => {
|
||||
|
||||
it('should support save saved views, show error', () => {
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
const savedViewPatchSpy = jest.spyOn(savedViewService, 'patchMany')
|
||||
|
||||
const toggle = fixture.debugElement.query(
|
||||
By.css('.form-check.form-switch input')
|
||||
)
|
||||
toggle.nativeElement.checked = true
|
||||
toggle.nativeElement.dispatchEvent(new Event('change'))
|
||||
const control = component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(savedViews[0].id.toString())
|
||||
.get('name')
|
||||
control.setValue(`${savedViews[0].name}-changed`)
|
||||
control.markAsDirty()
|
||||
|
||||
// saved views error first
|
||||
savedViewPatchSpy.mockReturnValueOnce(
|
||||
@@ -110,12 +115,13 @@ describe('SavedViewsComponent', () => {
|
||||
component.save()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(savedViewPatchSpy).toHaveBeenCalled()
|
||||
toastSpy.mockClear()
|
||||
toastErrorSpy.mockClear()
|
||||
savedViewPatchSpy.mockClear()
|
||||
|
||||
// succeed saved views
|
||||
savedViewPatchSpy.mockReturnValueOnce(of(savedViews as SavedView[]))
|
||||
control.setValue(savedViews[0].name)
|
||||
control.markAsDirty()
|
||||
component.save()
|
||||
expect(toastErrorSpy).not.toHaveBeenCalled()
|
||||
expect(savedViewPatchSpy).toHaveBeenCalled()
|
||||
@@ -127,26 +133,46 @@ describe('SavedViewsComponent', () => {
|
||||
expect(patchSpy).not.toHaveBeenCalled()
|
||||
|
||||
const view = savedViews[0]
|
||||
const toggle = fixture.debugElement.query(
|
||||
By.css('.form-check.form-switch input')
|
||||
)
|
||||
toggle.nativeElement.checked = true
|
||||
toggle.nativeElement.dispatchEvent(new Event('change'))
|
||||
// register change
|
||||
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
|
||||
'show_on_dashboard'
|
||||
] = !view.show_on_dashboard
|
||||
component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(view.id.toString())
|
||||
.get('name')
|
||||
.setValue('changed-view-name')
|
||||
component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(view.id.toString())
|
||||
.get('name')
|
||||
.markAsDirty()
|
||||
fixture.detectChanges()
|
||||
|
||||
component.save()
|
||||
expect(patchSpy).toHaveBeenCalledWith([
|
||||
{
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
show_in_sidebar: view.show_in_sidebar,
|
||||
show_on_dashboard: !view.show_on_dashboard,
|
||||
},
|
||||
])
|
||||
expect(patchSpy).toHaveBeenCalled()
|
||||
const patchBody = patchSpy.mock.calls[0][0][0]
|
||||
expect(patchBody).toMatchObject({
|
||||
id: view.id,
|
||||
name: 'changed-view-name',
|
||||
})
|
||||
expect(patchBody.show_on_dashboard).toBeUndefined()
|
||||
expect(patchBody.show_in_sidebar).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should persist visibility changes to user settings', () => {
|
||||
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
|
||||
const updateVisibilitySpy = jest
|
||||
.spyOn(settingsService, 'updateSavedViewsVisibility')
|
||||
.mockReturnValue(of({ success: true }))
|
||||
|
||||
const dashboardControl = component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(savedViews[0].id.toString())
|
||||
.get('show_on_dashboard')
|
||||
dashboardControl.setValue(false)
|
||||
dashboardControl.markAsDirty()
|
||||
|
||||
component.save()
|
||||
|
||||
expect(patchSpy).not.toHaveBeenCalled()
|
||||
expect(updateVisibilitySpy).toHaveBeenCalledWith([], [savedViews[0].id])
|
||||
})
|
||||
|
||||
it('should support delete saved view', () => {
|
||||
@@ -162,14 +188,55 @@ describe('SavedViewsComponent', () => {
|
||||
|
||||
it('should support reset', () => {
|
||||
const view = savedViews[0]
|
||||
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
|
||||
'show_on_dashboard'
|
||||
] = !view.show_on_dashboard
|
||||
component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(view.id.toString())
|
||||
.get('show_on_dashboard')
|
||||
.setValue(!view.show_on_dashboard)
|
||||
component.reset()
|
||||
expect(
|
||||
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
|
||||
'show_on_dashboard'
|
||||
]
|
||||
component.savedViewsForm
|
||||
.get('savedViews')
|
||||
.get(view.id.toString())
|
||||
.get('show_on_dashboard').value
|
||||
).toEqual(view.show_on_dashboard)
|
||||
})
|
||||
|
||||
it('should support editing permissions', () => {
|
||||
const confirmClicked = new Subject<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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,11 +6,17 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||
import { BehaviorSubject, Observable, takeUntil } from 'rxjs'
|
||||
import { BehaviorSubject, Observable, of, switchMap, takeUntil } from 'rxjs'
|
||||
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
|
||||
import { DisplayMode } from 'src/app/data/document'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
@@ -41,8 +47,10 @@ export class SavedViewsComponent
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private permissionsService = inject(PermissionsService)
|
||||
private settings = inject(SettingsService)
|
||||
private toastService = inject(ToastService)
|
||||
private modalService = inject(NgbModal)
|
||||
|
||||
DisplayMode = DisplayMode
|
||||
|
||||
@@ -65,11 +73,17 @@ export class SavedViewsComponent
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reloadViews()
|
||||
}
|
||||
|
||||
private reloadViews(): void {
|
||||
this.loading = true
|
||||
this.savedViewService.listAll().subscribe((r) => {
|
||||
this.savedViews = r.results
|
||||
this.initialize()
|
||||
})
|
||||
this.savedViewService
|
||||
.listAll(null, null, { full_perms: true })
|
||||
.subscribe((r) => {
|
||||
this.savedViews = r.results
|
||||
this.initialize()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -95,16 +109,20 @@ export class SavedViewsComponent
|
||||
display_mode: view.display_mode,
|
||||
display_fields: view.display_fields,
|
||||
}
|
||||
const canEdit = this.canEditSavedView(view)
|
||||
this.savedViewsGroup.addControl(
|
||||
view.id.toString(),
|
||||
new FormGroup({
|
||||
id: new FormControl(null),
|
||||
name: new FormControl(null),
|
||||
show_on_dashboard: new FormControl(null),
|
||||
show_in_sidebar: new FormControl(null),
|
||||
page_size: new FormControl(null),
|
||||
display_mode: new FormControl(null),
|
||||
display_fields: new FormControl([]),
|
||||
id: new FormControl({ value: null, disabled: !canEdit }),
|
||||
name: new FormControl({ value: null, disabled: !canEdit }),
|
||||
show_on_dashboard: new FormControl({
|
||||
value: null,
|
||||
disabled: false,
|
||||
}),
|
||||
show_in_sidebar: new FormControl({ value: null, disabled: false }),
|
||||
page_size: new FormControl({ value: null, disabled: !canEdit }),
|
||||
display_mode: new FormControl({ value: null, disabled: !canEdit }),
|
||||
display_fields: new FormControl({ value: [], disabled: !canEdit }),
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -133,10 +151,7 @@ export class SavedViewsComponent
|
||||
$localize`Saved view "${savedView.name}" deleted.`
|
||||
)
|
||||
this.savedViewService.clearCache()
|
||||
this.savedViewService.listAll().subscribe((r) => {
|
||||
this.savedViews = r.results
|
||||
this.initialize()
|
||||
})
|
||||
this.reloadViews()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,26 +160,119 @@ export class SavedViewsComponent
|
||||
}
|
||||
|
||||
public save() {
|
||||
// only patch views that have actually changed
|
||||
// Save only changed views, then save the visibility changes into user settings.
|
||||
const groups = Object.values(this.savedViewsGroup.controls) as FormGroup[]
|
||||
const visibilityChanged = groups.some(
|
||||
(group) =>
|
||||
group.get('show_on_dashboard')?.dirty ||
|
||||
group.get('show_in_sidebar')?.dirty
|
||||
)
|
||||
|
||||
const changed: SavedView[] = []
|
||||
Object.values(this.savedViewsGroup.controls)
|
||||
.filter((g: FormGroup) => !g.pristine)
|
||||
.forEach((group: FormGroup) => {
|
||||
changed.push(group.value)
|
||||
})
|
||||
const dashboardVisibleIds: number[] = []
|
||||
const sidebarVisibleIds: number[] = []
|
||||
|
||||
groups.forEach((group) => {
|
||||
const value = group.getRawValue()
|
||||
if (value.show_on_dashboard) {
|
||||
dashboardVisibleIds.push(value.id)
|
||||
}
|
||||
if (value.show_in_sidebar) {
|
||||
sidebarVisibleIds.push(value.id)
|
||||
}
|
||||
// Would be fine to send, but no longer stored on the model
|
||||
delete value.show_on_dashboard
|
||||
delete value.show_in_sidebar
|
||||
|
||||
if (!group.get('name')?.enabled) {
|
||||
// Quick check for user doesn't have permissions, then bail
|
||||
return
|
||||
}
|
||||
|
||||
const modelFieldsChanged =
|
||||
group.get('name')?.dirty ||
|
||||
group.get('page_size')?.dirty ||
|
||||
group.get('display_mode')?.dirty ||
|
||||
group.get('display_fields')?.dirty
|
||||
|
||||
if (!modelFieldsChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
changed.push(value)
|
||||
})
|
||||
|
||||
if (!changed.length && !visibilityChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
let saveOperation = of([])
|
||||
if (changed.length) {
|
||||
this.savedViewService.patchMany(changed).subscribe({
|
||||
saveOperation = saveOperation.pipe(
|
||||
switchMap(() => this.savedViewService.patchMany(changed))
|
||||
)
|
||||
}
|
||||
if (visibilityChanged) {
|
||||
saveOperation = saveOperation.pipe(
|
||||
switchMap(() =>
|
||||
this.settings.updateSavedViewsVisibility(
|
||||
dashboardVisibleIds,
|
||||
sidebarVisibleIds
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
saveOperation.subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo($localize`Views saved successfully.`)
|
||||
this.savedViewService.clearCache()
|
||||
this.reloadViews()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error while saving views.`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public canEditSavedView(view: SavedView): boolean {
|
||||
return this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
view
|
||||
)
|
||||
}
|
||||
|
||||
public canDeleteSavedView(view: SavedView): boolean {
|
||||
return this.permissionsService.currentUserOwnsObject(view)
|
||||
}
|
||||
|
||||
public editPermissions(savedView: SavedView): void {
|
||||
const modal = this.modalService.open(PermissionsDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
const dialog = modal.componentInstance as PermissionsDialogComponent
|
||||
dialog.object = savedView
|
||||
|
||||
modal.componentInstance.confirmClicked.subscribe(({ permissions }) => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
const view = {
|
||||
id: savedView.id,
|
||||
owner: permissions.owner,
|
||||
}
|
||||
view['set_permissions'] = permissions.set_permissions
|
||||
this.savedViewService.patch(view as SavedView).subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo($localize`Views saved successfully.`)
|
||||
this.store.next(this.savedViewsForm.value)
|
||||
this.toastService.showInfo($localize`Permissions updated`)
|
||||
modal.close()
|
||||
this.reloadViews()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error while saving views.`,
|
||||
$localize`Error updating permissions`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,15 +128,15 @@ export interface Document extends ObjectWithPermissions {
|
||||
checksum?: string
|
||||
|
||||
// UTC
|
||||
created?: string // ISO string
|
||||
created?: Date
|
||||
|
||||
modified?: string // ISO string
|
||||
modified?: Date
|
||||
|
||||
added?: string // ISO string
|
||||
added?: Date
|
||||
|
||||
mime_type?: string
|
||||
|
||||
deleted_at?: string // ISO string
|
||||
deleted_at?: Date
|
||||
|
||||
original_file_name?: string
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ export const SETTINGS_KEYS = {
|
||||
'general-settings:update-checking:backend-setting',
|
||||
SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE:
|
||||
'general-settings:saved-views:warn-on-unsaved-change',
|
||||
DASHBOARD_VIEWS_VISIBLE_IDS:
|
||||
'general-settings:saved-views:dashboard-views-visible-ids',
|
||||
SIDEBAR_VIEWS_VISIBLE_IDS:
|
||||
'general-settings:saved-views:sidebar-views-visible-ids',
|
||||
DASHBOARD_VIEWS_SORT_ORDER:
|
||||
'general-settings:saved-views:dashboard-views-sort-order',
|
||||
SIDEBAR_VIEWS_SORT_ORDER:
|
||||
@@ -248,6 +252,16 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS,
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS,
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER,
|
||||
type: 'array',
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface WebsocketDocumentUpdatedMessage {
|
||||
document_id: number
|
||||
modified?: string
|
||||
owner_id?: number
|
||||
users_can_view?: number[]
|
||||
groups_can_view?: number[]
|
||||
}
|
||||
@@ -57,6 +57,11 @@ describe(`Additional service tests for SavedViewService`, () => {
|
||||
let settingsService
|
||||
|
||||
it('should retrieve saved views and sort them', () => {
|
||||
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS) return [1, 2, 3]
|
||||
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS) return [1, 2, 3]
|
||||
return []
|
||||
})
|
||||
service.reload()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
|
||||
@@ -93,7 +98,9 @@ describe(`Additional service tests for SavedViewService`, () => {
|
||||
it('should sort dashboard views', () => {
|
||||
service['savedViews'] = saved_views
|
||||
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS) return [1, 2, 3]
|
||||
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return [3, 1, 2]
|
||||
return []
|
||||
})
|
||||
expect(service.dashboardViews).toEqual([
|
||||
saved_views[2],
|
||||
@@ -102,10 +109,21 @@ describe(`Additional service tests for SavedViewService`, () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should use user-specific dashboard visibility when configured', () => {
|
||||
service['savedViews'] = saved_views
|
||||
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS) return [4, 2]
|
||||
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return []
|
||||
})
|
||||
expect(service.dashboardViews).toEqual([saved_views[1], saved_views[3]])
|
||||
})
|
||||
|
||||
it('should sort sidebar views', () => {
|
||||
service['savedViews'] = saved_views
|
||||
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS) return [1, 2, 3]
|
||||
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER) return [3, 1, 2]
|
||||
return []
|
||||
})
|
||||
expect(service.sidebarViews).toEqual([
|
||||
saved_views[2],
|
||||
@@ -114,6 +132,15 @@ describe(`Additional service tests for SavedViewService`, () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should use user-specific sidebar visibility when configured', () => {
|
||||
service['savedViews'] = saved_views
|
||||
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS) return [4, 2]
|
||||
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER) return []
|
||||
})
|
||||
expect(service.sidebarViews).toEqual([saved_views[1], saved_views[3]])
|
||||
})
|
||||
|
||||
it('should treat empty display_fields as null', () => {
|
||||
subscription = service
|
||||
.patch({
|
||||
|
||||
@@ -36,7 +36,9 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
|
||||
return super.list(page, pageSize, sortField, sortReverse, extraParams).pipe(
|
||||
tap({
|
||||
next: (r) => {
|
||||
this.savedViews = r.results
|
||||
const views = r.results.map((view) => this.withUserVisibility(view))
|
||||
this.savedViews = views
|
||||
r.results = views
|
||||
this._loading = false
|
||||
this.settingsService.dashboardIsEmpty =
|
||||
this.dashboardViews.length === 0
|
||||
@@ -65,8 +67,35 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
|
||||
return this.savedViews
|
||||
}
|
||||
|
||||
private getVisibleViewIds(setting: string): number[] {
|
||||
const configured = this.settingsService.get(setting)
|
||||
return Array.isArray(configured) ? configured : []
|
||||
}
|
||||
|
||||
private withUserVisibility(view: SavedView): SavedView {
|
||||
return {
|
||||
...view,
|
||||
show_on_dashboard: this.isDashboardVisible(view),
|
||||
show_in_sidebar: this.isSidebarVisible(view),
|
||||
}
|
||||
}
|
||||
|
||||
private isDashboardVisible(view: SavedView): boolean {
|
||||
const visibleIds = this.getVisibleViewIds(
|
||||
SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS
|
||||
)
|
||||
return visibleIds.includes(view.id)
|
||||
}
|
||||
|
||||
private isSidebarVisible(view: SavedView): boolean {
|
||||
const visibleIds = this.getVisibleViewIds(
|
||||
SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS
|
||||
)
|
||||
return visibleIds.includes(view.id)
|
||||
}
|
||||
|
||||
get sidebarViews(): SavedView[] {
|
||||
const sidebarViews = this.savedViews.filter((v) => v.show_in_sidebar)
|
||||
const sidebarViews = this.savedViews.filter((v) => this.isSidebarVisible(v))
|
||||
|
||||
const sorted: number[] = this.settingsService.get(
|
||||
SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER
|
||||
@@ -81,7 +110,9 @@ export class SavedViewService extends AbstractPaperlessService<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(
|
||||
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER
|
||||
|
||||
@@ -320,7 +320,7 @@ describe('SettingsService', () => {
|
||||
expect(req.request.method).toEqual('POST')
|
||||
})
|
||||
|
||||
it('should update saved view sorting', () => {
|
||||
it('should update saved view sorting and visibility', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
.flush(ui_settings)
|
||||
@@ -341,6 +341,15 @@ describe('SettingsService', () => {
|
||||
SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER,
|
||||
[1, 4]
|
||||
)
|
||||
settingsService.updateSavedViewsVisibility([1, 4], [4, 1])
|
||||
expect(setSpy).toHaveBeenCalledWith(
|
||||
SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS,
|
||||
[1, 4]
|
||||
)
|
||||
expect(setSpy).toHaveBeenCalledWith(
|
||||
SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS,
|
||||
[4, 1]
|
||||
)
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
.flush(ui_settings)
|
||||
|
||||
@@ -699,4 +699,17 @@ export class SettingsService {
|
||||
])
|
||||
return this.storeSettings()
|
||||
}
|
||||
|
||||
updateSavedViewsVisibility(
|
||||
dashboardVisibleViewIds: number[],
|
||||
sidebarVisibleViewIds: number[]
|
||||
): Observable<any> {
|
||||
this.set(SETTINGS_KEYS.DASHBOARD_VIEWS_VISIBLE_IDS, [
|
||||
...new Set(dashboardVisibleViewIds),
|
||||
])
|
||||
this.set(SETTINGS_KEYS.SIDEBAR_VIEWS_VISIBLE_IDS, [
|
||||
...new Set(sidebarVisibleViewIds),
|
||||
])
|
||||
return this.storeSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,25 +416,4 @@ describe('ConsumerStatusService', () => {
|
||||
websocketStatusService.disconnect()
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Injectable, inject } from '@angular/core'
|
||||
import { Subject } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { User } from '../data/user'
|
||||
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
|
||||
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
|
||||
import { WebsocketProgressMessage } from '../data/websocket-progress-message'
|
||||
import { SettingsService } from './settings.service'
|
||||
@@ -10,7 +9,6 @@ import { SettingsService } from './settings.service'
|
||||
export enum WebsocketStatusType {
|
||||
STATUS_UPDATE = 'status_update',
|
||||
DOCUMENTS_DELETED = 'documents_deleted',
|
||||
DOCUMENT_UPDATED = 'document_updated',
|
||||
}
|
||||
|
||||
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
||||
@@ -105,8 +103,6 @@ export class WebsocketStatusService {
|
||||
private documentConsumptionFinishedSubject = new Subject<FileStatus>()
|
||||
private documentConsumptionFailedSubject = new Subject<FileStatus>()
|
||||
private documentDeletedSubject = new Subject<boolean>()
|
||||
private documentUpdatedSubject =
|
||||
new Subject<WebsocketDocumentUpdatedMessage>()
|
||||
private connectionStatusSubject = new Subject<boolean>()
|
||||
|
||||
private get(taskId: string, filename?: string) {
|
||||
@@ -173,10 +169,7 @@ export class WebsocketStatusService {
|
||||
data: messageData,
|
||||
}: {
|
||||
type: WebsocketStatusType
|
||||
data:
|
||||
| WebsocketProgressMessage
|
||||
| WebsocketDocumentsDeletedMessage
|
||||
| WebsocketDocumentUpdatedMessage
|
||||
data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage
|
||||
} = JSON.parse(ev.data)
|
||||
|
||||
switch (type) {
|
||||
@@ -184,12 +177,6 @@ export class WebsocketStatusService {
|
||||
this.documentDeletedSubject.next(true)
|
||||
break
|
||||
|
||||
case WebsocketStatusType.DOCUMENT_UPDATED:
|
||||
this.handleDocumentUpdated(
|
||||
messageData as WebsocketDocumentUpdatedMessage
|
||||
)
|
||||
break
|
||||
|
||||
case WebsocketStatusType.STATUS_UPDATE:
|
||||
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
||||
break
|
||||
@@ -197,11 +184,7 @@ export class WebsocketStatusService {
|
||||
}
|
||||
}
|
||||
|
||||
private canViewMessage(messageData: {
|
||||
owner_id?: number
|
||||
users_can_view?: number[]
|
||||
groups_can_view?: number[]
|
||||
}): boolean {
|
||||
private canViewMessage(messageData: WebsocketProgressMessage): boolean {
|
||||
// see paperless.consumers.StatusConsumer._can_view
|
||||
const user: User = this.settingsService.currentUser
|
||||
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) {
|
||||
status.message = message
|
||||
status.phase = FileStatusPhase.FAILED
|
||||
@@ -323,10 +297,6 @@ export class WebsocketStatusService {
|
||||
return this.documentDeletedSubject
|
||||
}
|
||||
|
||||
onDocumentUpdated() {
|
||||
return this.documentUpdatedSubject
|
||||
}
|
||||
|
||||
onConnectionStatus() {
|
||||
return this.connectionStatusSubject.asObservable()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI)
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: document.baseURI + 'api/',
|
||||
apiVersion: '9', // match src/paperless/settings.py
|
||||
apiVersion: '10', // match src/paperless/settings.py
|
||||
appTitle: 'Paperless-ngx',
|
||||
tag: 'prod',
|
||||
version: '2.20.7',
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -15,7 +15,6 @@ class DocumentsConfig(AppConfig):
|
||||
from documents.signals.handlers import add_to_index
|
||||
from documents.signals.handlers import run_workflows_added
|
||||
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_document_type
|
||||
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(add_or_update_document_in_llm_index)
|
||||
document_updated.connect(run_workflows_updated)
|
||||
document_updated.connect(send_websocket_document_updated)
|
||||
|
||||
import documents.schema # noqa: F401
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -443,13 +443,6 @@ class SavedView(ModelWithOwner):
|
||||
|
||||
name = models.CharField(_("name"), max_length=128)
|
||||
|
||||
show_on_dashboard = models.BooleanField(
|
||||
_("show on dashboard"),
|
||||
)
|
||||
show_in_sidebar = models.BooleanField(
|
||||
_("show in sidebar"),
|
||||
)
|
||||
|
||||
sort_field = models.CharField(
|
||||
_("sort field"),
|
||||
max_length=128,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import enum
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
@@ -48,7 +47,7 @@ class BaseStatusManager:
|
||||
async_to_sync(self._channel.flush)
|
||||
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
|
||||
self.open()
|
||||
|
||||
@@ -74,28 +73,26 @@ class ProgressManager(BaseStatusManager):
|
||||
max_progress: int,
|
||||
extra_args: dict[str, str | int | None] | None = None,
|
||||
) -> None:
|
||||
data: dict[str, object] = {
|
||||
"filename": self.filename,
|
||||
"task_id": self.task_id,
|
||||
"current_progress": current_progress,
|
||||
"max_progress": max_progress,
|
||||
"status": status,
|
||||
"message": message,
|
||||
payload = {
|
||||
"type": "status_update",
|
||||
"data": {
|
||||
"filename": self.filename,
|
||||
"task_id": self.task_id,
|
||||
"current_progress": current_progress,
|
||||
"max_progress": max_progress,
|
||||
"status": status,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
if extra_args is not None:
|
||||
data.update(extra_args)
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"type": "status_update",
|
||||
"data": data,
|
||||
}
|
||||
payload["data"].update(extra_args)
|
||||
|
||||
self.send(payload)
|
||||
|
||||
|
||||
class DocumentsStatusManager(BaseStatusManager):
|
||||
def send_documents_deleted(self, documents: list[int]) -> None:
|
||||
payload: dict[str, object] = {
|
||||
payload = {
|
||||
"type": "documents_deleted",
|
||||
"data": {
|
||||
"documents": documents,
|
||||
@@ -103,25 +100,3 @@ class DocumentsStatusManager(BaseStatusManager):
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -1383,8 +1383,6 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"show_on_dashboard",
|
||||
"show_in_sidebar",
|
||||
"sort_field",
|
||||
"sort_reverse",
|
||||
"filter_rules",
|
||||
@@ -1394,6 +1392,7 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
||||
"owner",
|
||||
"permissions",
|
||||
"user_can_change",
|
||||
"set_permissions",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
@@ -1431,7 +1430,7 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
||||
and len(validated_data["display_fields"]) == 0
|
||||
):
|
||||
validated_data["display_fields"] = None
|
||||
super().update(instance, validated_data)
|
||||
instance = super().update(instance, validated_data)
|
||||
if rules_data is not None:
|
||||
SavedViewFilterRule.objects.filter(saved_view=instance).delete()
|
||||
for rule_data in rules_data:
|
||||
@@ -1443,7 +1442,7 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
||||
if "user" in validated_data:
|
||||
# backwards compatibility
|
||||
validated_data["owner"] = validated_data.pop("user")
|
||||
saved_view = SavedView.objects.create(**validated_data)
|
||||
saved_view = super().create(validated_data)
|
||||
for rule_data in rules_data:
|
||||
SavedViewFilterRule.objects.create(saved_view=saved_view, **rule_data)
|
||||
return saved_view
|
||||
|
||||
@@ -4,7 +4,6 @@ import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from celery import shared_task
|
||||
from celery import states
|
||||
@@ -24,7 +23,6 @@ from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
from filelock import FileLock
|
||||
from rest_framework import serializers
|
||||
|
||||
from documents import matching
|
||||
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 WorkflowTrigger
|
||||
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.workflows.actions import build_workflow_action_context
|
||||
from documents.workflows.actions import execute_email_action
|
||||
@@ -66,7 +63,6 @@ if TYPE_CHECKING:
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
|
||||
logger = logging.getLogger("paperless.handlers")
|
||||
DRF_DATETIME_FIELD = serializers.DateTimeField()
|
||||
|
||||
|
||||
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(
|
||||
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
||||
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)
|
||||
def delete_document_from_llm_index(
|
||||
sender: Any,
|
||||
instance: Document,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
def delete_document_from_llm_index(sender, instance: Document, **kwargs):
|
||||
"""
|
||||
Delete a document from the LLM index when it is deleted.
|
||||
"""
|
||||
|
||||
@@ -60,7 +60,6 @@ from documents.sanity_checker import SanityCheckFailedException
|
||||
from documents.signals import document_updated
|
||||
from documents.signals.handlers import cleanup_document_deletion
|
||||
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 paperless.config import AIConfig
|
||||
from paperless_ai.indexing import llm_index_add_or_update_document
|
||||
@@ -535,11 +534,6 @@ def check_scheduled_workflows() -> None:
|
||||
workflow_to_run=workflow,
|
||||
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:
|
||||
|
||||
@@ -1206,11 +1206,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
input_doc, overrides = self.get_last_consume_delay_call_args()
|
||||
|
||||
self.assertEqual(input_doc.original_file.name, "simple.pdf")
|
||||
self.assertTrue(
|
||||
input_doc.original_file.resolve(strict=False).is_relative_to(
|
||||
Path(settings.SCRATCH_DIR).resolve(strict=False),
|
||||
),
|
||||
)
|
||||
self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents)
|
||||
self.assertIsNone(overrides.title)
|
||||
self.assertIsNone(overrides.correspondent_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()
|
||||
|
||||
self.assertEqual(input_doc.original_file.name, "simple.pdf")
|
||||
self.assertTrue(
|
||||
input_doc.original_file.resolve(strict=False).is_relative_to(
|
||||
Path(settings.SCRATCH_DIR).resolve(strict=False),
|
||||
),
|
||||
)
|
||||
self.assertIn(Path(settings.SCRATCH_DIR), input_doc.original_file.parents)
|
||||
self.assertIsNone(overrides.title)
|
||||
self.assertIsNone(overrides.correspondent_id)
|
||||
self.assertIsNone(overrides.document_type_id)
|
||||
@@ -2022,69 +2014,93 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
mock_get_date_parser.assert_not_called()
|
||||
|
||||
def test_saved_views(self) -> None:
|
||||
u1 = User.objects.create_superuser("user1")
|
||||
u2 = User.objects.create_superuser("user2")
|
||||
u1 = User.objects.create_user("user1")
|
||||
u2 = User.objects.create_user("user2")
|
||||
u3 = User.objects.create_user("user3")
|
||||
|
||||
view_perm = Permission.objects.get(codename="view_savedview")
|
||||
change_perm = Permission.objects.get(codename="change_savedview")
|
||||
for user in [u1, u2, u3]:
|
||||
user.user_permissions.add(view_perm, change_perm)
|
||||
|
||||
v1 = SavedView.objects.create(
|
||||
owner=u1,
|
||||
name="test1",
|
||||
sort_field="",
|
||||
show_on_dashboard=False,
|
||||
show_in_sidebar=False,
|
||||
)
|
||||
SavedView.objects.create(
|
||||
v2 = SavedView.objects.create(
|
||||
owner=u2,
|
||||
name="test2",
|
||||
sort_field="",
|
||||
show_on_dashboard=False,
|
||||
show_in_sidebar=False,
|
||||
)
|
||||
SavedView.objects.create(
|
||||
v3 = SavedView.objects.create(
|
||||
owner=u2,
|
||||
name="test3",
|
||||
sort_field="",
|
||||
show_on_dashboard=False,
|
||||
show_in_sidebar=False,
|
||||
)
|
||||
|
||||
response = self.client.get("/api/saved_views/")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 0)
|
||||
|
||||
self.assertEqual(
|
||||
self.client.get(f"/api/saved_views/{v1.id}/").status_code,
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
assign_perm("view_savedview", u1, v2)
|
||||
assign_perm("change_savedview", u1, v2)
|
||||
assign_perm("view_savedview", u1, v3)
|
||||
|
||||
self.client.force_authenticate(user=u1)
|
||||
|
||||
response = self.client.get("/api/saved_views/")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
self.assertEqual(response.data["count"], 3)
|
||||
|
||||
for view_id in [v1.id, v2.id, v3.id]:
|
||||
self.assertEqual(
|
||||
self.client.get(f"/api/saved_views/{view_id}/").status_code,
|
||||
status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/saved_views/{v2.id}/",
|
||||
{"sort_field": "added"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/saved_views/{v3.id}/",
|
||||
{"sort_field": "added"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.get(f"/api/saved_views/{v1.id}/").status_code,
|
||||
status.HTTP_200_OK,
|
||||
response.status_code,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user=u2)
|
||||
response = self.client.patch(
|
||||
f"/api/saved_views/{v2.id}/",
|
||||
{
|
||||
"set_permissions": {
|
||||
"view": {"users": [u3.id]},
|
||||
},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/saved_views/{v2.id}/",
|
||||
{"owner": u1.id},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
self.client.force_authenticate(user=u3)
|
||||
|
||||
response = self.client.get("/api/saved_views/")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 2)
|
||||
|
||||
self.assertEqual(
|
||||
self.client.get(f"/api/saved_views/{v1.id}/").status_code,
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
self.assertEqual(response.data["count"], 0)
|
||||
|
||||
def test_saved_view_create_update_patch(self) -> None:
|
||||
User.objects.create_user("user1")
|
||||
|
||||
view = {
|
||||
"name": "test",
|
||||
"show_on_dashboard": True,
|
||||
"show_in_sidebar": True,
|
||||
"sort_field": "created2",
|
||||
"filter_rules": [{"rule_type": 4, "value": "test"}],
|
||||
}
|
||||
@@ -2099,13 +2115,13 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/saved_views/{v1.id}/",
|
||||
{"show_in_sidebar": False},
|
||||
{"sort_reverse": True},
|
||||
format="json",
|
||||
)
|
||||
|
||||
v1 = SavedView.objects.get(id=v1.id)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertFalse(v1.show_in_sidebar)
|
||||
self.assertTrue(v1.sort_reverse)
|
||||
self.assertEqual(v1.filter_rules.count(), 1)
|
||||
|
||||
view["filter_rules"] = [{"rule_type": 12, "value": "secret"}]
|
||||
@@ -2139,8 +2155,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
|
||||
view = {
|
||||
"name": "test",
|
||||
"show_on_dashboard": True,
|
||||
"show_in_sidebar": True,
|
||||
"sort_field": "created2",
|
||||
"filter_rules": [{"rule_type": 4, "value": "test"}],
|
||||
"page_size": 20,
|
||||
@@ -2228,8 +2242,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
"""
|
||||
view = {
|
||||
"name": "test",
|
||||
"show_on_dashboard": True,
|
||||
"show_in_sidebar": True,
|
||||
"sort_field": "created2",
|
||||
"filter_rules": [{"rule_type": 4, "value": "test"}],
|
||||
"page_size": 20,
|
||||
@@ -2305,8 +2317,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
owner=self.user,
|
||||
name="test",
|
||||
sort_field=SavedView.DisplayFields.CUSTOM_FIELD % custom_field.id,
|
||||
show_on_dashboard=True,
|
||||
show_in_sidebar=True,
|
||||
display_fields=[
|
||||
SavedView.DisplayFields.TITLE,
|
||||
SavedView.DisplayFields.CREATED,
|
||||
|
||||
@@ -1307,13 +1307,12 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
tag1 = Tag.objects.create(name="bank tag1")
|
||||
Tag.objects.create(name="tag2")
|
||||
|
||||
SavedView.objects.create(
|
||||
shared_view = SavedView.objects.create(
|
||||
name="bank view",
|
||||
show_on_dashboard=True,
|
||||
show_in_sidebar=True,
|
||||
sort_field="",
|
||||
owner=user1,
|
||||
owner=user2,
|
||||
)
|
||||
assign_perm("view_savedview", user1, shared_view)
|
||||
mail_account1 = MailAccount.objects.create(name="bank mail account 1")
|
||||
mail_account2 = MailAccount.objects.create(name="mail account 2")
|
||||
mail_rule1 = MailRule.objects.create(
|
||||
|
||||
175
src/documents/tests/test_migration_saved_view_visibility.py
Normal file
175
src/documents/tests/test_migration_saved_view_visibility.py
Normal 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)
|
||||
@@ -3,7 +3,6 @@ import json
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -641,9 +640,7 @@ class TestWorkflows(
|
||||
|
||||
expected_str = f"Document did not match {w}"
|
||||
self.assertIn(expected_str, cm.output[0])
|
||||
expected_str = (
|
||||
f"Document path {Path(test_file).resolve(strict=False)} does not match"
|
||||
)
|
||||
expected_str = f"Document path {test_file} does not match"
|
||||
self.assertIn(expected_str, cm.output[1])
|
||||
|
||||
def test_workflow_no_match_mail_rule(self) -> None:
|
||||
@@ -1968,36 +1965,6 @@ class TestWorkflows(
|
||||
doc.refresh_from_db()
|
||||
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:
|
||||
"""
|
||||
GIVEN:
|
||||
@@ -4136,7 +4103,7 @@ class TestWebhookSecurity:
|
||||
def test_strips_user_supplied_host_header(
|
||||
self,
|
||||
httpx_mock: HTTPXMock,
|
||||
resolve_to: Callable[[str], None],
|
||||
resolve_to,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -1660,24 +1660,21 @@ class LogViewSet(ViewSet):
|
||||
return Response(existing_logs)
|
||||
|
||||
|
||||
class SavedViewViewSet(ModelViewSet, PassUserMixin):
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(SavedViewSerializer))
|
||||
class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet):
|
||||
model = SavedView
|
||||
|
||||
queryset = SavedView.objects.all()
|
||||
queryset = SavedView.objects.select_related("owner").prefetch_related(
|
||||
"filter_rules",
|
||||
)
|
||||
serializer_class = SavedViewSerializer
|
||||
pagination_class = StandardPagination
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
return (
|
||||
SavedView.objects.filter(owner=user)
|
||||
.select_related("owner")
|
||||
.prefetch_related("filter_rules")
|
||||
)
|
||||
|
||||
def perform_create(self, serializer) -> None:
|
||||
serializer.save(owner=self.request.user)
|
||||
filter_backends = (
|
||||
OrderingFilter,
|
||||
ObjectOwnedOrGrantedPermissionsFilter,
|
||||
)
|
||||
ordering_fields = ("name",)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -2201,7 +2198,11 @@ class GlobalSearchView(PassUserMixin):
|
||||
)
|
||||
docs = docs[:OBJECT_LIMIT]
|
||||
saved_views = (
|
||||
SavedView.objects.filter(owner=request.user, name__icontains=query)
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_savedview",
|
||||
SavedView,
|
||||
).filter(name__icontains=query)
|
||||
if request.user.has_perm("documents.view_savedview")
|
||||
else []
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.exceptions import AcceptConnection
|
||||
@@ -53,10 +52,3 @@ class StatusConsumer(WebsocketConsumer):
|
||||
self.close()
|
||||
else:
|
||||
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))
|
||||
|
||||
@@ -374,10 +374,10 @@ REST_FRAMEWORK = {
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
],
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning",
|
||||
"DEFAULT_VERSION": "9", # match src-ui/src/environments/environment.prod.ts
|
||||
"DEFAULT_VERSION": "10", # match src-ui/src/environments/environment.prod.ts
|
||||
# Make sure these are ordered and that the most recent version appears
|
||||
# last. See api.md#api-versioning when adding new versions.
|
||||
"ALLOWED_VERSIONS": ["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
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
}
|
||||
|
||||
@@ -158,40 +158,6 @@ class TestWebSockets(TestCase):
|
||||
|
||||
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")
|
||||
def test_manager_send_progress(self, mock_group_send) -> None:
|
||||
with ProgressManager(task_id="test") as manager:
|
||||
@@ -224,10 +190,7 @@ class TestWebSockets(TestCase):
|
||||
)
|
||||
|
||||
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
|
||||
def test_manager_send_documents_deleted(
|
||||
self,
|
||||
mock_group_send: mock.MagicMock,
|
||||
) -> None:
|
||||
def test_manager_send_documents_deleted(self, mock_group_send) -> None:
|
||||
with DocumentsStatusManager() as manager:
|
||||
manager.send_documents_deleted([1, 2, 3])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user