diff --git a/CHANGES.rst b/CHANGES.rst
index a6a48184..46b8ad48 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -4,8 +4,10 @@ Changelog
3.47.2 (unreleased)
-------------------
-- Nothing changed yet.
-
+- Added viewlet on SubTemplate to show its use in merge_templates field.
+ [sgeulette]
+- Added `sub-templates-usage` view listing every sub-template usage.
+ [sgeulette]
3.47.1 (2026-03-26)
-------------------
diff --git a/src/collective/documentgenerator/browser/configure.zcml b/src/collective/documentgenerator/browser/configure.zcml
index a811769f..b4356cde 100644
--- a/src/collective/documentgenerator/browser/configure.zcml
+++ b/src/collective/documentgenerator/browser/configure.zcml
@@ -45,7 +45,7 @@
class="collective.documentgenerator.browser.converter.DocumentConvertView"
permission="zope2.View"
/>
-
+
+
+
diff --git a/src/collective/documentgenerator/browser/sub_templates_usage.pt b/src/collective/documentgenerator/browser/sub_templates_usage.pt
new file mode 100644
index 00000000..eafd48df
--- /dev/null
+++ b/src/collective/documentgenerator/browser/sub_templates_usage.pt
@@ -0,0 +1,56 @@
+
+
+
+
+
+ Sub-templates usages
+
+
+
+
+
+ | Sub-template |
+ Path |
+ Using templates |
+
+
+
+
+
+ |
+ Sub-template
+ |
+ not used |
+
+
+ |
+ Sub-template
+ |
+ Folder / Sub folder |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
diff --git a/src/collective/documentgenerator/browser/templates_listing.pt b/src/collective/documentgenerator/browser/templates_listing.pt
index ce3e5886..73b0e1e9 100644
--- a/src/collective/documentgenerator/browser/templates_listing.pt
+++ b/src/collective/documentgenerator/browser/templates_listing.pt
@@ -10,6 +10,9 @@
+ Sub-templates usage
+
diff --git a/src/collective/documentgenerator/browser/views.py b/src/collective/documentgenerator/browser/views.py
index 44870cbe..5aeb3e08 100644
--- a/src/collective/documentgenerator/browser/views.py
+++ b/src/collective/documentgenerator/browser/views.py
@@ -7,6 +7,8 @@
from collective.documentgenerator.content.pod_template import MailingLoopTemplate
from collective.documentgenerator.content.pod_template import SubTemplate
from collective.documentgenerator.content.style_template import IStyleTemplate
+from collective.documentgenerator.utils import get_pod_templates_using
+from collective.documentgenerator.utils import group_templates_by_path
from collective.documentgenerator.utils import translate as _
from OFS.interfaces import IOrderedContainer
from plone import api
@@ -94,6 +96,22 @@ def __call__(self, local_search=None, search_depth=None):
return self.index()
+class SubTemplatesUsage(BrowserView):
+ """Overview listing, for each sub-template, the POD templates that use
+ it in their 'merge_templates' field, grouped by the path they live in."""
+
+ def sub_templates_usage(self):
+ """Return a list of {'sub_template': brain, 'groups': [...]} entries, one
+ per sub-template (including unused ones), ordered by title. 'groups' is
+ the per-path grouping produced for the dedicated viewlet."""
+ catalog = api.portal.get_tool("portal_catalog")
+ result = []
+ for brain in catalog(portal_type="SubTemplate", sort_on="sortable_title"):
+ templates = get_pod_templates_using(brain.getObject())
+ result.append({"sub_template": brain, "groups": group_templates_by_path(templates)})
+ return result
+
+
class DisplayChildrenPodTemplateProvider(ContentProviderBase):
template = ViewPageTemplateFile("children_pod_template.pt")
diff --git a/src/collective/documentgenerator/tests/test_sub_templates_usage_view.py b/src/collective/documentgenerator/tests/test_sub_templates_usage_view.py
new file mode 100644
index 00000000..746f09f3
--- /dev/null
+++ b/src/collective/documentgenerator/tests/test_sub_templates_usage_view.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+
+from collective.documentgenerator.testing import POD_TEMPLATE_INTEGRATION
+
+import unittest
+
+
+class TestSubTemplatesUsageView(unittest.TestCase):
+ """Test the 'sub-templates-usage' view.
+
+ The demo profile creates a single 'sub_template' used by
+ 'test_template_multiple' and 'test_template_bis', both in 'podtemplates'.
+ """
+
+ layer = POD_TEMPLATE_INTEGRATION
+
+ def setUp(self):
+ self.view = self.layer["portal"].restrictedTraverse("@@sub-templates-usage")
+
+ def test_sub_templates_usage(self):
+ entries = self.view.sub_templates_usage()
+ # one entry per sub-template (here the single demo 'sub_template')
+ self.assertEqual([e["sub_template"].getId for e in entries], ["sub_template"])
+ # both using templates share the same folder -> one group, sorted by title
+ groups = entries[0]["groups"]
+ self.assertEqual(len(groups), 1)
+ self.assertEqual([t.getId() for t in groups[0]["templates"]], ["test_template_bis", "test_template_multiple"])
diff --git a/src/collective/documentgenerator/tests/test_viewlets.py b/src/collective/documentgenerator/tests/test_viewlets.py
new file mode 100644
index 00000000..f55f40b1
--- /dev/null
+++ b/src/collective/documentgenerator/tests/test_viewlets.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+
+from collective.documentgenerator.testing import POD_TEMPLATE_INTEGRATION
+from collective.documentgenerator.viewlets.sub_template_usage import SubTemplateUsageViewlet
+
+import unittest
+
+
+class TestSubTemplateUsageViewlet(unittest.TestCase):
+ """Test the 'sub-template-usage' viewlet on a SubTemplate.
+
+ The demo profile creates a 'sub_template' used by 'test_template_multiple'
+ and 'test_template_bis', both in the 'podtemplates' folder.
+ """
+
+ layer = POD_TEMPLATE_INTEGRATION
+
+ def setUp(self):
+ portal = self.layer['portal']
+ sub_template = portal.podtemplates.sub_template
+ self.viewlet = SubTemplateUsageViewlet(sub_template, portal.REQUEST, None, None)
+
+ def test_available_and_using_templates_grouped_by_path(self):
+ self.assertTrue(self.viewlet.available())
+ groups = self.viewlet.get_using_templates_by_path()
+ # both templates live in the same folder -> a single group, sorted by title
+ self.assertEqual(len(groups), 1)
+ self.assertEqual([t.getId() for t in groups[0]['templates']],
+ ['test_template_bis', 'test_template_multiple'])
diff --git a/src/collective/documentgenerator/utils.py b/src/collective/documentgenerator/utils.py
index 578acd2f..41fd55e9 100644
--- a/src/collective/documentgenerator/utils.py
+++ b/src/collective/documentgenerator/utils.py
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
+from Acquisition import aq_inner
+from Acquisition import aq_parent
from appy.bin.odfclean import Cleaner
from appy.pod.renderer import Renderer
from collective.documentgenerator import _
@@ -184,6 +186,55 @@ def get_site_root_relative_path(obj):
)
+def title_path(obj):
+ """Return a breadcrumb-like path made of the Title of each level between the
+ site root (excluded) and ``obj`` (included)."""
+ portal_path = '/'.join(api.portal.get().getPhysicalPath())
+ titles = []
+ current = aq_inner(obj)
+ while current is not None:
+ current_path = '/'.join(current.getPhysicalPath())
+ if current_path == portal_path or not current_path.startswith(portal_path):
+ break
+ titles.append(safe_unicode(current.Title()))
+ current = aq_parent(aq_inner(current))
+ return u' / '.join(reversed(titles))
+
+
+def get_pod_templates_using(sub_template):
+ """Return the list of templates referencing ``sub_template`` in their
+ 'merge_templates' field."""
+ from collective.documentgenerator.content.pod_template import IConfigurablePODTemplate
+ catalog = getToolByName(sub_template, 'portal_catalog')
+ uid = sub_template.UID()
+ templates = []
+ for brain in catalog(object_provides=IConfigurablePODTemplate.__identifier__):
+ template = brain.getObject()
+ for line in getattr(template, 'merge_templates', None) or []:
+ if line.get('template') == uid:
+ templates.append(template)
+ break
+ return templates
+
+
+def group_templates_by_path(templates):
+ """Group ``templates`` by their container, returning a list of
+ {'path', 'title_path', 'templates'} dicts sorted by path then by title."""
+ grouped = {}
+ for template in templates:
+ parent = aq_parent(aq_inner(template))
+ path = get_site_root_relative_path(parent)
+ grouped.setdefault(path, {'title_path': title_path(parent), 'templates': []})
+ grouped[path]['templates'].append(template)
+ result = []
+ for path in sorted(grouped):
+ group = grouped[path]
+ group['templates'].sort(key=lambda t: safe_unicode(t.Title()).lower())
+ result.append({'path': path, 'title_path': group['title_path'],
+ 'templates': group['templates']})
+ return result
+
+
def temporary_file_name(suffix=''):
tmp_dir = os.getenv('CUSTOM_TMP', None)
if tmp_dir and not os.path.exists(tmp_dir):
@@ -286,6 +337,7 @@ def convert_file(afile, fmt="pdf", renderer=False, gen_context=None, delete_temp
:param renderer: whether to use appy.pod Renderer or converter script. Default to False.
:param gen_context: generation context dict passed to renderer
:param delete_temp_files:
+ :return: converted file content
"""
if renderer:
if not afile.filename.endswith('.odt'):
diff --git a/src/collective/documentgenerator/viewlets/configure.zcml b/src/collective/documentgenerator/viewlets/configure.zcml
index 095d07ff..00e54ef0 100644
--- a/src/collective/documentgenerator/viewlets/configure.zcml
+++ b/src/collective/documentgenerator/viewlets/configure.zcml
@@ -13,4 +13,13 @@
permission="zope2.View"
/>
+
+
diff --git a/src/collective/documentgenerator/viewlets/sub_template_usage.pt b/src/collective/documentgenerator/viewlets/sub_template_usage.pt
new file mode 100644
index 00000000..116e53c8
--- /dev/null
+++ b/src/collective/documentgenerator/viewlets/sub_template_usage.pt
@@ -0,0 +1,32 @@
+
+
+
+ Templates using this sub-template:
+
+
+
+
+ | Path |
+ Templates |
+
+
+
+
+ | Folder / Sub folder |
+
+
+ |
+
+
+
+
+
diff --git a/src/collective/documentgenerator/viewlets/sub_template_usage.py b/src/collective/documentgenerator/viewlets/sub_template_usage.py
new file mode 100644
index 00000000..e67e841b
--- /dev/null
+++ b/src/collective/documentgenerator/viewlets/sub_template_usage.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+
+from collective.documentgenerator.content.pod_template import ISubTemplate
+from collective.documentgenerator.utils import get_pod_templates_using
+from collective.documentgenerator.utils import group_templates_by_path
+from plone.app.layout.viewlets import ViewletBase
+from plone.memoize.view import memoize
+
+
+class SubTemplateUsageViewlet(ViewletBase):
+ """Display the POD templates that use the current sub-template in their
+ 'merge_templates' field, grouped by the folder path they live in."""
+
+ def available(self):
+ return ISubTemplate.providedBy(self.context) and bool(self.get_using_templates())
+
+ @memoize
+ def get_using_templates(self):
+ """Return the list of templates referencing the current sub-template
+ in their 'merge_templates' field."""
+ return get_pod_templates_using(self.context)
+
+ def get_using_templates_by_path(self):
+ """Return a list of {'path', 'title_path', 'templates'} groups of the
+ templates referencing the current sub-template, grouped by their
+ container and sorted by path then by title."""
+ return group_templates_by_path(self.get_using_templates())