diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index c6ae846be..a04a8c655 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -1440,6 +1440,124 @@ class SavedViewSerializer(OwnedObjectSerializer): "set_permissions", ] + def _get_api_version(self) -> int: + request = self.context.get("request") + return int( + request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"], + ) + + def _update_legacy_visibility_preferences( + self, + saved_view_id: int, + *, + show_on_dashboard: bool | None, + show_in_sidebar: bool | None, + ) -> UiSettings | None: + if show_on_dashboard is None and show_in_sidebar is None: + return None + + request = self.context.get("request") + user = request.user if request else self.user + if user is None: + return None + + ui_settings, _ = UiSettings.objects.get_or_create( + user=user, + defaults={"settings": {}}, + ) + current_settings = ( + ui_settings.settings if isinstance(ui_settings.settings, dict) else {} + ) + current_settings = dict(current_settings) + + saved_views_settings = current_settings.get("saved_views") + if isinstance(saved_views_settings, dict): + saved_views_settings = dict(saved_views_settings) + else: + saved_views_settings = {} + + dashboard_ids = { + int(raw_id) + for raw_id in saved_views_settings.get("dashboard_views_visible_ids", []) + if str(raw_id).isdigit() + } + sidebar_ids = { + int(raw_id) + for raw_id in saved_views_settings.get("sidebar_views_visible_ids", []) + if str(raw_id).isdigit() + } + + if show_on_dashboard is not None: + if show_on_dashboard: + dashboard_ids.add(saved_view_id) + else: + dashboard_ids.discard(saved_view_id) + if show_in_sidebar is not None: + if show_in_sidebar: + sidebar_ids.add(saved_view_id) + else: + sidebar_ids.discard(saved_view_id) + + saved_views_settings["dashboard_views_visible_ids"] = sorted(dashboard_ids) + saved_views_settings["sidebar_views_visible_ids"] = sorted(sidebar_ids) + current_settings["saved_views"] = saved_views_settings + ui_settings.settings = current_settings + ui_settings.save(update_fields=["settings"]) + return ui_settings + + def to_representation(self, instance): + # TODO: remove this and related backwards compatibility code when API v9 is dropped + ret = super().to_representation(instance) + request = self.context.get("request") + api_version = self._get_api_version() + + if api_version < 10: + dashboard_ids = set() + sidebar_ids = set() + user = request.user if request else None + if user is not None and hasattr(user, "ui_settings"): + ui_settings = user.ui_settings.settings or None + saved_views = None + if isinstance(ui_settings, dict): + saved_views = ui_settings.get("saved_views", {}) + if isinstance(saved_views, dict): + dashboard_ids = set( + saved_views.get("dashboard_views_visible_ids", []), + ) + sidebar_ids = set( + saved_views.get("sidebar_views_visible_ids", []), + ) + ret["show_on_dashboard"] = instance.id in dashboard_ids + ret["show_in_sidebar"] = instance.id in sidebar_ids + + return ret + + def to_internal_value(self, data): + # TODO: remove this and related backwards compatibility code when API v9 is dropped + api_version = self._get_api_version() + if api_version >= 10: + return super().to_internal_value(data) + + normalized_data = data.copy() + legacy_visibility_fields = {} + boolean_field = serializers.BooleanField() + + for field_name in ("show_on_dashboard", "show_in_sidebar"): + if field_name in normalized_data: + try: + legacy_visibility_fields[field_name] = ( + boolean_field.to_internal_value( + normalized_data.get(field_name), + ) + ) + except serializers.ValidationError as exc: + raise serializers.ValidationError({field_name: exc.detail}) + del normalized_data[field_name] + + ret = super().to_internal_value(normalized_data) + ret.update(legacy_visibility_fields) + return ret + def validate(self, attrs): attrs = super().validate(attrs) if "display_fields" in attrs and attrs["display_fields"] is not None: @@ -1459,6 +1577,9 @@ class SavedViewSerializer(OwnedObjectSerializer): return attrs def update(self, instance, validated_data): + request = self.context.get("request") + show_on_dashboard = validated_data.pop("show_on_dashboard", None) + show_in_sidebar = validated_data.pop("show_in_sidebar", None) if "filter_rules" in validated_data: rules_data = validated_data.pop("filter_rules") else: @@ -1480,9 +1601,19 @@ class SavedViewSerializer(OwnedObjectSerializer): SavedViewFilterRule.objects.filter(saved_view=instance).delete() for rule_data in rules_data: SavedViewFilterRule.objects.create(saved_view=instance, **rule_data) + ui_settings = self._update_legacy_visibility_preferences( + instance.id, + show_on_dashboard=show_on_dashboard, + show_in_sidebar=show_in_sidebar, + ) + if request is not None and ui_settings is not None: + request.user.ui_settings = ui_settings return instance def create(self, validated_data): + request = self.context.get("request") + show_on_dashboard = validated_data.pop("show_on_dashboard", None) + show_in_sidebar = validated_data.pop("show_in_sidebar", None) rules_data = validated_data.pop("filter_rules") if "user" in validated_data: # backwards compatibility @@ -1490,6 +1621,13 @@ class SavedViewSerializer(OwnedObjectSerializer): saved_view = super().create(validated_data) for rule_data in rules_data: SavedViewFilterRule.objects.create(saved_view=saved_view, **rule_data) + ui_settings = self._update_legacy_visibility_preferences( + saved_view.id, + show_on_dashboard=show_on_dashboard, + show_in_sidebar=show_in_sidebar, + ) + if request is not None and ui_settings is not None: + request.user.ui_settings = ui_settings return saved_view diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 4008b09f2..9fa2982c5 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -41,6 +41,7 @@ from documents.models import SavedView from documents.models import ShareLink from documents.models import StoragePath from documents.models import Tag +from documents.models import UiSettings from documents.models import Workflow from documents.models import WorkflowAction from documents.models import WorkflowTrigger @@ -2200,6 +2201,205 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["count"], 0) + def test_saved_view_api_version_backward_compatibility(self) -> None: + """ + GIVEN: + - Saved views and UiSettings with visibility preferences + WHEN: + - API request with version=9 (legacy) + - API request with version=10 (current) + THEN: + - Version 9 returns show_on_dashboard and show_in_sidebar from UiSettings + - Version 10 omits these fields (moved to UiSettings) + """ + v1 = SavedView.objects.create( + owner=self.user, + name="dashboard_view", + sort_field="created", + ) + v2 = SavedView.objects.create( + owner=self.user, + name="sidebar_view", + sort_field="created", + ) + v3 = SavedView.objects.create( + owner=self.user, + name="hidden_view", + sort_field="created", + ) + + UiSettings.objects.update_or_create( + user=self.user, + defaults={ + "settings": { + "saved_views": { + "dashboard_views_visible_ids": [v1.id], + "sidebar_views_visible_ids": [v2.id], + }, + }, + }, + ) + + response_v9 = self.client.get( + "/api/saved_views/", + headers={"Accept": "application/json; version=9"}, + format="json", + ) + self.assertEqual(response_v9.status_code, status.HTTP_200_OK) + results_v9 = {r["id"]: r for r in response_v9.data["results"]} + self.assertIn("show_on_dashboard", results_v9[v1.id]) + self.assertIn("show_in_sidebar", results_v9[v1.id]) + self.assertTrue(results_v9[v1.id]["show_on_dashboard"]) + self.assertFalse(results_v9[v1.id]["show_in_sidebar"]) + self.assertTrue(results_v9[v2.id]["show_in_sidebar"]) + self.assertFalse(results_v9[v2.id]["show_on_dashboard"]) + self.assertFalse(results_v9[v3.id]["show_on_dashboard"]) + self.assertFalse(results_v9[v3.id]["show_in_sidebar"]) + + response_v10 = self.client.get( + "/api/saved_views/", + headers={"Accept": "application/json; version=10"}, + format="json", + ) + self.assertEqual(response_v10.status_code, status.HTTP_200_OK) + results_v10 = {r["id"]: r for r in response_v10.data["results"]} + self.assertNotIn("show_on_dashboard", results_v10[v1.id]) + self.assertNotIn("show_in_sidebar", results_v10[v1.id]) + + def test_saved_view_api_version_9_user_without_ui_settings(self) -> None: + """ + GIVEN: + - User with no UiSettings and a saved view + WHEN: + - API request with version=9 + THEN: + - show_on_dashboard and show_in_sidebar are False (default) + """ + SavedView.objects.create( + owner=self.user, + name="test_view", + sort_field="created", + ) + UiSettings.objects.filter(user=self.user).delete() + + response = self.client.get( + "/api/saved_views/", + headers={"Accept": "application/json; version=9"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + result = response.data["results"][0] + self.assertFalse(result["show_on_dashboard"]) + self.assertFalse(result["show_in_sidebar"]) + + def test_saved_view_api_version_9_create_writes_visibility_to_ui_settings( + self, + ) -> None: + """ + GIVEN: + - No UiSettings for the current user + WHEN: + - A saved view is created through API version 9 with visibility flags + THEN: + - Visibility is persisted in UiSettings.saved_views + """ + UiSettings.objects.filter(user=self.user).delete() + + response = self.client.post( + "/api/saved_views/", + { + "name": "legacy-v9-create", + "sort_field": "created", + "filter_rules": [], + "show_on_dashboard": True, + "show_in_sidebar": False, + }, + headers={"Accept": "application/json; version=9"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(response.data["show_on_dashboard"]) + self.assertFalse(response.data["show_in_sidebar"]) + + self.user.refresh_from_db() + self.assertTrue(hasattr(self.user, "ui_settings")) + saved_view_settings = self.user.ui_settings.settings["saved_views"] + self.assertListEqual( + saved_view_settings["dashboard_views_visible_ids"], + [response.data["id"]], + ) + self.assertListEqual(saved_view_settings["sidebar_views_visible_ids"], []) + + def test_saved_view_api_version_9_patch_writes_visibility_to_ui_settings( + self, + ) -> None: + """ + GIVEN: + - Existing saved views and UiSettings visibility ids + WHEN: + - A saved view is updated through API version 9 visibility flags + THEN: + - The per-user UiSettings visibility ids are updated + """ + v1 = SavedView.objects.create( + owner=self.user, + name="legacy-v9-patch-1", + sort_field="created", + ) + v2 = SavedView.objects.create( + owner=self.user, + name="legacy-v9-patch-2", + sort_field="created", + ) + UiSettings.objects.update_or_create( + user=self.user, + defaults={ + "settings": { + "saved_views": { + "dashboard_views_visible_ids": [v1.id], + "sidebar_views_visible_ids": [v1.id, v2.id], + }, + }, + }, + ) + + response = self.client.patch( + f"/api/saved_views/{v1.id}/", + { + "show_on_dashboard": False, + }, + headers={"Accept": "application/json; version=9"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.data["show_on_dashboard"]) + self.assertTrue(response.data["show_in_sidebar"]) + + self.user.refresh_from_db() + saved_view_settings = self.user.ui_settings.settings["saved_views"] + self.assertListEqual(saved_view_settings["dashboard_views_visible_ids"], []) + self.assertListEqual( + saved_view_settings["sidebar_views_visible_ids"], + [v1.id, v2.id], + ) + + response = self.client.patch( + f"/api/saved_views/{v1.id}/", + { + "show_in_sidebar": False, + }, + headers={"Accept": "application/json; version=9"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.data["show_on_dashboard"]) + self.assertFalse(response.data["show_in_sidebar"]) + + self.user.refresh_from_db() + saved_view_settings = self.user.ui_settings.settings["saved_views"] + self.assertListEqual(saved_view_settings["dashboard_views_visible_ids"], []) + self.assertListEqual(saved_view_settings["sidebar_views_visible_ids"], [v2.id]) + def test_saved_view_create_update_patch(self) -> None: User.objects.create_user("user1")