From 53df596b3df5a1d98d84d8d2db0feb1afd4372ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 12 Jun 2020 14:41:31 +0200
Subject: [PATCH] datasource: import/export (#13722)

---
 tests/test_admin_pages.py                     | 57 ++++++++++++++
 tests/test_datasource.py                      |  1 +
 wcs/admin/data_sources.py                     | 74 ++++++++++++++++++-
 wcs/data_sources.py                           |  1 +
 .../wcs/backoffice/data-sources.html          |  1 +
 5 files changed, 130 insertions(+), 4 deletions(-)

diff --git a/tests/test_admin_pages.py b/tests/test_admin_pages.py
index a3f353a6..c199a2b3 100644
--- a/tests/test_admin_pages.py
+++ b/tests/test_admin_pages.py
@@ -5450,6 +5450,63 @@ def test_data_sources_in_use_delete(pub):
     assert 'delete-button' in resp.text
 
 
+def test_data_sources_export(pub):
+    create_superuser(pub)
+    create_role()
+
+    NamedDataSource.wipe()
+    data_source = NamedDataSource(name='foobar')
+    data_source.data_source = {'type': 'formula', 'value': '[]'}
+    data_source.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/settings/data-sources/1/')
+
+    resp = resp.click(href='export')
+    xml_export = resp.text
+
+    ds = StringIO(xml_export)
+    data_source2 = NamedDataSource.import_from_xml(ds)
+    assert data_source2.name == 'foobar'
+
+
+def test_data_sources_import(pub):
+    create_superuser(pub)
+    create_role()
+
+    NamedDataSource.wipe()
+    data_source = NamedDataSource(name='foobar')
+    data_source.data_source = {'type': 'formula', 'value': '[]'}
+    data_source.store()
+    data_source_xml = ET.tostring(data_source.export_to_xml(include_id=True))
+
+    NamedDataSource.wipe()
+    assert NamedDataSource.count() == 0
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/settings/data-sources/')
+    resp = resp.click(href='import')
+    resp.forms[0]['file'] = Upload('datasource.wcs', data_source_xml)
+    resp = resp.forms[0].submit()
+    assert NamedDataSource.count() == 1
+
+    # import the same datasource a second time, make sure slug is not reused
+    resp = app.get('/backoffice/settings/data-sources/')
+    resp = resp.click(href='import')
+    resp.forms[0]['file'] = Upload('datasource.wcs', data_source_xml)
+    resp = resp.forms[0].submit()
+    assert NamedDataSource.count() == 2
+    assert NamedDataSource.get(1).slug == 'foobar'
+    assert NamedDataSource.get(2).slug == 'foobar-1'
+
+    # import an invalid file
+    resp = app.get('/backoffice/settings/data-sources/')
+    resp = resp.click(href='import')
+    resp.form['file'] = Upload('datasource.wcs', b'garbage')
+    resp = resp.form.submit()
+    assert 'Invalid File' in resp.text
+
+
 def test_data_sources_edit_slug(pub):
     create_superuser(pub)
     NamedDataSource.wipe()
diff --git a/tests/test_datasource.py b/tests/test_datasource.py
index c1efa1aa..8f54c386 100644
--- a/tests/test_datasource.py
+++ b/tests/test_datasource.py
@@ -564,6 +564,7 @@ def test_named_datasource_id_parameter(requests_pub):
 
 def test_named_datasource_in_formdef():
     from wcs.formdef import FormDef
+    NamedDataSource.wipe()
     datasource = NamedDataSource(name='foobar')
     datasource.data_source = {'type': 'json', 'value': 'http://whatever/'}
     datasource.store()
diff --git a/wcs/admin/data_sources.py b/wcs/admin/data_sources.py
index 4e75110e..866338ff 100644
--- a/wcs/admin/data_sources.py
+++ b/wcs/admin/data_sources.py
@@ -14,14 +14,21 @@
 # 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 xml.etree.ElementTree as ET
+
 from quixote import redirect
 from quixote.directory import Directory
 from quixote.html import TemplateIO, htmltext
 
-from wcs.qommon import _
+from wcs.qommon import _, force_str
 from wcs.qommon import errors, template
 from wcs.qommon.form import *
-from wcs.qommon.misc import json_response
+from wcs.qommon.form import FileWidget
+from wcs.qommon.form import Form
+from wcs.qommon.form import UrlWidget
+from wcs.qommon.form import get_response
+from wcs.qommon.form import get_session
+from wcs.qommon import misc
 from wcs.qommon.backoffice.menu import html_top
 from wcs.data_sources import (NamedDataSource, DataSourceSelectionWidget,
         get_structured_items)
@@ -123,7 +130,7 @@ class NamedDataSourceUI(object):
 
 
 class NamedDataSourcePage(Directory):
-    _q_exports = ['', 'edit', 'delete']
+    _q_exports = ['', 'edit', 'delete', 'export']
     do_not_call_in_templates = True
 
     def __init__(self, component):
@@ -222,9 +229,19 @@ class NamedDataSourcePage(Directory):
             self.datasource.remove_self()
             return redirect('..')
 
+    def export(self):
+        x = self.datasource.export_to_xml(include_id=True)
+        misc.indent_xml(x)
+        response = get_response()
+        response.set_content_type('application/x-wcs-datasource')
+        response.set_header(
+            'content-disposition',
+            'attachment; filename=datasource-%s.wcs' % self.datasource.slug)
+        return '<?xml version="1.0"?>\n' + force_str(ET.tostring(x))
+
 
 class NamedDataSourcesDirectory(Directory):
-    _q_exports = ['', 'new']
+    _q_exports = ['', 'new', ('import', 'p_import')]
 
     def _q_traverse(self, path):
         get_response().breadcrumb.append( ('data-sources/', _('Data Sources')) )
@@ -239,6 +256,7 @@ class NamedDataSourcesDirectory(Directory):
         r += htmltext('<div id="appbar">')
         r += htmltext('<h2>%s</h2>') % _('Data Sources')
         r += htmltext('<span class="actions">')
+        r += htmltext('<a href="import" rel="popup">%s</a>') % _('Import')
         r += htmltext('<a class="new-item" href="new">%s</a>') % _('New Data Source')
         r += htmltext('</span>')
         r += htmltext('</div>')
@@ -275,3 +293,51 @@ class NamedDataSourcesDirectory(Directory):
 
     def _q_lookup(self, component):
         return NamedDataSourcePage(component)
+
+    def p_import(self):
+        form = Form(enctype='multipart/form-data')
+        import_title = _('Import Data Source')
+
+        form.add(FileWidget, 'file', title=_('File'), required=True)
+        form.add_submit('submit',  import_title)
+        form.add_submit('cancel', _('Cancel'))
+
+        if form.get_submit() == 'cancel':
+            return redirect('.')
+
+        if form.is_submitted() and not form.has_errors():
+            try:
+                return self.import_submit(form)
+            except ValueError:
+                pass
+
+        get_response().breadcrumb.append(('import', _('Import')))
+        html_top('datasources', title=import_title)
+        r = TemplateIO(html=True)
+        r += htmltext('<h2>%s</h2>') % import_title
+        r += htmltext('<p>%s</p>') % _(
+            'You can install a new data source by uploading a file '
+            'or by pointing to the data source URL.')
+        r += form.render()
+        return r.getvalue()
+
+    def import_submit(self, form):
+        self.imported_datasource = None
+        fp = form.get_widget('file').parse().fp
+
+        error = False
+        try:
+            datasource = NamedDataSource.import_from_xml(fp)
+            get_session().message = (
+                'info', _('This datasource has been successfully imported.'))
+        except ValueError:
+            error = True
+
+        if error:
+            form.set_error('file', _('Invalid File'))
+            raise ValueError()
+
+        self.imported_datasource = datasource
+        datasource.slug = None  # a new one will be set in .store()
+        datasource.store()
+        return redirect('%s/' % datasource.id)
diff --git a/wcs/data_sources.py b/wcs/data_sources.py
index b510ec09..7aafcdfa 100644
--- a/wcs/data_sources.py
+++ b/wcs/data_sources.py
@@ -264,6 +264,7 @@ def get_object(data_source):
 
 class NamedDataSource(XmlStorableObject):
     _names = 'datasources'
+    _indexes = ['slug']
     _xml_tagname = 'datasources'
 
     name = None
diff --git a/wcs/templates/wcs/backoffice/data-sources.html b/wcs/templates/wcs/backoffice/data-sources.html
index 6c78b3fd..f53958dc 100644
--- a/wcs/templates/wcs/backoffice/data-sources.html
+++ b/wcs/templates/wcs/backoffice/data-sources.html
@@ -4,6 +4,7 @@
 <div id="appbar">
 <h2>{% trans "Data Source" %} - {{ datasource.name }}</h2>
 <span class="actions">
+  <a href="export">{% trans "Export" %}</a>
   <a href="delete" rel="popup">{% trans "Delete" %}</a>
   <a href="edit">{% trans "Edit" %}</a>
 </span>
-- 
2.20.1

