[html gen] Start generating HTML documentation for profiles
authorSylvain Thénault <sylvain.thenault@logilab.fr>
Thu, 21 Jul 2016 15:38:04 +0200
changeset 1466 a89ff4412318
parent 1437 f8446b60db7f
child 1468 b996f5890546
[html gen] Start generating HTML documentation for profiles Closes #11447493
entities/html_generation.py
i18n/en.po
i18n/fr.po
test/test_html_generation.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/entities/html_generation.py	Thu Jul 21 15:38:04 2016 +0200
@@ -0,0 +1,241 @@
+# copyright 2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr -- mailto:contact@logilab.fr
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+"""cubicweb-seda adapter classes for generation of a profile generation as HTML"""
+
+from cubes.seda.entities.profile_generation import SEDA2ExportAdapter, content_types
+from cubes.seda.entities.profile_generation import xselement_scheme_attribute
+
+_ = unicode
+
+
+def element_uml_cardinality(occ, card_entity):
+    """Return UML like cardinality for the given pyxst Occurence. Cardinality may be
+    overriden by the data model's user_cardinality value.
+    """
+    cardinality = getattr(card_entity, 'user_cardinality', None)
+    if cardinality is None:
+        minimum = occ.minimum
+        maximum = occ.maximum
+        if minimum == maximum == 1:
+            return '1'
+        elif maximum != 1:
+            maximum = 'n'
+        return '%s..%s' % (minimum, maximum)
+    else:
+        return cardinality
+
+
+def attribute_cardinality_as_string(occ, card_entity):
+    """Return 'optional' or 'mandatory' for the given pyxst attribute's Occurence. Cardinality may be
+    overriden by the data model's user_cardinality value.
+    """
+    cardinality = getattr(card_entity, 'user_cardinality', None)
+    if cardinality is None:
+        minimum = occ.minimum
+    else:
+        # XXX assert cardinality in ('0..1', '1'), cardinality
+        if cardinality[0] == '0':
+            minimum = 0
+        else:
+            minimum = 1
+    return _('mandatory') if minimum else _('optional')
+
+
+class SEDA2HTMLExport(SEDA2ExportAdapter):
+    """Adapter to build a HTML representation of a SEDA profile"""
+    __regid__ = 'SEDA-2.0.html'
+    extension = 'html'
+    namespaces = {}
+    css = '''
+    div {
+      margin-left: 1em;
+      color: #222;
+      margin-bottom: 2px;
+    }
+
+    h3 span.card {
+      font-size: 90%;
+    }
+
+    span {
+      font-size: 90%;
+      color: #555;
+    }
+    span.label {
+      font-weight: bold;
+      padding-left: 1px;
+    }
+
+    div.attribute {
+      border: 1px solid #BBB;
+      color: #888;
+      width: 40em;
+      font-size: 80%;
+      margin-bottom: 0;
+    }
+    div.attribute span {
+      border-left: 1px solid;
+      padding-left: 2px;
+      padding-right: 2px;
+    }
+    div.attribute span.label {
+      width: 33%;
+      float: left;
+    }
+    '''
+
+    def dump_etree(self):
+        """Return an XSD etree for the adapted SEDA profile."""
+        root = self.element('html')
+        head = self.element('head', root)
+        self.element('title', head, text=self.entity.dc_title())
+        self.element('meta', head, {'http-equiv': 'content-type',
+                                    'content': 'text/html; charset=UTF-8'})
+        self.element('style', head, text=self.css)
+        body = self.element('body', root)
+        self._dump(body)
+        return root
+
+    def init_transfer_element(self, xselement, root, entity):
+        self.element('h1', root, text=entity.title)
+        if entity.user_annotation:
+            self.element('div', root, text=entity.user_annotation,
+                         attributes={'class': 'description'})
+        return root
+
+    def jumped_element(self, profile_element):
+        return profile_element[-1]
+
+    def element_alternative(self, occ, profile_element, target_value, to_process, card_entity):
+        div = self.element('div', profile_element,
+                           attributes={'class': 'choice'})
+        self.title(div, self._cw._('Alternative'), occ, card_entity)
+        to_process[occ.target].append((target_value, div))
+
+    def element_sequence(self, occ, profile_element, target_value, to_process, card_entity):
+        div = self.element('div', profile_element,
+                           attributes={'class': 'sequence'})
+        self.title(div, self._cw._('Sequence'), occ, card_entity)
+        to_process[occ.target].append((target_value, div))
+
+    def element_xmlattribute(self, occ, profile_element, target_value, to_process, card_entity):
+        div = self.element('div', profile_element,
+                           attributes={'class': 'attribute'})
+        card = self._cw._(attribute_cardinality_as_string(occ, card_entity))
+        self.element('span', div, text=occ.target.local_name, attributes={'class': 'label'})
+        self.element('span', div, text=card, attributes={'class': 'card'})
+        fixed_value = self.serialize(target_value)
+        if isinstance(fixed_value, basestring):
+            self.element('span', div, text=fixed_value, attributes={'class': 'value'})
+        else:
+            span = self.element('span', div, attributes={'class': 'value'})
+            if fixed_value is not None:
+                span.append(fixed_value)
+
+    def element_xmlelement(self, occ, profile_element, target_value, to_process, card_entity):
+        xselement = occ.target
+        if isinstance(occ, dict):  # fake occurence introduced for some elements'content
+            return
+        attrs = {}
+        if hasattr(target_value, 'id'):
+            attrs['id'] = target_value.id
+        div = self.element('div', profile_element, attrs)
+        self.title(div, xselement.local_name, occ, card_entity)
+        annotation = getattr(card_entity, 'user_annotation', None)
+        if annotation:
+            self.element('div', div, text=annotation,
+                         attributes={'class': 'description'})
+        xstypes = content_types(xselement.textual_content_type)
+        self.fill_element(xselement, div, target_value, card_entity, xstypes)
+        if getattr(target_value, 'eid', None):  # value is an entity
+            to_process[xselement].append((target_value, div))
+
+    def fill_element(self, xselement, div, target_value, card_entity, xstypes=None):
+        if xselement.local_name == 'KeywordType':
+            list_div = self.element('div', div)
+            self.element('span', list_div, text=u'listVersionID',
+                         attributes={'class': 'label'})
+            if target_value:
+                list_value = target_value.scheme.description or target_value.scheme.dc_title()
+            else:
+                list_value = 'edition 2009'
+            self.element('span', list_div, text=list_value, attributes={'class': 'value'})
+
+        elif (xselement.local_name == 'KeywordReference' and card_entity.scheme):
+            self.concept_scheme_attribute(xselement, div, card_entity.scheme)
+
+        elif getattr(target_value, 'cw_etype', None) == 'Concept':
+            self.concept_scheme_attribute(xselement, div, target_value.scheme)
+        if xstypes:
+            ct_div = self.element('div', div)
+            self.element('span', ct_div, text=self._cw._('XSD content type'),
+                         attributes={'class': 'label'})
+            xstypes = self._cw._(' ALT_I18N ').join(u'xsd:' + xstype for xstype in xstypes)
+            self.element('span', ct_div, text=xstypes, attributes={'class': 'value'})
+        fixed_value = self.serialize(target_value)
+        if fixed_value is None:
+            return
+        value_div = self.element('div', div)
+        self.element('span', value_div, text=self._cw._('fixed value'),
+                     attributes={'class': 'label'})
+        if isinstance(fixed_value, basestring):
+            self.element('span', value_div, text=fixed_value, attributes={'class': 'value'})
+        else:
+            span = self.element('span', value_div, attributes={'class': 'value'})
+            span.append(fixed_value)
+
+    def concept_scheme_attribute(self, xselement, div, scheme):
+        try:
+            xsattr = xselement_scheme_attribute(xselement)
+        except KeyError:
+            return  # no attribute to define scheme (e.g. for language specification)
+        div = self.element('div', div)
+        self.element('span', div, text=xsattr, attributes={'class': 'label'})
+        self.element('a', self.element('span', div, attributes={'class': 'value'}),
+                     {'href': scheme.absolute_url()}, text=scheme.dc_title())
+
+    def title(self, div, label, occ, card_entity):
+        h3 = self.element('h3', div, text=label)
+        self.element('span', h3, attributes={'class': 'card'},
+                     text=u'[{0}]'.format(self._cw._(element_uml_cardinality(occ, card_entity))))
+
+    def serialize(self, value):
+        """Return value as None or some text or etree node to be inserted in the HTML DOM."""
+        if value is None:
+            return None
+        if hasattr(value, 'eid'):
+            if value.cw_etype in ('Agent', 'ConceptScheme'):
+                href, label = value.absolute_url(), value.dc_title()
+            elif value.cw_etype == 'Concept':
+                href = value.absolute_url()
+                try:
+                    label = value.labels['seda-2']
+                    if value.label() != label:
+                        label = '{0} ({1})'.format(label, value.label())
+                except KeyError:
+                    label = value.labels['en']
+            elif hasattr(value, 'id'):
+                # value is something in the profile which has a id (e.g. archive unit, data object)
+                href, label = '#{0}'.format(value.id), value.id
+            else:
+                return None  # intermediary entity
+            return self.element('a', self.element('span', attributes={'class': 'value'}),
+                                attributes={'href': href}, text=label)
+        else:
+            if isinstance(value, bool):
+                value = 'true' if value else 'false'
+            assert isinstance(value, basestring), repr(value)
+            return value
--- a/i18n/en.po	Wed Jul 20 11:53:51 2016 +0200
+++ b/i18n/en.po	Thu Jul 21 15:38:04 2016 +0200
@@ -83,6 +83,9 @@
 msgid "Agent_plural"
 msgstr ""
 
+msgid "Alternative"
+msgstr ""
+
 msgid ""
 "An agent is something that bears some form of responsibility for an activity "
 "taking place, for the existence of an entity, or for another agent's "
@@ -1796,6 +1799,9 @@
 msgid "SEDAwhen_plural"
 msgstr ""
 
+msgid "Sequence"
+msgstr ""
+
 msgid ""
 "Special relation from a concept scheme to a relation type, that may be used "
 "to restrict possible concept of a particular relation without depending on "
@@ -2663,6 +2669,9 @@
 msgid "This object is used as a relationship target of the following entities"
 msgstr ""
 
+msgid "XSD content type"
+msgstr ""
+
 msgid "add a Agent"
 msgstr ""
 
@@ -4325,6 +4334,9 @@
 msgid "final action: {0}"
 msgstr ""
 
+msgid "fixed value"
+msgstr ""
+
 msgid "format_litteral"
 msgstr ""
 
--- a/i18n/fr.po	Wed Jul 20 11:53:51 2016 +0200
+++ b/i18n/fr.po	Thu Jul 21 15:38:04 2016 +0200
@@ -83,6 +83,9 @@
 msgid "Agent_plural"
 msgstr "Agents"
 
+msgid "Alternative"
+msgstr "Choix"
+
 msgid ""
 "An agent is something that bears some form of responsibility for an activity "
 "taking place, for the existence of an entity, or for another agent's "
@@ -1799,6 +1802,9 @@
 msgid "SEDAwhen_plural"
 msgstr ""
 
+msgid "Sequence"
+msgstr "Séquence"
+
 msgid ""
 "Special relation from a concept scheme to a relation type, that may be used "
 "to restrict possible concept of a particular relation without depending on "
@@ -2666,6 +2672,9 @@
 msgid "This object is used as a relationship target of the following entities"
 msgstr "Cet objet est utilisé comme cible des relations suivantes :"
 
+msgid "XSD content type"
+msgstr "type de contenu"
+
 msgid "add a Agent"
 msgstr ""
 
@@ -4333,6 +4342,9 @@
 msgid "final action: {0}"
 msgstr "action finale : {0}"
 
+msgid "fixed value"
+msgstr "valeur fixe"
+
 msgid "format_litteral"
 msgstr "valeur"
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test/test_html_generation.py	Thu Jul 21 15:38:04 2016 +0200
@@ -0,0 +1,125 @@
+# copyright 2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+# contact http://www.logilab.fr -- mailto:contact@logilab.fr
+#
+# This program is free software: you can redistribute it and/or modify it under
+# the terms of the GNU Lesser General Public License as published by the Free
+# Software Foundation, either version 2.1 of the License, or (at your option)
+# any later version.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+# details.
+#
+# You should have received a copy of the GNU Lesser General Public License along
+# with this program. If not, see <http://www.gnu.org/licenses/>.
+"""cubicweb-seda unit tests for XSD profile generation"""
+
+from collections import namedtuple
+
+from lxml import etree
+
+from cubicweb.devtools.testlib import CubicWebTC
+
+from test_profile_generation import XmlTestMixin, SEDAExportFuncTCMixIn
+
+
+AttrDef = namedtuple('AttrDef', ['label', 'card', 'value'])
+
+
+class SEDAHTMLExportFuncTC(SEDAExportFuncTCMixIn, XmlTestMixin, CubicWebTC):
+    adapter_id = 'SEDA-2.0.html'
+
+    def setup_database(self):
+        super(SEDAHTMLExportFuncTC, self).setup_database()
+        with self.admin_access.client_cnx() as cnx:
+            some_concept = cnx.find('Concept').one()
+            lang_rtype = cnx.find('CWRType', name='seda_language_to').one()
+            some_concept.scheme.cw_set(scheme_relation_type=lang_rtype)
+            cnx.find('SEDALanguage').one().cw_set(seda_language_to=some_concept)
+            cnx.find('SEDAKeywordReference').one().cw_set(
+                seda_keyword_reference_to=some_concept,
+                seda_keyword_reference_to_scheme=some_concept.scheme)
+            cnx.commit()
+
+    def assertXmlValid(self, root):
+        pass  # no schema available for html 5
+
+    def get_element(self, profile, name):
+        elements = self.xpath(profile, '//h3[text()="{0}"]'.format(name))
+        self.assertEqual(len(elements), 1)
+        return elements[0].getparent()
+
+    def element_values(self, element):
+        el_defs = []
+        for div in self.xpath(element, 'div'):
+            el_def = {}
+            el_defs.append(el_def)
+            for span in self.xpath(div, 'span'):
+                if span:
+                    value = etree.tostring(span[0])
+                else:
+                    value = span.text
+                el_def[span.attrib['class']] = value
+        return el_defs
+
+    def test_profile1(self):
+        with self.admin_access.client_cnx() as cnx:
+            transfer = cnx.entity_from_eid(self.transfer_eid)
+            some_concept = cnx.find('Concept').one()
+            profile = self.profile_etree(transfer)
+            # ensure encoding is declared
+            meta = self.xpath(profile, '/html/head/meta')[0]
+            self.assertEqual(meta.attrib, {'content': 'text/html; charset=UTF-8',
+                                           'http-equiv': 'content-type'})
+            # ensure only one first level title
+            self.assertEqual(len(self.xpath(profile, '//title')), 1)
+            self.assertEqual(len(self.xpath(profile, '//h1')), 1)
+            # ensure annotation are serialized
+            self.assertEqual(self.xpath(profile, '//div[@class="description"]/text()'),
+                             ['Composant ISAD(G)'])
+            # ensure all attributes have label, card and value defined
+            attr_divs = self.xpath(profile, '//div[@class="attribute"]')
+            attr_defs = set()
+            for attr_div in attr_divs:
+                attr_def = AttrDef(self.xpath(attr_div, 'span[@class="label"]')[0].text,
+                                   self.xpath(attr_div, 'span[@class="card"]')[0].text,
+                                   self.xpath(attr_div, 'span[@class="value"]')[0].text)
+                attr_defs.add(attr_def)
+            self.assertEqual(attr_defs, set([
+                AttrDef(label='algorithm', card='mandatory', value=None),
+                AttrDef(label='filename', card='optional', value=None),
+                AttrDef(label='href', card='optional', value=None),
+                AttrDef(label='id', card='mandatory', value='au1'),
+                AttrDef(label='id', card='mandatory', value='bdo1'),
+                AttrDef(label='id', card='optional', value=None),
+                AttrDef(label='when', card='optional', value=None),
+                AttrDef(label='uri', card='optional', value=None),
+            ]))
+            # ensure jumped element children have proper parent
+            clv = self.get_element(profile, 'CodeListVersions')
+            self.assertEqual(len(self.xpath(clv, 'div/h3[text()="ReplyCodeListVersion"]')), 1)
+            # ensure KeywordType list handling
+            ktype = self.get_element(profile, 'KeywordType')
+            el_defs = self.element_values(ktype)
+            self.assertEqual(el_defs, [{'label': 'listVersionID', 'value': 'edition 2009'},
+                                       {'label': 'XSD content type', 'value': 'xsd:token'}])
+            # KeywordReference
+            kref = self.get_element(profile, 'KeywordReference')
+            el_defs = self.element_values(kref)
+            concept_link = '<a href="{0}">md5</a>'.format(some_concept.absolute_url())
+            scheme = some_concept.scheme
+            scheme_link = '<a href="{0}">Keyword Types</a>'.format(scheme.absolute_url())
+            self.assertEqual(el_defs, [{'label': 'schemeURI', 'value': scheme_link},
+                                       {'label': 'XSD content type', 'value': 'xsd:token'},
+                                       {'label': 'fixed value', 'value': concept_link}])
+            # Language
+            lang = self.get_element(profile, 'Language')
+            el_defs = self.element_values(lang)
+            self.assertEqual(el_defs, [{'label': 'XSD content type', 'value': 'xsd:language'},
+                                       {'label': 'fixed value', 'value': concept_link}])
+
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()