diff --git a/CHANGES.rst b/CHANGES.rst index 97d929fb..3ee90203 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,7 +5,12 @@ Changelog 1.4.56 (unreleased) ------------------- -- Nothing changed yet. +- Add a control-panel setting (Smartweb site admin) to configure, per authentic + source, whether it appears in the sitemap, how many + items are listed (max 50) and their ordering. Applies to both the HTML and XML + sitemaps. Defaults to all sources enabled at 50 items, so existing sites are + unaffected. + [boulch] 1.4.55 (2026-07-28) diff --git a/src/imio/smartweb/core/browser/controlpanel_siteadmin.py b/src/imio/smartweb/core/browser/controlpanel_siteadmin.py index 2dbe8f9b..7d4ae4f5 100644 --- a/src/imio/smartweb/core/browser/controlpanel_siteadmin.py +++ b/src/imio/smartweb/core/browser/controlpanel_siteadmin.py @@ -12,13 +12,19 @@ from plone.registry.interfaces import IRegistry from plone.z3cform import layout from Products.statusmessages.interfaces import IStatusMessage +from z3c.form.browser.select import SelectWidget +from z3c.form.widget import FieldWidget from zope import schema from zope.component import getUtility +from zope.i18n import translate from zope.interface import Interface from zope.schema import ValidationError from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm +from xml.sax.saxutils import escape +from xml.sax.saxutils import quoteattr + import logging logger = logging.getLogger("imio.smartweb.core.browser.controlpanel_siteadmin") @@ -36,6 +42,33 @@ def max_length_constraint(value): return True +MAX_SITEMAP_ITEMS = 50 + +SITEMAP_SOURCE_VOCABULARY = SimpleVocabulary( + [ + SimpleTerm( + "imio.smartweb.EventsView", + "imio.smartweb.EventsView", + _("Agenda — à venir"), + ), + SimpleTerm( + "imio.smartweb.NewsView", + "imio.smartweb.NewsView", + _("Actualités — les plus récents"), + ), + SimpleTerm( + "imio.smartweb.DirectoryView", + "imio.smartweb.DirectoryView", + _("Annuaire — les plus récents"), + ), + ] +) + +SITEMAP_FILTER_VOCABULARY = SimpleVocabulary( + [SimpleTerm("most_recent", "most_recent", _("Default sort"))] +) + + class IProcedureTextRowSchema(Interface): omitted("label_id") @@ -71,6 +104,83 @@ class IProcedureTextRowSchema(Interface): ) +class FrozenLabelSelectWidget(SelectWidget): + """Render a Choice column as a read-only label while still submitting it. + + A plain ``mode="display"`` column looks right but is skipped during + extraction, so DictRow rejects every row on save. This widget keeps the + field in input mode (thus extracted normally) yet renders only the term + title plus a hidden input carrying the token — a per-row "frozen label" + that persists. It mirrors exactly what the standard select would submit + (``:list`` + ``-empty-marker``). + """ + + def render(self): + # The DataGrid sets the sub-widget value to the raw field value (a + # single token string), while a stand-alone SelectWidget holds a + # list/tuple of tokens. Handle both so the label is never a stray + # character (self.value[0] on a string would render "i"). + value = self.value + if isinstance(value, (list, tuple)): + token = value[0] if value else "" + elif isinstance(value, str): + token = value + else: + token = "" + try: + title = translate( + self.terms.getTermByToken(token).title, context=self.request + ) + except (LookupError, AttributeError): + title = token + return ( + '{label}' + '' + '' + ).format( + label=escape(title or ""), + name=quoteattr("{}:list".format(self.name)), + token=quoteattr(token), + marker=quoteattr("{}-empty-marker".format(self.name)), + ) + + +def FrozenLabelFieldWidget(field, request): + return FieldWidget(field, FrozenLabelSelectWidget(request)) + + +class ISitemapSourceRowSchema(Interface): + + # source_type stays an input field (extracted on save) but is rendered as + # a read-only label via FrozenLabelFieldWidget. A mode="display" column is + # NOT submitted, which makes DictRow reject every row ("could not process + # the value" / "required"). + widget(source_type=FrozenLabelFieldWidget) + source_type = schema.Choice( + title=_("Source"), + vocabulary=SITEMAP_SOURCE_VOCABULARY, + required=False, + ) + enabled = schema.Bool( + title=_("Enabled"), + default=True, + required=False, + ) + max_items = schema.Int( + title=_("Maximum number of items"), + min=1, + max=MAX_SITEMAP_ITEMS, + default=MAX_SITEMAP_ITEMS, + required=True, + ) + item_filter = schema.Choice( + title=_("Filter"), + vocabulary=SITEMAP_FILTER_VOCABULARY, + default="most_recent", + required=True, + ) + + class ISmartwebSiteAdminControlPanel(Interface): menu_position_select = schema.Choice( @@ -97,6 +207,41 @@ class ISmartwebSiteAdminControlPanel(Interface): default="default", ) + widget(sitemap_authentic_sources=DataGridFieldFactory) + sitemap_authentic_sources = schema.List( + title=_("Sitemap: authentic sources configuration"), + description=_( + "Per authentic source: include it in the sitemap, cap how many " + "remote items are listed, and choose the ordering. Disabling or " + "lowering the count reduces the sitemap size." + ), + value_type=DictRow( + title="SitemapSource", + schema=ISitemapSourceRowSchema, + ), + default=[ + { + "source_type": "imio.smartweb.EventsView", + "enabled": True, + "max_items": 50, + "item_filter": "most_recent", + }, + { + "source_type": "imio.smartweb.NewsView", + "enabled": True, + "max_items": 50, + "item_filter": "most_recent", + }, + { + "source_type": "imio.smartweb.DirectoryView", + "enabled": True, + "max_items": 50, + "item_filter": "most_recent", + }, + ], + required=False, + ) + widget(procedure_button_text=DataGridFieldFactory) procedure_button_text = schema.List( title=_("Procedure : Define button text"), @@ -115,7 +260,32 @@ class SmartwebSiteAdminControlPanelForm(RegistryEditForm): schema_prefix = "smartweb" label = _("Smartweb Site admin Settings") + def updateWidgets(self, prefix=None): + super().updateWidgets(prefix) + # The sitemap sources grid has a fixed set of rows (one per authentic + # source); the admin edits them but must not add/remove/append rows. + sitemap_widget = self.widgets.get("sitemap_authentic_sources") + if sitemap_widget is not None: + sitemap_widget.allow_insert = False + sitemap_widget.allow_delete = False + sitemap_widget.auto_append = False + def applyChanges(self, data): + # Guard: the sitemap grid must list each authentic source exactly once + # (source_type is editable to satisfy the widget, so we validate it). + sitemap_rows = data.get("sitemap_authentic_sources") + if sitemap_rows is not None: + source_types = [row.get("source_type") for row in sitemap_rows] + if sorted(source_types) != sorted(SITEMAP_SOURCE_VOCABULARY.by_value): + IStatusMessage(self.request).addStatusMessage( + _( + "The sitemap configuration must list each authentic " + "source exactly once." + ), + type="error", + ) + return False + rows = data.get("procedure_button_text") or [] for row in rows: all_label_ids = [row.get("label_id") for row in rows if row.get("label_id")] diff --git a/src/imio/smartweb/core/browser/sitemap.py b/src/imio/smartweb/core/browser/sitemap.py index 673c4506..293e9cbc 100644 --- a/src/imio/smartweb/core/browser/sitemap.py +++ b/src/imio/smartweb/core/browser/sitemap.py @@ -5,6 +5,7 @@ from imio.smartweb.core.contents.rest.news.endpoint import NewsEndpointGet from imio.smartweb.core.interfaces import IImioSmartwebCoreLayer from imio.smartweb.locales import SmartwebMessageFactory as _ +from plone import api from plone.app.layout.navigation.navtree import buildFolderTree from plone.app.layout.sitemap.sitemap import SiteMapView from plone.base.interfaces import IPloneSiteRoot @@ -29,6 +30,46 @@ logger = logging.getLogger("imio.smartweb.core") +AUTHENTIC_SOURCE_TYPES = [ + "imio.smartweb.EventsView", + "imio.smartweb.NewsView", + "imio.smartweb.DirectoryView", +] + +FILTER_SORT_BY_TYPE = { + "imio.smartweb.DirectoryView": { + "most_recent": ("created", "descending"), + }, +} + + +def get_filter_sort(portal_type, filter_value): + """(sort_on, sort_order) override for a source; (None, None) = native.""" + return FILTER_SORT_BY_TYPE.get(portal_type, {}).get(filter_value, (None, None)) + + +def get_sitemap_sources_config(): + """{portal_type: {enabled, max_items, item_filter}} from the registry. + + A missing record (None) means all three sources enabled, 50 items, native + ordering — preserving behavior on instances not yet migrated. + """ + rows = api.portal.get_registry_record( + "smartweb.sitemap_authentic_sources", default=None + ) + if rows is None: + rows = [ + { + "source_type": t, + "enabled": True, + "max_items": 50, + "item_filter": "most_recent", + } + for t in AUTHENTIC_SOURCE_TYPES + ] + return {r["source_type"]: r for r in rows} + + FRIENDLY_TYPES = [ "Collection", "Image", @@ -44,14 +85,17 @@ ] -def cache_key(method, obj, request): - """We cache data from authentic sources for the sitemap (.xml.gz) for 2 hours.""" +def cache_key(method, obj, request, batch_size, sort_on, sort_order): + """Cache authentic-source data for the sitemap for 2 hours.""" b_start = request.form.get("b_start", "0") - return f"sitemap_{obj.UID()}_{b_start}_{int(time.time() // 7200)}" + return ( + f"sitemap_{obj.UID()}_{b_start}_{batch_size}_{sort_on}_{sort_order}_" + f"{int(time.time() // 7200)}" + ) @ram.cache(cache_key) -def get_endpoint_data(obj, request): +def get_endpoint_data(obj, request, batch_size, sort_on, sort_order): endpoint_mapping = { "imio.smartweb.DirectoryView": DirectoryEndpointGet, "imio.smartweb.EventsView": EventsEndpointGet, @@ -60,15 +104,15 @@ def get_endpoint_data(obj, request): endpoint_class = endpoint_mapping.get(obj.portal_type) if not endpoint_class: return {} - endpoint = endpoint_class() - if not request.form.get("b_size", 0): - batch_size = 1000 if obj.portal_type == "imio.smartweb.DirectoryView" else 365 - else: - batch_size = int(request.form.get("b_size", 0)) return ( endpoint.reply_for_given_object( - obj, request, fullobjects=0, batch_size=batch_size + obj, + request, + fullobjects=0, + batch_size=batch_size, + sort_on=sort_on, + sort_order=sort_order, ) or {} ) @@ -167,17 +211,25 @@ def objects(self): ) yield {"loc": loc, "lastmod": modified[1]} - brains = catalog( - portal_type=[ - "imio.smartweb.EventsView", - "imio.smartweb.NewsView", - "imio.smartweb.DirectoryView", - ] - ) - for brain in brains: - obj = brain.getObject() - data = get_endpoint_data(obj, obj.REQUEST) - yield from format_sitemap_items(data.get("items", {}), obj.absolute_url()) + config = get_sitemap_sources_config() + enabled_types = [t for t, c in config.items() if c.get("enabled")] + if enabled_types: + brains = catalog(portal_type=enabled_types) + for brain in brains: + obj = brain.getObject() + source_cfg = config[obj.portal_type] + sort_on, sort_order = get_filter_sort( + obj.portal_type, source_cfg.get("item_filter") + ) + data = get_endpoint_data( + obj, + obj.REQUEST, + source_cfg.get("max_items"), + sort_on, + sort_order, + ) + items = data.get("items", [])[: source_cfg.get("max_items")] + yield from format_sitemap_items(items, obj.absolute_url()) @implementer(IImioSmartwebCoreLayer) @@ -192,14 +244,26 @@ def siteMap(self): context, obj=context, query=query, strategy=strategy ) + config = get_sitemap_sources_config() for child in base_folder_tree.get("children"): obj = child.get("item").getObject() - data = get_endpoint_data(obj, obj.REQUEST) - if not data: + source_cfg = config.get(obj.portal_type) + if source_cfg is None or not source_cfg.get("enabled"): continue - child["children"] = format_sitemap_items( - data.get("items", []), obj.absolute_url() + sort_on, sort_order = get_filter_sort( + obj.portal_type, source_cfg.get("item_filter") ) + data = get_endpoint_data( + obj, + obj.REQUEST, + source_cfg.get("max_items"), + sort_on, + sort_order, + ) + if not data: + continue + items = data.get("items", [])[: source_cfg.get("max_items")] + child["children"] = format_sitemap_items(items, obj.absolute_url()) return base_folder_tree diff --git a/src/imio/smartweb/core/contents/rest/base.py b/src/imio/smartweb/core/contents/rest/base.py index 11223eee..1c69993e 100644 --- a/src/imio/smartweb/core/contents/rest/base.py +++ b/src/imio/smartweb/core/contents/rest/base.py @@ -15,12 +15,21 @@ class BaseEndpoint(object): language = "fr" remote_endpoint = "" - def __init__(self, context, request, fullobjects=1, batch_size=0): - + def __init__( + self, + context, + request, + fullobjects=1, + batch_size=0, + sort_on=None, + sort_order=None, + ): self.context = context self.request = request self.fullobjects = fullobjects self.batch_size = batch_size + self.sort_on = sort_on + self.sort_order = sort_order def __call__(self): results = get_json(self.query_url, timeout=20) @@ -30,6 +39,15 @@ def __call__(self): def query_url(self): raise NotImplementedError + def sort_params(self, default_sort_on, default_sort_order=None): + """Build sort_on/sort_order query params, honoring an override.""" + sort_on = self.sort_on or default_sort_on + sort_order = self.sort_order or default_sort_order + params = ["sort_on={}".format(sort_on)] + if sort_order: + params.append("sort_order={}".format(sort_order)) + return params + def convert_cached_image_scales( self, item, diff --git a/src/imio/smartweb/core/contents/rest/directory/endpoint.py b/src/imio/smartweb/core/contents/rest/directory/endpoint.py index 7271a05d..b117e4e3 100644 --- a/src/imio/smartweb/core/contents/rest/directory/endpoint.py +++ b/src/imio/smartweb/core/contents/rest/directory/endpoint.py @@ -46,7 +46,6 @@ def query_url(self): "metadata_fields=topics", "metadata_fields=has_leadimage", "fullobjects={}".format(self.fullobjects), - "sort_on=sortable_title", ] if self.batch_size == 0: params.append("b_size={}".format(self.context.nb_results)) @@ -56,6 +55,7 @@ def query_url(self): for category in self.context.selected_categories: params.append(f"taxonomy_contact_category.query={category}") params.append("taxonomy_contact_category.operator=or") + params += self.sort_params("sortable_title") params = self.construct_query_string(params) url = f"{DIRECTORY_URL}/{self.remote_endpoint}?{params}" return url @@ -77,9 +77,22 @@ class DirectoryEndpointGet(BaseService): def reply(self): return DirectoryEndpoint(self.context, self.request)() - def reply_for_given_object(self, obj, request, fullobjects=1, batch_size=0): + def reply_for_given_object( + self, + obj, + request, + fullobjects=1, + batch_size=0, + sort_on=None, + sort_order=None, + ): return DirectoryEndpoint( - obj, request, fullobjects=fullobjects, batch_size=batch_size + obj, + request, + fullobjects=fullobjects, + batch_size=batch_size, + sort_on=sort_on, + sort_order=sort_order, )() diff --git a/src/imio/smartweb/core/contents/rest/events/endpoint.py b/src/imio/smartweb/core/contents/rest/events/endpoint.py index 157af7cc..22b17a9d 100644 --- a/src/imio/smartweb/core/contents/rest/events/endpoint.py +++ b/src/imio/smartweb/core/contents/rest/events/endpoint.py @@ -16,10 +16,23 @@ class BaseEventsEndpoint(BaseEndpoint): - def __init__(self, context, request, fullobjects=0, batch_size=0): + def __init__( + self, + context, + request, + fullobjects=0, + batch_size=0, + sort_on=None, + sort_order=None, + ): self.fullobjects = fullobjects super(BaseEventsEndpoint, self).__init__( - context, request, fullobjects=fullobjects, batch_size=batch_size + context, + request, + fullobjects=fullobjects, + batch_size=batch_size, + sort_on=sort_on, + sort_order=sort_order, ) def __call__(self): @@ -95,7 +108,6 @@ def query_url(self): "metadata_fields=has_leadimage", "metadata_fields=UID", "metadata_fields=language", - "sort_on=event_dates", "fullobjects={}".format(self.fullobjects), ] if self.batch_size == 0: @@ -105,6 +117,7 @@ def query_url(self): if self.context.selected_event_types is not None: for event_type in self.context.selected_event_types: params.append(f"event_type={event_type}") + params += self.sort_params("event_dates") params = self.construct_query_string(params) url = f"{EVENTS_URL}/{self.remote_endpoint}?{params}" if is_log_active(): @@ -128,9 +141,22 @@ class EventsEndpointGet(BaseService): def reply(self): return EventsEndpoint(self.context, self.request)() - def reply_for_given_object(self, obj, request, fullobjects=0, batch_size=0): + def reply_for_given_object( + self, + obj, + request, + fullobjects=0, + batch_size=0, + sort_on=None, + sort_order=None, + ): return EventsEndpoint( - obj, request, fullobjects=fullobjects, batch_size=batch_size + obj, + request, + fullobjects=fullobjects, + batch_size=batch_size, + sort_on=sort_on, + sort_order=sort_order, )() diff --git a/src/imio/smartweb/core/contents/rest/news/endpoint.py b/src/imio/smartweb/core/contents/rest/news/endpoint.py index 1b0aaa5d..da8cd249 100644 --- a/src/imio/smartweb/core/contents/rest/news/endpoint.py +++ b/src/imio/smartweb/core/contents/rest/news/endpoint.py @@ -72,8 +72,6 @@ def query_url(self): "metadata_fields=topics", "metadata_fields=has_leadimage", "metadata_fields=UID", - "sort_on=effective", - "sort_order=descending", "entity_uid={}".format(entity_uid), "fullobjects={}".format(self.fullobjects), ] @@ -86,6 +84,7 @@ def query_url(self): params.append("b_size={}".format(self.context.nb_results)) else: params.append("b_size={}".format(self.batch_size)) + params += self.sort_params("effective", "descending") params = self.construct_query_string(params) url = f"{NEWS_URL}/{self.remote_endpoint}?{params}" return url @@ -107,9 +106,22 @@ class NewsEndpointGet(BaseService): def reply(self): return NewsEndpoint(self.context, self.request)() - def reply_for_given_object(self, obj, request, fullobjects=1, batch_size=0): + def reply_for_given_object( + self, + obj, + request, + fullobjects=1, + batch_size=0, + sort_on=None, + sort_order=None, + ): return NewsEndpoint( - obj, request, fullobjects=fullobjects, batch_size=batch_size + obj, + request, + fullobjects=fullobjects, + batch_size=batch_size, + sort_on=sort_on, + sort_order=sort_order, )() diff --git a/src/imio/smartweb/core/contents/rest/view.py b/src/imio/smartweb/core/contents/rest/view.py index 425ea9c7..7b99f800 100644 --- a/src/imio/smartweb/core/contents/rest/view.py +++ b/src/imio/smartweb/core/contents/rest/view.py @@ -115,7 +115,10 @@ def __call__(self): self.request.form["b_start"] = b_start self.request.form["b_size"] = b_size - data = get_endpoint_data(self.context, self.request) + # seo_html keeps its own (larger, paginated) b_size and the endpoint's + # default ordering — it is NOT capped by the sitemap control-panel + # max_items, so all items stay crawlable for SEO. + data = get_endpoint_data(self.context, self.request, b_size, None, None) self.items = format_sitemap_items( data.get("items", []), self.context.absolute_url() ) diff --git a/src/imio/smartweb/core/profiles/default/metadata.xml b/src/imio/smartweb/core/profiles/default/metadata.xml index e51fb4e1..09b16d75 100644 --- a/src/imio/smartweb/core/profiles/default/metadata.xml +++ b/src/imio/smartweb/core/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 1081 + 1082 profile-plone.app.dexterity:default profile-plone.app.discussion:default diff --git a/src/imio/smartweb/core/tests/test_rest.py b/src/imio/smartweb/core/tests/test_rest.py index cba2459b..c9cd6203 100644 --- a/src/imio/smartweb/core/tests/test_rest.py +++ b/src/imio/smartweb/core/tests/test_rest.py @@ -195,8 +195,8 @@ def test_call_directory(self, m): "metadata_fields=topics&" "metadata_fields=has_leadimage&" "fullobjects=1&" - "sort_on=sortable_title&" "b_size=20&" + "sort_on=sortable_title&" "taxonomy_contact_category_for_filtering=token&" "topics=education&" "translated_in_en=1", @@ -231,11 +231,11 @@ def test_call_directory(self, m): "metadata_fields=topics&" "metadata_fields=has_leadimage&" "fullobjects=1&" - "sort_on=sortable_title&" "b_size=30&" "taxonomy_contact_category.query=hlsm9bijb1&" "taxonomy_contact_category.query=9kgcmrj4lu&" "taxonomy_contact_category.operator=or&" + "sort_on=sortable_title&" "taxonomy_contact_category_for_filtering=token&" "topics=education&" "translated_in_en=1", @@ -429,10 +429,10 @@ def test_call_events(self, m): "metadata_fields=has_leadimage&" "metadata_fields=UID&" "metadata_fields=language&" - "sort_on=event_dates&" "fullobjects=0&" "b_size=20&" "event_type=event-driven&" + "sort_on=event_dates&" "translated_in_en=1".format(self.rest_events.selected_agenda), ) m.get(url, text=json.dumps({})) @@ -500,11 +500,11 @@ def test_call_news(self, m): "metadata_fields=topics&" "metadata_fields=has_leadimage&" "metadata_fields=UID&" - "sort_on=effective&" - "sort_order=descending&" "entity_uid=7c69f9a738ec497c819725c55888ee32&" "fullobjects=1&" "b_size=20&" + "sort_on=effective&" + "sort_order=descending&" "translated_in_en=1".format(self.rest_news.selected_news_folder), ) m.get(url, text=json.dumps({})) diff --git a/src/imio/smartweb/core/tests/test_rest_views.py b/src/imio/smartweb/core/tests/test_rest_views.py index 505406d6..becba3db 100644 --- a/src/imio/smartweb/core/tests/test_rest_views.py +++ b/src/imio/smartweb/core/tests/test_rest_views.py @@ -225,6 +225,36 @@ def test_seo_hidden_react_links_items_total(self, mock_format, mock_endpoint): self.assertEqual(view.total, 42) self.assertEqual(len(view.get_data), 0) + @patch( + "imio.smartweb.core.contents.rest.directory.endpoint.BaseDirectoryEndpoint.__call__", + return_value={ + "items": [ + { + "@type": "imio.directory.Contact", + "title": "Alice", + "UID": "u1", + "modified": "2024-01-01T00:00:00Z", + "description": "", + } + ], + "items_total": 1, + }, + ) + def test_seo_hidden_react_links_calls_endpoint_with_correct_arity(self, mock_call): + # Regression: get_endpoint_data() gained (batch_size, sort_on, + # sort_order) params. The seo_html view must call it with the right + # arity. The other seo tests mock get_endpoint_data itself, which hides + # an arity mismatch; here we mock only the external HTTP (__call__) so + # the REAL get_endpoint_data runs and a wrong-arity call would raise. + view = queryMultiAdapter((self.directory_view, self.request), name="seo_html") + view() # must not raise TypeError + self.assertEqual(view.total, 1) + self.assertGreaterEqual(len(view.get_data), 1) + # seo_html must keep its own (larger) batch size, NOT the sitemap + # control-panel max_items cap (50) — otherwise SEO discovery of the + # long tail via /seo_html would be truncated. + self.assertEqual(view.b_size, view.DEFAULT_BATCH_SIZE) + @patch("imio.smartweb.core.contents.rest.view.get_endpoint_data") @patch("imio.smartweb.core.contents.rest.view.format_sitemap_items") def test_seo_hidden_react_links_batching(self, mock_format, mock_endpoint): diff --git a/src/imio/smartweb/core/tests/test_sitemap.py b/src/imio/smartweb/core/tests/test_sitemap.py index 4e27f15a..9a7ef333 100644 --- a/src/imio/smartweb/core/tests/test_sitemap.py +++ b/src/imio/smartweb/core/tests/test_sitemap.py @@ -179,38 +179,58 @@ def test_sitemap(self, mock_news, m): ), ) def test_site_map_for_user_display(self, mock_news): - sitemap = CatalogSiteMap(self.portal, self.request) - # 3 authentic sources - self.assertEqual(len(sitemap.siteMap().get("children")), 3) - self.assertNotIn( - "Folder", - [child.get("Title") for child in sitemap.siteMap().get("children")], - ) + # Keep the "empty source" assertions hermetic: without mocking, the + # remote calls would reach a live DIRECTORY_URL/EVENTS_URL/NEWS_URL + # instance if one happens to run (e.g. a dev server on :8080), which + # returns a truthy response and adds a spurious seo entry, breaking + # "0 children". Mock external HTTP only (plone-testing R1). + with ( + patch( + "imio.smartweb.core.contents.rest.directory.endpoint.BaseDirectoryEndpoint.__call__", + return_value={}, + ), + patch( + "imio.smartweb.core.contents.rest.events.endpoint.BaseEventsEndpoint.__call__", + return_value={}, + ), + patch( + "imio.smartweb.core.contents.rest.news.endpoint.BaseNewsEndpoint.__call__", + return_value={}, + ), + ): + sitemap = CatalogSiteMap(self.portal, self.request) + # 3 authentic sources + self.assertEqual(len(sitemap.siteMap().get("children")), 3) + self.assertNotIn( + "Folder", + [child.get("Title") for child in sitemap.siteMap().get("children")], + ) - # Publish folder and page (private content don't appear in sitemap) - api.content.transition(self.folder, "publish") - api.content.transition(self.page, "publish") - sitemap = CatalogSiteMap(self.portal, self.request) - self.assertEqual(len(sitemap.siteMap().get("children")), 4) - self.assertIn( - "Folder", - [child.get("Title") for child in sitemap.siteMap().get("children")], - ) - folder_entry = [ - child - for child in sitemap.siteMap().get("children") - if child.get("Title") == "Folder" - ][0] - self.assertIn( - "Page 1", [child.get("Title") for child in folder_entry.get("children")] - ) + # Publish folder and page (private content don't appear in sitemap) + api.content.transition(self.folder, "publish") + api.content.transition(self.page, "publish") + sitemap = CatalogSiteMap(self.portal, self.request) + self.assertEqual(len(sitemap.siteMap().get("children")), 4) + self.assertIn( + "Folder", + [child.get("Title") for child in sitemap.siteMap().get("children")], + ) + folder_entry = [ + child + for child in sitemap.siteMap().get("children") + if child.get("Title") == "Folder" + ][0] + self.assertIn( + "Page 1", + [child.get("Title") for child in folder_entry.get("children")], + ) - directory_entry = [ - child - for child in sitemap.siteMap().get("children") - if child.get("Title") == "directory view" - ][0] - self.assertEqual(len(directory_entry.get("children")), 0) + directory_entry = [ + child + for child in sitemap.siteMap().get("children") + if child.get("Title") == "directory view" + ][0] + self.assertEqual(len(directory_entry.get("children")), 0) cache = choose_cache("imio.smartweb.core.browser.sitemap.get_endpoint_data") cache.ramcache.invalidateAll() @@ -232,9 +252,272 @@ def test_bad_portal_type(self): obj = Mock() obj.portal_type = None request = Mock() - result = get_endpoint_data(obj, request) + result = get_endpoint_data(obj, request, 50, None, None) assert result == {} + def test_get_filter_sort(self): + from imio.smartweb.core.browser.sitemap import get_filter_sort + + self.assertEqual( + get_filter_sort("imio.smartweb.DirectoryView", "most_recent"), + ("created", "descending"), + ) + self.assertEqual( + get_filter_sort("imio.smartweb.NewsView", "most_recent"), + (None, None), + ) + self.assertEqual( + get_filter_sort("imio.smartweb.EventsView", "most_recent"), + (None, None), + ) + + def test_get_sitemap_sources_config_fallback(self): + from imio.smartweb.core.browser.sitemap import ( + get_sitemap_sources_config, + ) + + with patch( + "imio.smartweb.core.browser.sitemap.api.portal.get_registry_record", + return_value=None, + ): + config = get_sitemap_sources_config() + self.assertEqual( + set(config), + { + "imio.smartweb.EventsView", + "imio.smartweb.NewsView", + "imio.smartweb.DirectoryView", + }, + ) + for cfg in config.values(): + self.assertTrue(cfg["enabled"]) + self.assertEqual(cfg["max_items"], 50) + self.assertEqual(cfg["item_filter"], "most_recent") + + @patch( + "imio.smartweb.core.contents.rest.news.endpoint.BaseNewsEndpoint._get_news_folders_uids_and_title_from_entity", + return_value=( + ["64f4cbee9a394a018a951f6d94452914"], + {"64f4cbee9a394a018a951f6d94452914": "News Folder title"}, + ), + ) + def test_site_map_html_respects_enabled(self, mock_news): + # Disable Directory -> its remote items are not expanded even when the + # endpoint returns contacts. + api.portal.set_registry_record( + "smartweb.sitemap_authentic_sources", + [ + { + "source_type": "imio.smartweb.EventsView", + "enabled": True, + "max_items": 50, + "item_filter": "most_recent", + }, + { + "source_type": "imio.smartweb.NewsView", + "enabled": True, + "max_items": 50, + "item_filter": "most_recent", + }, + { + "source_type": "imio.smartweb.DirectoryView", + "enabled": False, + "max_items": 50, + "item_filter": "most_recent", + }, + ], + ) + cache = choose_cache("imio.smartweb.core.browser.sitemap.get_endpoint_data") + cache.ramcache.invalidateAll() + with patch( + "imio.smartweb.core.contents.rest.directory.endpoint.BaseDirectoryEndpoint.__call__", + return_value=self.json_rest_directory, + ): + sitemap = CatalogSiteMap(self.portal, self.request) + directory_entry = [ + c + for c in sitemap.siteMap().get("children") + if c.get("Title") == "directory view" + ][0] + self.assertEqual(len(directory_entry.get("children")), 0) + + @patch( + "imio.smartweb.core.contents.rest.news.endpoint.BaseNewsEndpoint._get_news_folders_uids_and_title_from_entity", + return_value=( + ["64f4cbee9a394a018a951f6d94452914"], + {"64f4cbee9a394a018a951f6d94452914": "News Folder title"}, + ), + ) + def test_site_map_html_caps_max_items(self, mock_news): + # Directory endpoint returns several contacts; max_items=2 caps to 2. + api.portal.set_registry_record( + "smartweb.sitemap_authentic_sources", + [ + { + "source_type": "imio.smartweb.EventsView", + "enabled": True, + "max_items": 50, + "item_filter": "most_recent", + }, + { + "source_type": "imio.smartweb.NewsView", + "enabled": True, + "max_items": 50, + "item_filter": "most_recent", + }, + { + "source_type": "imio.smartweb.DirectoryView", + "enabled": True, + "max_items": 2, + "item_filter": "most_recent", + }, + ], + ) + cache = choose_cache("imio.smartweb.core.browser.sitemap.get_endpoint_data") + cache.ramcache.invalidateAll() + with patch( + "imio.smartweb.core.contents.rest.directory.endpoint.BaseDirectoryEndpoint.__call__", + return_value=self.json_rest_directory, + ): + sitemap = CatalogSiteMap(self.portal, self.request) + directory_entry = [ + c + for c in sitemap.siteMap().get("children") + if c.get("Title") == "directory view" + ][0] + # format_sitemap_items appends one extra "seo_html" entry, so a + # 2-item cap yields 2 items + 1 seo entry = 3 children. + self.assertEqual(len(directory_entry.get("children")), 3) + + def test_sitemap_sources_config_default(self): + rows = api.portal.get_registry_record("smartweb.sitemap_authentic_sources") + self.assertEqual(len(rows), 3) + by_type = {r["source_type"]: r for r in rows} + self.assertEqual( + set(by_type), + { + "imio.smartweb.EventsView", + "imio.smartweb.NewsView", + "imio.smartweb.DirectoryView", + }, + ) + for row in rows: + self.assertTrue(row["enabled"]) + self.assertEqual(row["max_items"], 50) + self.assertEqual(row["item_filter"], "most_recent") + + @patch( + "imio.smartweb.core.contents.rest.news.endpoint.BaseNewsEndpoint." + "_get_news_folders_uids_and_title_from_entity", + return_value=( + ["64f4cbee9a394a018a951f6d94452914"], + {"64f4cbee9a394a018a951f6d94452914": "News Folder title"}, + ), + ) + def test_endpoint_sort_override(self, mock_news): + from imio.smartweb.core.contents.rest.directory.endpoint import ( + DirectoryEndpoint, + ) + from imio.smartweb.core.contents.rest.events.endpoint import EventsEndpoint + from imio.smartweb.core.contents.rest.news.endpoint import NewsEndpoint + + # Directory: default alphabetical, overridable to created/descending. + ep = DirectoryEndpoint(self.rest_directory, self.request) + self.assertIn("sort_on=sortable_title", ep.query_url) + ep = DirectoryEndpoint( + self.rest_directory, + self.request, + sort_on="created", + sort_order="descending", + ) + url = ep.query_url + self.assertIn("sort_on=created", url) + self.assertIn("sort_order=descending", url) + self.assertNotIn("sort_on=sortable_title", url) + + # Events: native event_dates preserved when no override. + ep = EventsEndpoint(self.rest_agenda, self.request) + self.assertIn("sort_on=event_dates", ep.query_url) + + # News: native effective/descending preserved when no override. + ep = NewsEndpoint(self.rest_news, self.request) + self.assertIn("sort_on=effective", ep.query_url) + self.assertIn("sort_order=descending", ep.query_url) + + def test_sitemap_source_type_is_not_display_mode(self): + # Regression: source_type was declared mode="display". A display-mode + # column is not submitted, so DictRow validation rejected every row on + # save ("Le système n'a pas pu traiter la valeur fournie" / "Champ + # obligatoire"). It must stay a real (input) widget so its per-row + # value is posted and survives extraction. + from imio.smartweb.core.browser.controlpanel_siteadmin import ( + ISitemapSourceRowSchema, + ) + from plone.autoform.interfaces import MODES_KEY + + modes = ISitemapSourceRowSchema.queryTaggedValue(MODES_KEY, []) + display_fields = [name for _, name, mode in modes if mode == "display"] + self.assertNotIn("source_type", display_fields) + + def test_frozen_label_widget_renders_full_token(self): + # The Source column widget renders a read-only label (the term title) + # plus a hidden input carrying the FULL token, whether the DataGrid + # feeds it a raw token string or a list of tokens. Regression: + # self.value[0] on a string rendered a single character ("i") as both + # label and submitted value. + from imio.smartweb.core.browser.controlpanel_siteadmin import ( + FrozenLabelFieldWidget, + ) + from imio.smartweb.core.browser.controlpanel_siteadmin import ( + ISitemapSourceRowSchema, + ) + from z3c.form.testing import TestRequest + + field = ISitemapSourceRowSchema["source_type"].bind(self.portal) + + # Raw token string (what the DataGrid object widget actually feeds). + widget = FrozenLabelFieldWidget(field, TestRequest()) + widget.update() + widget.value = "imio.smartweb.NewsView" + html = widget.render() + self.assertIn('value="imio.smartweb.NewsView"', html) + self.assertNotIn('value="i"', html) + self.assertIn("Actualités", html) + + # List of tokens (what a stand-alone SelectWidget holds). + widget = FrozenLabelFieldWidget(field, TestRequest()) + widget.update() + widget.value = ["imio.smartweb.EventsView"] + html = widget.render() + self.assertIn('value="imio.smartweb.EventsView"', html) + self.assertIn("Agenda", html) + + def test_sitemap_config_guard_rejects_incomplete_source_set(self): + # The applyChanges guard must reject a grid that does not list each + # authentic source exactly once (protects against an edited/duplicated + # source_type now that the column is an editable Choice). + from imio.smartweb.core.browser.controlpanel_siteadmin import ( + SmartwebSiteAdminControlPanelForm, + ) + + form = SmartwebSiteAdminControlPanelForm(self.portal, self.request) + incomplete = [ + { + "source_type": "imio.smartweb.EventsView", + "enabled": True, + "max_items": 50, + "item_filter": "most_recent", + }, + { + "source_type": "imio.smartweb.NewsView", + "enabled": True, + "max_items": 50, + "item_filter": "most_recent", + }, + ] + result = form.applyChanges({"sitemap_authentic_sources": incomplete}) + self.assertFalse(result) + def uncompress(self, sitemapdata): sio = BytesIO(sitemapdata) unzipped = GzipFile(fileobj=sio) diff --git a/src/imio/smartweb/core/upgrades/configure.zcml b/src/imio/smartweb/core/upgrades/configure.zcml index d5c08cb1..5aa5945f 100644 --- a/src/imio/smartweb/core/upgrades/configure.zcml +++ b/src/imio/smartweb/core/upgrades/configure.zcml @@ -1238,4 +1238,13 @@ /> + + diff --git a/src/imio/smartweb/core/upgrades/upgrades.py b/src/imio/smartweb/core/upgrades/upgrades.py index 5d5d37a2..00db5ebb 100644 --- a/src/imio/smartweb/core/upgrades/upgrades.py +++ b/src/imio/smartweb/core/upgrades/upgrades.py @@ -316,3 +316,21 @@ def uninstall_sendinblue(context): portal_setup = api.portal.get_tool("portal_setup") portal_setup.runAllImportStepsFromProfile("profile-collective.sendinblue:uninstall") logger.info("Sendinblue uninstalled successfully.") + + +def add_sitemap_authentic_sources_registry(context): + """(Re)create the smartweb.sitemap_authentic_sources control-panel record. + + The field type changed from a ``List(Choice)`` multiselect to a + ``List(DictRow)`` DataGridField. On a dev instance where an earlier run of + this step created the old-typed record, we delete it first to avoid a + persistent field-type conflict, then re-import the registry so the new + DataGridField is registered with its default rows. + """ + registry = api.portal.get_tool("portal_registry") + if "smartweb.sitemap_authentic_sources" in registry: + del registry.records["smartweb.sitemap_authentic_sources"] + logger.info("Removed obsolete smartweb.sitemap_authentic_sources record.") + portal_setup = api.portal.get_tool("portal_setup") + portal_setup.runImportStepFromProfile(PROFILEID, "plone.app.registry") + logger.info("smartweb.sitemap_authentic_sources registry record ensured.")