Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
170 changes: 170 additions & 0 deletions src/imio/smartweb/core/browser/controlpanel_siteadmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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
(``<name>:list`` + ``<name>-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 (
'<span class="dgf-frozen-label">{label}</span>'
'<input type="hidden" name={name} value={token} />'
'<input type="hidden" name={marker} value="1" />'
).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(
Expand All @@ -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"),
Expand All @@ -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

Comment on lines 273 to +288

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

sorted() can crash on a None source_type instead of showing the status error.

source_type is required=False, so row.get("source_type") can legitimately be None for a row (e.g. the hidden input isn't posted). sorted(source_types) then mixes str and None, which raises TypeError: '<' not supported between instances of 'NoneType' and 'str' in Python 3 — turning the intended graceful rejection into an unhandled 500 for the site admin instead of the status message.

🛡️ Proposed fix to sort safely
         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):
+            if sorted(source_types, key=lambda v: (v is None, v)) != sorted(
+                SITEMAP_SOURCE_VOCABULARY.by_value
+            ):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
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, key=lambda v: (v is None, v)) != sorted(
SITEMAP_SOURCE_VOCABULARY.by_value
):
IStatusMessage(self.request).addStatusMessage(
_(
"The sitemap configuration must list each authentic "
"source exactly once."
),
type="error",
)
return False
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/imio/smartweb/core/browser/controlpanel_siteadmin.py` around lines 273 -
288, Update the sitemap validation in applyChanges so missing source_type values
are rejected through the existing status-message path without passing None and
strings to sorted(). Normalize or otherwise compare source_types safely, while
preserving the requirement that each SITEMAP_SOURCE_VOCABULARY.by_value entry
appears exactly once.

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")]
Expand Down
114 changes: 89 additions & 25 deletions src/imio/smartweb/core/browser/sitemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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 {}
)
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down
Loading
Loading