Add basic SEDA compatibility diagnosis tool
authorSylvain Thénault <sylvain.thenault@logilab.fr>
Thu, 13 Oct 2016 14:27:04 +0200
changeset 1685 51669dddb342
parent 1634 c2ccf07bb418
child 1686 a824bb9d5554
Add basic SEDA compatibility diagnosis tool Introduce an adapter that will 'diagnose' seda profile. It will be able to return all problems leading to not supporting some SEDA export format. There is currently a single sample rule (missing access rule on top level archive unit). Overall result (supported formats) are stored in a computed attribute so that we may control depending actions'appearance without recomputing the whole diagnosis. Related to #15524215
entities/diag.py
entities/profile_generation.py
hooks.py
i18n/en.po
i18n/fr.po
schema/__init__.py
test/test_diag.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/entities/diag.py	Thu Oct 13 14:27:04 2016 +0200
@@ -0,0 +1,82 @@
+# 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 tools to diagnose versions compatibility of profiles."""
+
+from collections import namedtuple
+
+from cubicweb import _
+from cubicweb.view import EntityAdapter
+from cubicweb.selectors import is_instance
+
+
+ALL_FORMATS = frozenset(('SEDA 2', 'SEDA 1', 'SEDA 0.2'))
+
+Rule = namedtuple('Rule', ['impacted_formats', 'message', 'watch'])
+RULES = {
+    'seda1_need_access_rule': Rule(
+        set(['SEDA 1']),
+        _("First level archive unit must have an associated access rule to be exportable "
+          "in SEDA 1. You may define it on the archive unit or as a default rule on the "
+          "transfer."),
+        set([
+            'seda_archive_unit',
+            'seda_alt_archive_unit_archive_unit_ref_id',
+            'seda_seq_alt_archive_unit_archive_unit_ref_id_management',
+        ])),
+}
+
+
+class CompatError(namedtuple('_CompatError', ['impacted_formats', 'message'])):
+    """Convenience class holding information about a problem in a profile forbidding export to some
+    format.
+    """
+    def __new__(cls, rule_id):
+        rule = RULES[rule_id]
+        return super(CompatError, cls).__new__(cls, rule.impacted_formats, rule.message)
+
+
+class ISEDACompatAnalyzer(EntityAdapter):
+    """Adapter that will analyze profile to diagnose its format compatibility (SEDA 2, SEDA 1 and/or
+    SEDA 0.2) while being to explain the problem to end-users.
+    """
+
+    __regid__ = 'ISEDACompatAnalyzer'
+    __select__ = is_instance('SEDAArchiveTransfer')
+
+    def diagnose(self):
+        possible_formats = set(ALL_FORMATS)
+        for compat_error in self.detect_problems():
+            possible_formats -= compat_error.impacted_formats
+        return possible_formats
+
+    def detect_problems(self):
+        """Yield :class:`CompatError` describing a problem that prevents the given profile to be
+        compatible with some format.
+        """
+        for rule_id in self.failing_rule_ids():
+            yield CompatError(rule_id)
+
+    def failing_rule_ids(self):
+        """Yield rule identifiers describing a problem that prevents the given profile to be
+        compatible with some format.
+        """
+        profile = self.entity
+        # First level archive unit needs an access rule (SEDA 1)
+        if not profile.reverse_seda_access_rule:
+            for archive_unit in profile.archive_units:
+                seq = archive_unit.first_level_choice.content_sequence
+                if not seq.reverse_seda_access_rule:
+                    yield 'seda1_need_access_rule'
--- a/entities/profile_generation.py	Tue Oct 11 17:02:06 2016 +0200
+++ b/entities/profile_generation.py	Thu Oct 13 14:27:04 2016 +0200
@@ -878,7 +878,7 @@
         appraisal_rule_entity = self.archive_unit_appraisal_rule(archive_unit)
         if appraisal_rule_entity:
             self.xsd_appraisal_rule(archive_node, appraisal_rule_entity)
-        # XXX not optional in seda 1
+        # not optional in seda 1
         access_rule_entity = self.archive_unit_access_rule(archive_unit)
         self.xsd_access_rule(archive_node, access_rule_entity)
         content_entity = self.archive_unit_content(archive_unit)
--- a/hooks.py	Tue Oct 11 17:02:06 2016 +0200
+++ b/hooks.py	Thu Oct 13 14:27:04 2016 +0200
@@ -25,7 +25,7 @@
 from cubicweb.predicates import is_instance, score_entity
 from cubicweb.server import hook
 
-from cubes.seda.entities import rule_type_from_etype
+from cubes.seda.entities import rule_type_from_etype, diag
 from cubes.seda.entities.generated import CHOICE_RTYPE
 
 
@@ -227,6 +227,75 @@
                         {'t': transfer, 'rt': ref_rtype, 'et': ref_etype})
 
 
+class CheckProfileSEDACompatiblityOp(hook.DataOperationMixIn, hook.LateOperation):
+    """Data operation that will check compatibility of a SEDA profile upon modification.
+
+    This is a late operation since it has to be executed once the 'container' relation is set.
+    """
+
+    def precommit_event(self):
+        cnx = self.cnx
+        profiles = set()
+        for eid in self.get_data():
+            if cnx.deleted_in_transaction(eid):
+                continue
+            entity = cnx.entity_from_eid(eid)
+            if entity.cw_etype == 'SEDAArchiveTransfer':
+                profiles.add(entity)
+            else:
+                container = entity.cw_adapt_to('IContained').container
+                if container is not None:
+                    profiles.add(container)
+        with cnx.deny_all_hooks_but():
+            for profile in profiles:
+                adapter = profile.cw_adapt_to('ISEDACompatAnalyzer')
+                profile.cw_set(compat_list=u', '.join(sorted(adapter.diagnose())))
+
+    def add_entity(self, entity):
+        """Add entity, its parent entities (up to the container root) for update of their
+        modification date at commit time.
+        """
+        self.add_data(entity.eid)
+        while True:
+            safety_belt = set((entity.eid,))
+            contained = entity.cw_adapt_to('IContained')
+            if contained is None:
+                assert entity.cw_adapt_to('IContainer')
+                break
+            else:
+                entity = contained.container
+                if entity is None or entity.eid in safety_belt:
+                    break
+                self.add_data(entity.eid)
+                safety_belt.add(entity.eid)
+
+
+class CheckNewProfile(hook.Hook):
+    """Instantiate operation checking for profile seda compat on its creation"""
+    __regid__ = 'seda.transfer.created.checkcompat'
+    __select__ = hook.Hook.__select__ & is_instance('SEDAArchiveTransfer')
+    events = ('after_add_entity', )
+
+    def __call__(self):
+        CheckProfileSEDACompatiblityOp.get_instance(self._cw).add_entity(self.entity)
+
+
+WATCH_RTYPES_SET = set().union(*(rule.watch for rule in diag.RULES.values()))
+
+
+class AddOrRemoveChildrenHook(hook.Hook):
+    """Some relation involved in diagnosis of the profile is added or removed."""
+    __regid__ = 'seda.transfer.relupdated.checkcompat'
+    __select__ = hook.Hook.__select__ & hook.match_rtype_sets(WATCH_RTYPES_SET)
+    events = ('before_add_relation', 'before_delete_relation')
+    category = 'metadata'
+
+    def __call__(self):
+        op = CheckProfileSEDACompatiblityOp.get_instance(self._cw)
+        op.add_entity(self._cw.entity_from_eid(self.eidfrom))
+        op.add_entity(self._cw.entity_from_eid(self.eidto))
+
+
 def registration_callback(vreg):
     from cubicweb.server import ON_COMMIT_ADD_RELATIONS
     from cubes.seda import seda_profile_container_def, iter_all_rdefs
--- a/i18n/en.po	Tue Oct 11 17:02:06 2016 +0200
+++ b/i18n/en.po	Thu Oct 13 14:27:04 2016 +0200
@@ -3555,6 +3555,13 @@
 msgid "comment"
 msgstr ""
 
+msgid "compat_list"
+msgstr ""
+
+msgctxt "SEDAArchiveTransfer"
+msgid "compat_list"
+msgstr ""
+
 msgid "compressed"
 msgstr ""
 
@@ -4402,6 +4409,10 @@
 msgid "name"
 msgstr ""
 
+msgid ""
+"names of format in which the profile may be exported (e.g. 'SEDA 2, SEDA 1')"
+msgstr ""
+
 msgid "need human intervention"
 msgstr ""
 
--- a/i18n/fr.po	Tue Oct 11 17:02:06 2016 +0200
+++ b/i18n/fr.po	Thu Oct 13 14:27:04 2016 +0200
@@ -3558,6 +3558,13 @@
 msgid "comment"
 msgstr "valeur"
 
+msgid "compat_list"
+msgstr "formats supportés"
+
+msgctxt "SEDAArchiveTransfer"
+msgid "compat_list"
+msgstr ""
+
 msgid "compressed"
 msgstr "fichier compressé"
 
@@ -4410,6 +4417,12 @@
 msgid "name"
 msgstr ""
 
+msgid ""
+"names of format in which the profile may be exported (e.g. 'SEDA 2, SEDA 1')"
+msgstr ""
+"noms des formats dans lesquels ce profil peut être exporté (e.g. 'SEDA 2, "
+"SEDA 1')"
+
 msgid "need human intervention"
 msgstr "requière une intervention humaine"
 
--- a/schema/__init__.py	Tue Oct 11 17:02:06 2016 +0200
+++ b/schema/__init__.py	Thu Oct 13 14:27:04 2016 +0200
@@ -107,6 +107,15 @@
     cardinality = '11'
 
 
+class compat_list(RelationDefinition):
+    __permissions__ = {'read': ('managers', 'users', 'guests',),
+                       'add': (),
+                       'update': ()}
+    subject = 'SEDAArchiveTransfer'
+    object = 'String'
+    description = _("names of format in which the profile may be exported (e.g. 'SEDA 2, SEDA 1')")
+
+
 language_code = skos.Label.get_relation('language_code')
 language_code.constraints[0].max = 6
 # XXX code below may be dropped once https://www.cubicweb.org/ticket/14037638 is released
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test/test_diag.py	Thu Oct 13 14:27:04 2016 +0200
@@ -0,0 +1,46 @@
+# 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 versions compatibility diagnosis test."""
+
+from cubicweb.devtools.testlib import CubicWebTC
+
+from testutils import create_archive_unit
+
+
+class CompatAnalyzerTC(CubicWebTC):
+
+    def test_base(self):
+        with self.admin_access.repo_cnx() as cnx:
+            transfer = cnx.create_entity('SEDAArchiveTransfer', title=u'diagnosis testing')
+            doctor = transfer.cw_adapt_to('ISEDACompatAnalyzer')
+
+            cnx.commit()
+            self.assertDiagnostic(doctor, ('SEDA 2', 'SEDA 1', 'SEDA 0.2'))
+            unit, unit_alt, unit_alt_seq = create_archive_unit(transfer)
+            transfer.cw_clear_all_caches()
+            cnx.commit()
+            self.assertDiagnostic(doctor, ('SEDA 2', 'SEDA 0.2'), 'seda1_need_access_rule')
+
+    def assertDiagnostic(self, doctor, expected_formats, *expected_rule_ids):
+        rule_ids = set(doctor.failing_rule_ids())
+        self.assertEqual(rule_ids, set(expected_rule_ids))
+        self.assertEqual(doctor.diagnose(), set(expected_formats))
+        self.assertEqual(doctor.entity.compat_list, ', '.join(sorted(expected_formats)))
+
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()