From eef0d3b70ad396eda192c710c2dbe0aad3fc35ba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Mon, 25 Oct 2021 15:01:31 +0200
Subject: [PATCH] backoffice: svg of carddefs relations (#57953)

---
 tests/admin_pages/test_card.py | 153 +++++++++++++++++++++++++++++++++
 wcs/admin/categories.py        |   9 +-
 wcs/backoffice/cards.py        |  12 ++-
 wcs/carddef.py                 |  76 ++++++++++++++++
 4 files changed, 246 insertions(+), 4 deletions(-)

diff --git a/tests/admin_pages/test_card.py b/tests/admin_pages/test_card.py
index c062e5b4..57d55a02 100644
--- a/tests/admin_pages/test_card.py
+++ b/tests/admin_pages/test_card.py
@@ -6,6 +6,7 @@ from webtest import Upload
 
 from wcs import fields
 from wcs.admin.settings import UserFieldsFormDef
+from wcs.blocks import BlockDef
 from wcs.carddef import CardDef
 from wcs.categories import CardDefCategory
 from wcs.formdef import FormDef
@@ -636,3 +637,155 @@ def test_card_category_management_roles(pub, backoffice_user, backoffice_role):
     resp = app.get(carddata.get_backoffice_url())
     assert 'inspect' in resp.text
     resp = app.get(carddata.get_backoffice_url() + 'inspect')
+
+
+def test_cards_svg(pub):
+    create_superuser(pub)
+
+    CardDefCategory.wipe()
+    cat1 = CardDefCategory(name='Foo')
+    cat1.store()
+    cat2 = CardDefCategory(name='Foo')
+    cat2.store()
+
+    ds1 = {'type': 'carddef:card-1-2'}
+    ds2 = {'type': 'carddef:card-2-2'}
+    ds3 = {'type': 'carddef:card-3-2'}
+
+    BlockDef.wipe()
+    block1 = BlockDef()
+    block1.name = 'block 1'
+    block1.fields = [
+        fields.StringField(id='1', label='string', type='string', varname='block foo 1', data_source=ds1),
+        fields.ItemField(id='2', label='item', type='item', varname='block foo 2', data_source=ds1),
+        fields.ItemsField(id='3', label='items', type='items', varname='block foo 3', data_source=ds1),
+        fields.StringField(id='10', label='string', type='string', varname='block fooo 10', data_source=ds2),
+        fields.ItemField(id='20', label='item', type='item', varname='block fooo 20', data_source=ds2),
+        fields.ItemsField(id='30', label='items', type='items', varname='block fooo 30', data_source=ds2),
+    ]
+    block1.store()
+    block2 = BlockDef()
+    block2.name = 'block 2'
+    block2.fields = [
+        fields.StringField(id='1', label='string', type='string', varname='block bar 1', data_source=ds2),
+        fields.ItemField(id='2', label='item', type='item', varname='block bar 2', data_source=ds2),
+        fields.ItemsField(id='3', label='items', type='items', varname='block bar 3', data_source=ds2),
+    ]
+    block2.store()
+    block3 = BlockDef()
+    block3.name = 'block 3'
+    block3.fields = [
+        fields.StringField(id='1', label='string', type='string', varname='block baz 1', data_source=ds3),
+        fields.ItemField(id='2', label='item', type='item', varname='block baz 2', data_source=ds3),
+        fields.ItemsField(id='3', label='items', type='items', varname='block baz 3', data_source=ds3),
+    ]
+    block3.store()
+
+    CardDef.wipe()
+
+    carddef11 = CardDef()
+    carddef11.name = 'card 1-1'
+    carddef11.category_id = cat1.id
+    carddef11.fields = [
+        fields.StringField(id='1', label='string', type='string', varname='foo 1', data_source=ds1),
+        fields.ItemField(id='2', label='item', type='item', varname='foo 2', data_source=ds1),
+        fields.ItemsField(id='3', label='items', type='items', varname='foo 3', data_source=ds1),
+        fields.BlockField(id='4', label='block', type='block:%s' % block1.slug),
+        fields.StringField(id='10', label='string', type='string', varname='fooo 10', data_source=ds2),
+        fields.ItemField(id='20', label='item', type='item', varname='fooo 20', data_source=ds2),
+        fields.ItemsField(id='30', label='items', type='items', varname='fooo 30', data_source=ds2),
+        fields.BlockField(id='40', label='block', type='block:%s' % block2.slug),
+    ]
+    carddef11.store()
+    carddef12 = CardDef()
+    carddef12.name = 'card 1-2'
+    carddef12.category_id = cat1.id
+    carddef12.fields = []
+    carddef12.store()
+    carddef13 = CardDef()
+    carddef13.name = 'card 1-3'
+    carddef13.category_id = cat1.id
+    carddef13.fields = []
+    carddef13.store()
+
+    carddef21 = CardDef()
+    carddef21.name = 'card 2-1'
+    carddef21.category_id = cat2.id
+    carddef21.fields = [
+        fields.StringField(id='1', label='string', type='string', varname='bar 1', data_source=ds2),
+        fields.ItemField(id='2', label='item', type='item', varname='bar 2', data_source=ds2),
+        fields.ItemsField(id='3', label='items', type='items', varname='bar 3', data_source=ds2),
+        fields.BlockField(id='4', label='block', type='block:%s' % block2.slug),
+    ]
+    carddef21.store()
+    carddef22 = CardDef()
+    carddef22.name = 'card 2-2'
+    carddef22.category_id = cat2.id
+    carddef22.fields = []
+    carddef22.store()
+
+    carddef31 = CardDef()
+    carddef31.name = 'card 3-1'
+    carddef31.fields = [
+        fields.StringField(id='1', label='string', type='string', varname='baz 1', data_source=ds3),
+        fields.ItemField(id='2', label='item', type='item', varname='baz 2', data_source=ds3),
+        fields.ItemsField(id='3', label='items', type='items', varname='baz 3', data_source=ds3),
+        fields.BlockField(id='4', label='block', type='block:%s' % block3.slug),
+    ]
+    carddef31.store()
+    carddef32 = CardDef()
+    carddef32.name = 'card 3-2'
+    carddef32.fields = []
+    carddef32.store()
+
+    app = login(get_app(pub))
+
+    resp = app.get('/backoffice/cards/svg')
+    # cards
+    assert '<title>card_card_1_1</title' in resp
+    assert '<title>card_card_1_2</title' in resp
+    assert '<title>card_card_1_3</title' not in resp
+    assert '<title>card_card_2_1</title' in resp
+    assert '<title>card_card_2_2</title' in resp
+    assert '<title>card_card_3_1</title' in resp
+    assert '<title>card_card_3_2</title' in resp
+    # and relations
+    assert resp.text.count('<title>card_card_1_1&#45;&gt;card_card_1_2</title>') == 6
+    assert resp.text.count('<title>card_card_1_1&#45;&gt;card_card_2_2</title>') == 9
+    assert resp.text.count('<title>card_card_2_1&#45;&gt;card_card_2_2</title>') == 6
+    assert resp.text.count('<title>card_card_3_1&#45;&gt;card_card_3_2</title>') == 6
+
+    resp = app.get('/backoffice/cards/svg?show-orphans=on')
+    assert '<title>card_card_1_3</title' in resp
+
+    resp = app.get('/backoffice/cards/categories/%s/svg' % cat1.id)
+    # cards
+    assert '<title>card_card_1_1</title' in resp
+    assert '<title>card_card_1_2</title' in resp
+    assert '<title>card_card_1_3</title' not in resp
+    assert '<title>card_card_2_1</title' not in resp
+    assert '<title>card_card_2_2</title' not in resp
+    assert '<title>card_card_3_1</title' not in resp
+    assert '<title>card_card_3_2</title' not in resp
+    # and relations
+    assert resp.text.count('<title>card_card_1_1&#45;&gt;card_card_1_2</title>') == 6
+    assert resp.text.count('<title>card_card_1_1&#45;&gt;card_card_2_2</title>') == 0
+    assert resp.text.count('<title>card_card_2_1&#45;&gt;card_card_2_2</title>') == 0
+    assert resp.text.count('<title>card_card_3_1&#45;&gt;card_card_3_2</title>') == 0
+
+    resp = app.get('/backoffice/cards/categories/%s/svg?show-orphans=on' % cat1.id)
+    assert '<title>card_card_1_3</title' in resp
+
+    resp = app.get('/backoffice/cards/categories/%s/svg' % cat2.id)
+    # cards
+    assert '<title>card_card_1_1</title' not in resp
+    assert '<title>card_card_1_2</title' not in resp
+    assert '<title>card_card_2_1</title' in resp
+    assert '<title>card_card_2_2</title' in resp
+    assert '<title>card_card_3_1</title' not in resp
+    assert '<title>card_card_3_2</title' not in resp
+    # and relations
+    assert resp.text.count('<title>card_card_1_1&#45;&gt;card_card_1_2</title>') == 0
+    assert resp.text.count('<title>card_card_1_1&#45;&gt;card_card_2_2</title>') == 0
+    assert resp.text.count('<title>card_card_2_1&#45;&gt;card_card_2_2</title>') == 6
+    assert resp.text.count('<title>card_card_3_1&#45;&gt;card_card_3_2</title>') == 0
diff --git a/wcs/admin/categories.py b/wcs/admin/categories.py
index afa569e9..bae96c32 100644
--- a/wcs/admin/categories.py
+++ b/wcs/admin/categories.py
@@ -18,7 +18,7 @@ from quixote import get_publisher, get_request, get_response, redirect
 from quixote.directory import Directory
 from quixote.html import TemplateIO, htmltext
 
-from wcs.carddef import CardDef
+from wcs.carddef import CardDef, get_cards_graph
 from wcs.categories import CardDefCategory, Category, WorkflowCategory
 from wcs.formdef import FormDef
 from wcs.qommon import _, misc, template
@@ -253,6 +253,13 @@ class CardDefCategoryPage(CategoryPage):
     object_class = CardDef
     usage_title = _('Card models in this category')
     empty_message = _('No card model associated to this category.')
+    _q_exports = CategoryPage._q_exports + ['svg']
+
+    def svg(self):
+        response = get_response()
+        response.set_content_type('image/svg+xml')
+        show_orphans = get_request().form.get('show-orphans') == 'on'
+        return get_cards_graph(category=self.category, show_orphans=show_orphans)
 
 
 class WorkflowCategoryPage(CategoryPage):
diff --git a/wcs/backoffice/cards.py b/wcs/backoffice/cards.py
index 999627a8..e2b08718 100644
--- a/wcs/backoffice/cards.py
+++ b/wcs/backoffice/cards.py
@@ -14,13 +14,13 @@
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
-from quixote import get_publisher, get_response, get_session, redirect
+from quixote import get_publisher, get_request, get_response, get_session, redirect
 from quixote.html import TemplateIO, htmltext
 
 from wcs.admin import utils
 from wcs.admin.categories import CardDefCategoriesDirectory
 from wcs.admin.forms import FormDefPage, FormDefUI, FormsDirectory, OptionsDirectory
-from wcs.carddef import CardDef
+from wcs.carddef import CardDef, get_cards_graph
 from wcs.categories import CardDefCategory
 from wcs.workflows import Workflow
 
@@ -225,7 +225,7 @@ class CardDefPage(FormDefPage):
 
 
 class CardsDirectory(FormsDirectory):
-    _q_exports = ['', 'new', ('import', 'p_import'), 'categories']
+    _q_exports = ['', 'new', ('import', 'p_import'), 'categories', 'svg']
 
     category_class = CardDefCategory
     categories = CardDefCategoriesDirectory()
@@ -293,5 +293,11 @@ class CardsDirectory(FormsDirectory):
             self.imported_formdef.store()
         return response
 
+    def svg(self):
+        response = get_response()
+        response.set_content_type('image/svg+xml')
+        show_orphans = get_request().form.get('show-orphans') == 'on'
+        return get_cards_graph(show_orphans=show_orphans)
+
     def _q_lookup(self, component):
         return self.formdef_page_class(component)
diff --git a/wcs/carddef.py b/wcs/carddef.py
index 5c057296..d76065bd 100644
--- a/wcs/carddef.py
+++ b/wcs/carddef.py
@@ -14,9 +14,12 @@
 # You should have received a copy of the GNU General Public License
 # along with this program; if not, see <http://www.gnu.org/licenses/>.
 
+import io
 import sys
 import types
+from subprocess import PIPE, Popen
 
+from django.utils.encoding import force_bytes
 from quixote import get_publisher
 
 from wcs.carddata import CardData
@@ -282,3 +285,76 @@ class CardDef(FormDef):
                 continue
             varnames.extend(Field.get_referenced_varnames(formdef, criteria.value))
         return varnames
+
+
+def get_cards_graph(category=None, show_orphans=False):
+    out = io.StringIO()
+    out.write('digraph main {\n')
+    out.write('node [shape=box,style=filled];\n')
+    out.write('edge [];\n')
+
+    criterias = []
+    if category is not None:
+        criterias = [Equal('category_id', str(category.id))]
+    carddefs = CardDef.select(clause=criterias)
+    carddefs_slugs = [c.url_name for c in carddefs]
+
+    def check_relations(carddef_ref, fields, check_blocks=True, prefix=''):
+        cardinality = {
+            'string': '1..n',
+            'item': '1..n',
+            'items': 'n..n',
+        }
+        for field in fields:
+            data_source = getattr(field, 'data_source', None)
+            if data_source and data_source['type'].startswith('carddef:'):
+                slug = field.data_source['type'].split(':')[1]
+                if not show_orphans and slug not in carddefs_slugs:
+                    # don't report extra category relations
+                    continue
+                label = '%s%s %s' % (prefix, field.varname or field.label, cardinality.get(field.key))
+                yield '%s -> card_%s [label="%s"];' % (
+                    carddef_ref,
+                    slug.replace('-', '_'),
+                    label,
+                )
+            if check_blocks and field.key == 'block':
+                yield from check_relations(
+                    carddef_ref,
+                    field.block.fields,
+                    check_blocks=False,
+                    prefix='%s (block) ' % (field.varname or field.label),
+                )
+
+    records = []
+    relations = []
+
+    for carddef in carddefs:
+        carddef_ref = 'card_%s' % carddef.url_name.replace('-', '_')
+        record = '%s [shape=record,label="<card>%s' % (carddef_ref, carddef.name)
+        relations += list(check_relations(carddef_ref, carddef.get_all_fields()))
+        record += '"];'
+        records.append(record)
+    if not show_orphans:
+        for record in records[:]:
+            if not [x for x in relations if record.split()[0] in x.split()]:
+                records.remove(record)
+
+    for record in records:
+        out.write('%s\n' % record)
+
+    for relation in relations:
+        out.write('%s\n' % relation)
+
+    out.write('}\n')
+
+    out = out.getvalue()
+    try:
+        with Popen(['dot', '-Tsvg'], stdin=PIPE, stdout=PIPE) as process:
+            out = process.communicate(force_bytes(out))[0]
+            if process.returncode != 0:
+                return ''
+    except OSError:
+        return ''
+
+    return out
-- 
2.33.0

