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