diff --git a/src/kitconcept/solr/services/configure.zcml b/src/kitconcept/solr/services/configure.zcml index 1ffc22c..64d004e 100644 --- a/src/kitconcept/solr/services/configure.zcml +++ b/src/kitconcept/solr/services/configure.zcml @@ -1,7 +1,8 @@ + + + + + diff --git a/src/kitconcept/solr/services/navigation_with_excluded.py b/src/kitconcept/solr/services/navigation_with_excluded.py new file mode 100644 index 0000000..ac251b7 --- /dev/null +++ b/src/kitconcept/solr/services/navigation_with_excluded.py @@ -0,0 +1,195 @@ +from collections import defaultdict +from plone.memoize.view import memoize +from plone.memoize.view import memoize_contextless +from plone.registry.interfaces import IRegistry +from plone.restapi.bbb import INavigationSchema +from plone.restapi.bbb import safe_text +from plone.restapi.interfaces import IExpandableElement +from plone.restapi.serializer.converters import json_compatible +from plone.restapi.services import Service +from plone.restapi.services.navigation.get import Navigation +from Products.CMFCore.utils import getToolByName +from zope.component import adapter +from zope.component import getUtility +from zope.i18n import translate +from zope.interface import implementer +from zope.interface import Interface + + +@implementer(IExpandableElement) +@adapter(Interface, Interface) +class NavigationWithExcluded(Navigation): + """Navigation expander that includes all items, even those excluded from navigation. + + This is specifically used for breadcrumbs in global search results where we need + to show the breadcrumb titles for an item, regardless of whether intermediate items are + marked as 'exclude_from_nav'. + + It inherits from the standard Navigation expander but overrides the settings + to ensure show_excluded_items is always True. + """ + + def __call__(self, expand=False): + if self.request.form.get( + "expand.navigation_with_excluded.depth", False + ): + self.depth = int( + self.request.form["expand.navigation_with_excluded.depth"] + ) + else: + self.depth = 1 + + result = { + "navigation_with_excluded": { + "@id": f"{self.context.absolute_url()}/@navigation_with_excluded" + } + } + if not expand: + return result + + result["navigation_with_excluded"]["items"] = self.build_tree( + self.navtree_path + ) + return result + + @property + @memoize_contextless + def settings(self): + registry = getUtility(IRegistry) + settings = registry.forInterface(INavigationSchema, prefix="plone") + return { + "displayed_types": settings.displayed_types, + "nonfolderish_tabs": settings.nonfolderish_tabs, + "filter_on_workflow": settings.filter_on_workflow, + "workflow_states_to_show": settings.workflow_states_to_show, + # Always show excluded items + "show_excluded_items": True, + } + + @property + @memoize + def navtree(self): + ret = defaultdict(list) + navtree_path = self.navtree_path + for tab in self.portal_tabs: + entry = {} + entry.update( + { + "path": "/".join((navtree_path, tab["id"])), + "description": tab["description"], + "@id": tab["url"], + } + ) + if "review_state" in tab: + entry["review_state"] = json_compatible(tab["review_state"]) + else: + entry["review_state"] = None + + if "title" not in entry: + entry["title"] = ( + tab.get("name") or tab.get("description") or tab["id"] + ) + else: + # translate Home tab + entry["title"] = translate( + entry["title"], domain="plone", context=self.request + ) + + entry["title"] = safe_text(entry["title"]) + ret[navtree_path].append(entry) + + query = { + "path": { + "query": self.navtree_path, + "depth": self.depth, + }, + "portal_type": {"query": self.settings["displayed_types"]}, + "Language": self.current_language, + "is_default_page": False, + "sort_on": "getObjPositionInParent", + } + + if not self.settings["nonfolderish_tabs"]: + query["is_folderish"] = True + + if self.settings["filter_on_workflow"]: + query["review_state"] = list( + self.settings["workflow_states_to_show"] or () + ) + + # if not self.settings["show_excluded_items"]: + # query["exclude_from_nav"] = False + + # context_path = "/".join(self.context.getPhysicalPath()) + portal_catalog = getToolByName(self.context, "portal_catalog") + brains = portal_catalog.searchResults(**query) + + # registry = getUtility(IRegistry) + # types_using_view = registry.get( + # "plone.types_use_view_action_in_listings", [] + # ) + + for brain in brains: + brain_path = brain.getPath() + brain_parent_path = brain_path.rpartition("/")[0] + # if brain_parent_path == navtree_path: + # # This should be already provided by the portal_tabs_view + # # continue + # pass + + # if brain.exclude_from_nav and not f"{brain_path}/".startswith( + # f"{context_path}/" + # ): + # # skip excluded items if they're not in our context path + # # continue + # pass + url = brain.getURL() + + entry = { + "path": brain_path, + "@id": url, + "title": safe_text(brain.Title), + # "description": safe_text(brain.Description), + # "review_state": json_compatible(brain.review_state), + # "use_view_action_in_listings": brain.portal_type in types_using_view, + } + if "nav_title" in brain and brain.nav_title: + entry.update({"title": brain.nav_title}) + + self.customize_entry(entry, brain) + ret[brain_parent_path].append(entry) + return ret + + def render_item(self, item, path): + if "path" in item: + item_path = item["path"] + else: + # Path not found. Use the @id to build the path. + item_path = ( + self.navtree_path + "/" + "/".join(item["@id"].split("/")[4:]) + ) + + sub = self.build_tree(item_path, first_run=False) + + item.update({"items": sub}) + + if "path" in item: + del item["path"] + return item + + +class NavigationWithExcludedGet(Service): + """REST API endpoint that returns navigation data including excluded items. + + This service is used to get breadcrumb navigation data for search results, + ensuring that the breadcrumb titles for any item can be displayed even if parent + items are marked as excluded from navigation. + """ + + def reply(self): + navigation_with_excluded = NavigationWithExcluded( + self.context, self.request + ) + return navigation_with_excluded(expand=True)[ + "navigation_with_excluded" + ] diff --git a/tests/services/navigation/test_services_navigation.py b/tests/services/navigation/test_services_navigation.py new file mode 100644 index 0000000..4e6e1d4 --- /dev/null +++ b/tests/services/navigation/test_services_navigation.py @@ -0,0 +1,248 @@ +from plone.app.testing import setRoles +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.app.testing import TEST_USER_ID +from plone.dexterity.utils import createContentInContainer +from plone.registry.interfaces import IRegistry +from plone.restapi.bbb import INavigationSchema +from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING +from plone.restapi.testing import RelativeSession +from zope.component import getUtility + +import transaction +import unittest + + +class TestServicesNavigation(unittest.TestCase): + layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.app = self.layer["app"] + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + setRoles(self.portal, TEST_USER_ID, ["Manager"]) + + self.api_session = RelativeSession(self.portal_url, test=self) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + self.folder = createContentInContainer( + self.portal, "Folder", id="folder", title="Some Folder" + ) + self.folder2 = createContentInContainer( + self.portal, "Folder", id="folder2", title="Some Folder 2" + ) + self.subfolder1 = createContentInContainer( + self.folder, "Folder", id="subfolder1", title="SubFolder 1" + ) + self.subfolder2 = createContentInContainer( + self.folder, "Folder", id="subfolder2", title="SubFolder 2" + ) + self.thirdlevelfolder = createContentInContainer( + self.subfolder1, + "Folder", + id="thirdlevelfolder", + title="Third Level Folder", + ) + self.fourthlevelfolder = createContentInContainer( + self.thirdlevelfolder, + "Folder", + id="fourthlevelfolder", + title="Fourth Level Folder", + ) + createContentInContainer( + self.folder, "Document", id="doc1", title="A document" + ) + transaction.commit() + + def tearDown(self): + self.api_session.close() + + def test_navigation_service(self): + response = self.api_session.get( + "/folder/@navigation", params={"expand.navigation.depth": 2} + ) + + assert response.status_code == 200 + assert len(response.json()["items"]) == 3 + assert response.json()["items"][1]["title"] == "Some Folder" + assert len(response.json()["items"][1]["items"]) == 3 + assert len(response.json()["items"][2]["items"]) == 0 + + response = self.api_session.get( + "/folder/@navigation", params={"expand.navigation.depth": 3} + ) + + assert len(response.json()["items"][1]["items"][0]["items"]) == 1 + assert ( + response.json()["items"][1]["items"][0]["items"][0]["title"] + == "Third Level Folder" + ) + assert ( + len(response.json()["items"][1]["items"][0]["items"][0]["items"]) + == 0 + ) + + response = self.api_session.get( + "/folder/@navigation", params={"expand.navigation.depth": 4} + ) + + assert ( + len(response.json()["items"][1]["items"][0]["items"][0]["items"]) + == 1 + ) + assert ( + response.json()["items"][1]["items"][0]["items"][0]["items"][0][ + "title" + ] + == "Fourth Level Folder" + ) + + def test_dont_broke_with_contents_without_review_state(self): + registry = getUtility(IRegistry) + settings = registry.forInterface(INavigationSchema, prefix="plone") + displayed_types = settings.displayed_types + settings.displayed_types = tuple(list(displayed_types) + ["File"]) + createContentInContainer( + self.portal, + "File", + id="example-file", + title="Example file", + ) + createContentInContainer( + self.folder, + "File", + id="example-file-1", + title="Example file 1", + ) + transaction.commit() + + response = self.api_session.get("/folder/@navigation") + assert response.json()["items"][3]["review_state"] is None + + response = self.api_session.get( + "/folder/@navigation", params={"expand.navigation.depth": 2} + ) + assert response.json()["items"][1]["items"][3]["review_state"] is None + + def test_show_excluded_items(self): + registry = getUtility(IRegistry) + settings = registry.forInterface(INavigationSchema, prefix="plone") + + # Plone 5.2 and Plone 6.0 have different default values: + # False for Plone 6.0 and True for Plone 5.2 + # explicitly set the value to False to avoid test failures + settings.show_excluded_items = False + createContentInContainer( + self.folder, + "Folder", + id="excluded-subfolder", + title="Excluded SubFolder", + exclude_from_nav=True, + ) + transaction.commit() + response = self.api_session.get( + "/folder/@navigation", params={"expand.navigation.depth": 2} + ) + assert "Excluded SubFolder" not in [ + item["title"] for item in response.json()["items"][1]["items"] + ] + + # change setting to show excluded items + registry = getUtility(IRegistry) + settings = registry.forInterface(INavigationSchema, prefix="plone") + settings.show_excluded_items = True + transaction.commit() + response = self.api_session.get( + "/folder/@navigation", params={"expand.navigation.depth": 2} + ) + assert "Excluded SubFolder" in [ + item["title"] for item in response.json()["items"][1]["items"] + ] + + def test_navigation_sorting(self): + registry = getUtility(IRegistry) + registry["plone.displayed_types"] = ( + "Link", + "News Item", + "Folder", + "Document", + "Event", + "Collection", + "File", + ) + createContentInContainer( + self.portal, + "File", + id="example-file", + title="Example file", + ) + createContentInContainer( + self.folder, + "File", + id="example-file-1", + title="Example file 1", + ) + transaction.commit() + response = self.api_session.get( + "/folder/@navigation", params={"expand.navigation.depth": 2} + ).json() + + contents = response["items"][1]["items"] + assert [ + p["@id"].replace(self.portal.absolute_url(), "") for p in contents + ] == [ + "/folder/subfolder1", + "/folder/subfolder2", + "/folder/doc1", + "/folder/example-file-1", + ] + + self.portal["folder"].moveObjectsUp(["example-file-1"]) + transaction.commit() + response = self.api_session.get( + "/folder/@navigation", params={"expand.navigation.depth": 2} + ).json() + contents = response["items"][1]["items"] + assert [ + p["@id"].replace(self.portal.absolute_url(), "") for p in contents + ] == [ + "/folder/subfolder1", + "/folder/subfolder2", + "/folder/example-file-1", + "/folder/doc1", + ] + + def test_use_nav_title_when_available_and_set(self): + registry = getUtility(IRegistry) + settings = registry.forInterface(INavigationSchema, prefix="plone") + displayed_types = settings.displayed_types + settings.displayed_types = tuple( + list(displayed_types) + ["DXTestDocument"] + ) + + title = "Example Document" + nav_title = "Fancy title" + + createContentInContainer( + self.folder, + "DXTestDocument", + id="example-dx-document", + title=title, + nav_title=nav_title, + ) + transaction.commit() + + response = self.api_session.get( + "/folder/@navigation", params={"expand.navigation.depth": 2} + ) + + assert response.json()["items"][1]["items"][-1]["title"] == nav_title + + def test_navigation_badrequests(self): + response = self.api_session.get( + "/folder/@navigation", params={"expand.navigation.depth": "php"} + ) + + assert response.status_code == 400 + assert "Invalid expand.navigation.depth" in response.json()["message"]