diff --git a/CHANGES.rst b/CHANGES.rst
index 670d60d..0d4afcc 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,7 +5,17 @@ Changelog
1.0.10 (unreleased)
-------------------
-- Nothing changed yet.
+- Use lxml for the xml generation.
+ [fedevancin]
+- at_map views are now disabled by default in the controlpanel.
+ [fedevancin]
+- Added the layer to the at_map views.
+ [fedevancin]
+- Return NotFound when views are disabled, instead of empty objects.
+ [fedevancin]
+- Internal links in the category fields now always use the public frontend
+ domain instead of the backend one.
+ [fedevancin]
1.0.9 (2026-07-23)
diff --git a/setup.py b/setup.py
index 02ff8f9..ca14707 100644
--- a/setup.py
+++ b/setup.py
@@ -55,6 +55,7 @@
"z3c.jbot",
"plone.api>=1.8.4",
"plone.app.dexterity",
+ "lxml",
],
extras_require={
"test": [
diff --git a/src/rer/linkmap/browser/configure.zcml b/src/rer/linkmap/browser/configure.zcml
index e895ab3..62d79f4 100644
--- a/src/rer/linkmap/browser/configure.zcml
+++ b/src/rer/linkmap/browser/configure.zcml
@@ -9,6 +9,7 @@
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
class=".views.ATMapJSONView"
permission="zope2.View"
+ layer="rer.linkmap.interfaces.IBrowserLayer"
/>
diff --git a/src/rer/linkmap/browser/views.py b/src/rer/linkmap/browser/views.py
index b7f698a..1ce04c9 100644
--- a/src/rer/linkmap/browser/views.py
+++ b/src/rer/linkmap/browser/views.py
@@ -1,6 +1,9 @@
+from AccessControl import Unauthorized
from Acquisition import aq_base
from json import dumps
+from lxml import etree
from plone import api
+from plone.registry.interfaces import IRegistry
from Products.Five import BrowserView
from rer.linkmap.linkmap import CATEGORY_C1
from rer.linkmap.linkmap import CATEGORY_KEYS
@@ -8,8 +11,17 @@
from rer.linkmap.linkmap import is_valid_date
from rer.linkmap.linkmap import is_valid_url
from rer.linkmap.linkmap import today_date_string
-from xml.sax.saxutils import escape
+from urllib.parse import urlsplit
+from urllib.parse import urlunsplit
from zExceptions import NotFound
+from zope.component import getUtility
+
+try:
+ from plone.volto.interfaces import IVoltoSettings
+
+ HAS_PLONE_VOLTO = True
+except ImportError:
+ HAS_PLONE_VOLTO = False
REGISTRY_PREFIX = "rer.linkmap.controlpanels.settings.ILinkMapSettings"
ROOT_KEY = "amministrazione_trasparente"
@@ -24,11 +36,11 @@ def get_registry_value(field_name, default=""):
def get_expose_json():
- return get_registry_value("expose_json", default=True)
+ return get_registry_value("expose_json", default=False)
def get_expose_xml():
- return get_registry_value("expose_xml", default=True)
+ return get_registry_value("expose_xml", default=False)
def get_data_ultima_modifica():
@@ -38,20 +50,70 @@ def get_data_ultima_modifica():
return today_date_string()
-def build_category_map_from_fields():
+def get_frontend_url():
+ """Return the public (frontend) base url for this Plone site.
+
+ Editors fill in the category fields as plain absolute urls, and they
+ often copy them while browsing the backend, so internal links may end
+ up with the backend/IAM domain baked in. Falls back to the current
+ portal absolute_url() when plone.volto is not installed or
+ ``volto.frontend_domain`` has not been configured.
+ """
+ portal_url = api.portal.get().absolute_url()
+ if not HAS_PLONE_VOLTO:
+ return portal_url
+ registry = getUtility(IRegistry)
+ settings = registry.forInterface(IVoltoSettings, prefix="volto", check=False)
+ frontend_domain = (getattr(settings, "frontend_domain", "") or "").rstrip("/")
+ if not frontend_domain or frontend_domain == "http://localhost:3000":
+ return portal_url
+ return frontend_domain
+
+
+def resolve_internal_url(value, frontend_url):
+ """If ``value`` points to an object inside this Plone site, rewrite it
+ so that it always uses the public frontend domain, regardless of the
+ domain that was used to author it (e.g. the backend/IAM domain).
+ External urls are returned unchanged.
+ """
+ parsed = urlsplit(value)
+ path = parsed.path.strip("/")
+ if not path:
+ return value
+ portal = api.portal.get()
+ try:
+ target = portal.unrestrictedTraverse(path, None)
+ except (AttributeError, KeyError, TypeError, ValueError, Unauthorized):
+ target = None
+ if target is None:
+ return value
+ frontend_parts = urlsplit(frontend_url)
+ return urlunsplit(
+ (
+ frontend_parts.scheme,
+ frontend_parts.netloc,
+ parsed.path,
+ parsed.query,
+ parsed.fragment,
+ )
+ )
+
+
+def build_category_map_from_fields(frontend_url):
"""Build category map from individual field values."""
category_map = {}
for key in CATEGORY_KEYS:
value = get_registry_value(key)
if value and is_valid_url(value.strip()):
- category_map[key] = value.strip()
+ category_map[key] = resolve_internal_url(value.strip(), frontend_url)
return category_map
def build_payload():
data_ultima_modifica = get_data_ultima_modifica()
- category_map = build_category_map_from_fields()
- ensure_required_root_url(category_map, api.portal.get().absolute_url())
+ frontend_url = get_frontend_url()
+ category_map = build_category_map_from_fields(frontend_url)
+ ensure_required_root_url(category_map, frontend_url)
payload = {
"data_ultima_modifica": data_ultima_modifica,
@@ -60,58 +122,58 @@ def build_payload():
return payload
-def build_empty_xml():
- return (
- '\n'
- f''
- )
+def _qname(tag):
+ return f"{{{NAMESPACE}}}{tag}"
+
+
+def _serialize_xml(root):
+ return etree.tostring(
+ root, xml_declaration=True, encoding="utf-8", pretty_print=True
+ ).decode("utf-8")
def build_xml(payload):
- root_open = f''
- lines = [
- '',
- root_open,
- f" {escape(payload['data_ultima_modifica'])}"
- "",
- " ", ""])
- return "\n".join(lines)
+ key_node = etree.SubElement(category_node, _qname(map_key))
+ key_node.text = map_url
+
+ return _serialize_xml(root)
class ATMapJSONView(BrowserView):
def __call__(self):
if aq_base(self.context) is not aq_base(api.portal.get()):
raise NotFound()
+ if not get_expose_json():
+ raise NotFound()
self.request.response.setHeader(
"Content-Type", "application/json; charset=utf-8"
)
- if not get_expose_json():
- return "{}"
payload = build_payload()
- return dumps(payload, indent=2, ensure_ascii=False, sort_keys=True)
+ return dumps(payload, indent=2, ensure_ascii=False)
class ATMapXMLView(BrowserView):
def __call__(self):
if aq_base(self.context) is not aq_base(api.portal.get()):
raise NotFound()
+ if not get_expose_xml():
+ raise NotFound()
self.request.response.setHeader(
"Content-Type", "application/xml; charset=utf-8"
)
- if not get_expose_xml():
- return build_empty_xml()
payload = build_payload()
return build_xml(payload)
diff --git a/src/rer/linkmap/controlpanels/settings.py b/src/rer/linkmap/controlpanels/settings.py
index e672d60..9be611a 100644
--- a/src/rer/linkmap/controlpanels/settings.py
+++ b/src/rer/linkmap/controlpanels/settings.py
@@ -33,7 +33,7 @@ class ILinkMapSettings(Interface):
"formato JSON.",
),
required=False,
- default=True,
+ default=False,
)
expose_xml = Bool(
@@ -44,7 +44,7 @@ class ILinkMapSettings(Interface):
"formato XML.",
),
required=False,
- default=True,
+ default=False,
)
data_ultima_modifica = TextLine(
diff --git a/src/rer/linkmap/tests/test_linkmap_views.py b/src/rer/linkmap/tests/test_linkmap_views.py
index 7ab3b77..b13a9cf 100644
--- a/src/rer/linkmap/tests/test_linkmap_views.py
+++ b/src/rer/linkmap/tests/test_linkmap_views.py
@@ -5,8 +5,11 @@
from plone.app.testing import setRoles
from plone.app.testing import TEST_USER_ID
from rer.linkmap.controlpanels.settings import ILinkMapSettings
+from rer.linkmap.interfaces import IBrowserLayer
from rer.linkmap.testing import RER_LINKMAP_INTEGRATION_TESTING # noqa: E501
from zExceptions import NotFound
+from zope.interface import alsoProvides
+from zope.interface import noLongerProvides
import unittest
@@ -63,9 +66,8 @@ def test_json_view_returns_schema_shape(self):
"https://www.example.org/disposizioni-generali"
)
- def test_json_view_disabled_returns_blank_object(self):
- """Test that JSON view returns an empty object when expose_json is
- False, as if the system was not configured at all."""
+ def test_json_view_disabled_returns_404(self):
+ """Test that JSON view raises NotFound when expose_json is False."""
api.portal.set_registry_record(
"expose_json",
False,
@@ -77,16 +79,14 @@ def test_json_view_disabled_returns_blank_object(self):
interface=ILinkMapSettings,
)
- output = api.content.get_view(
+ view = api.content.get_view(
name="at_map.json", context=self.portal, request=self.request
- )()
-
- assert "application/json" in self.request.RESPONSE.getHeader("Content-Type")
- assert output == "{}"
+ )
+ with self.assertRaises(NotFound):
+ view()
- def test_xml_view_disabled_returns_blank_object(self):
- """Test that XML view returns an empty root element when
- expose_xml is False, as if the system was not configured at all."""
+ def test_xml_view_disabled_returns_404(self):
+ """Test that XML view raises NotFound when expose_xml is False."""
api.portal.set_registry_record(
"expose_xml",
False,
@@ -98,17 +98,11 @@ def test_xml_view_disabled_returns_blank_object(self):
interface=ILinkMapSettings,
)
- output = api.content.get_view(
+ view = api.content.get_view(
name="at_map.xml", context=self.portal, request=self.request
- )()
-
- assert "application/xml" in self.request.RESPONSE.getHeader("Content-Type")
- root = safe_fromstring(output)
- assert (
- root.tag
- == "{https://guida-servizi.anticorruzione.it/trasparenza}amministrazione_trasparente"
)
- assert len(root) == 0
+ with self.assertRaises(NotFound):
+ view()
def test_json_view_ensures_required_root_key(self):
"""Test that JSON view adds required amministrazione_trasparente key."""
@@ -219,6 +213,28 @@ def test_xml_view_not_accessible_from_non_root_context(self):
with self.assertRaises((NotFound, AttributeError)):
page.restrictedTraverse("@@at_map.xml")()
+ def test_json_view_not_registered_without_browser_layer(self):
+ """Test that at_map.json is only registered while the add-on's
+ browser layer is applied to the request, i.e. while the add-on
+ is installed."""
+ noLongerProvides(self.request, IBrowserLayer)
+ try:
+ with self.assertRaises((NotFound, AttributeError)):
+ self.portal.restrictedTraverse("@@at_map.json")()
+ finally:
+ alsoProvides(self.request, IBrowserLayer)
+
+ def test_xml_view_not_registered_without_browser_layer(self):
+ """Test that at_map.xml is only registered while the add-on's
+ browser layer is applied to the request, i.e. while the add-on
+ is installed."""
+ noLongerProvides(self.request, IBrowserLayer)
+ try:
+ with self.assertRaises((NotFound, AttributeError)):
+ self.portal.restrictedTraverse("@@at_map.xml")()
+ finally:
+ alsoProvides(self.request, IBrowserLayer)
+
def test_json_view_with_multiple_fields(self):
"""Test JSON view with multiple category fields."""
api.portal.set_registry_record(
@@ -304,22 +320,25 @@ def test_empty_fields_not_included_in_output(self):
assert "amministrazione_trasparente" in c1
assert "disposizioni_generali" not in c1
- def test_default_expose_flags_are_true(self):
- """Test that expose_json and expose_xml default to True."""
- # Don't explicitly set the flags, verify they default to True
+ def test_default_expose_flags_are_false(self):
+ """Test that expose_json and expose_xml default to False, i.e. the
+ views are not exposed unless explicitly enabled."""
+ # Don't explicitly set the flags, verify they default to False
api.portal.set_registry_record(
"amministrazione_trasparente",
"https://example.org/at",
interface=ILinkMapSettings,
)
- # Both views should work with defaults
- json_output = api.content.get_view(
+ # Both views should raise NotFound with defaults
+ json_view = api.content.get_view(
name="at_map.json", context=self.portal, request=self.request
- )()
- xml_output = api.content.get_view(
+ )
+ xml_view = api.content.get_view(
name="at_map.xml", context=self.portal, request=self.request
- )()
+ )
- assert loads(json_output)
- assert "amministrazione_trasparente" in xml_output
+ with self.assertRaises(NotFound):
+ json_view()
+ with self.assertRaises(NotFound):
+ xml_view()