Skip to content
Merged
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
12 changes: 11 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"z3c.jbot",
"plone.api>=1.8.4",
"plone.app.dexterity",
"lxml",
],
extras_require={
"test": [
Expand Down
2 changes: 2 additions & 0 deletions src/rer/linkmap/browser/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
class=".views.ATMapJSONView"
permission="zope2.View"
layer="rer.linkmap.interfaces.IBrowserLayer"
/>

<browser:page
name="at_map.xml"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
class=".views.ATMapXMLView"
permission="zope2.View"
layer="rer.linkmap.interfaces.IBrowserLayer"
/>

</configure>
124 changes: 93 additions & 31 deletions src/rer/linkmap/browser/views.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
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
from rer.linkmap.linkmap import ensure_required_root_url
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"
Expand All @@ -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():
Expand All @@ -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,
Expand All @@ -60,58 +122,58 @@ def build_payload():
return payload


def build_empty_xml():
return (
'<?xml version="1.0" encoding="utf-8"?>\n'
f'<amministrazione_trasparente xmlns="{NAMESPACE}"/>'
)
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'<amministrazione_trasparente xmlns="{NAMESPACE}">'
lines = [
'<?xml version="1.0" encoding="utf-8"?>',
root_open,
f" <data_ultima_modifica>{escape(payload['data_ultima_modifica'])}"
"</data_ultima_modifica>",
" <map>",
]
root = etree.Element(_qname("amministrazione_trasparente"), nsmap={None: NAMESPACE})

category = CATEGORY_C1
data_node = etree.SubElement(root, _qname("data_ultima_modifica"))
data_node.text = payload["data_ultima_modifica"]

map_node = etree.SubElement(root, _qname("map"))

lines.append(f" <{category}>")
category = CATEGORY_C1
category_node = etree.SubElement(map_node, _qname(category))
map_values = payload.get(category, {})
for map_key in CATEGORY_KEYS:
map_url = map_values.get(map_key)
if not map_url:
continue
lines.append(f" <{map_key}>{escape(map_url)}</{map_key}>")
lines.append(f" </{category}>")
lines.extend([" </map>", "</amministrazione_trasparente>"])
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)
4 changes: 2 additions & 2 deletions src/rer/linkmap/controlpanels/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class ILinkMapSettings(Interface):
"formato JSON.",
),
required=False,
default=True,
default=False,
)

expose_xml = Bool(
Expand All @@ -44,7 +44,7 @@ class ILinkMapSettings(Interface):
"formato XML.",
),
required=False,
default=True,
default=False,
)

data_ultima_modifica = TextLine(
Expand Down
79 changes: 49 additions & 30 deletions src/rer/linkmap/tests/test_linkmap_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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."""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Loading