Commit cc99e7a3 authored by Bryan  BRANCOTTE's avatar Bryan BRANCOTTE

Merge branch 'generate-template-2' into 'master'

Generate template 2

See merge request hub/12765-viralhostrangedb!28
parents b861357e 597bf2d9
......@@ -15,4 +15,5 @@ empty.xlsx.json.not_needed
VHDB105.xlsx*
VHDB107.xlsx*
.static/
.media/
doc/_build/
......@@ -13,3 +13,4 @@ data:
USE_SQLITE_AS_DB: "$USE_SQLITE_AS_DB"
PROJECT_NAME: "viralhostrange"
STATIC_URL: "/static"
MEDIA_URL: "/media"
......@@ -61,8 +61,6 @@ COPY . /code
ARG CI_COMMIT_SHORT_SHA
ARG CI_COMMIT_REF_SLUG
RUN if [ "$CI_COMMIT_REF_SLUG" != "master" ]; then envsubst < ./viralhostrangedb/templates/viralhostrangedb/pre_content_page_title.example.html > ./viralhostrangedb/templates/viralhostrangedb/pre_content_page_title.html; fi
RUN echo "Version $CI_COMMIT_SHORT_SHA $CI_COMMIT_REF_SLUG on $(date +'%Y-%m-%d %H:%M %Z')" > /code/viralhostrangedb/templates/viralhostrangedb/last_update.html && adduser --disabled-password --gecos '' kiwi && chown -R kiwi:kiwi /code
RUN if [ "$CI_COMMIT_REF_SLUG" != "master" ]; then export WHEN="$(date +'%Y-%m-%d %H:%M %Z')"; envsubst < ./viralhostrangedb/templates/viralhostrangedb/pre_content_page_title.example.html > ./viralhostrangedb/templates/viralhostrangedb/pre_content_page_title.html; fi && echo "Version $CI_COMMIT_SHORT_SHA $CI_COMMIT_REF_SLUG on $(date +'%Y-%m-%d %H:%M %Z')" > /code/viralhostrangedb/templates/viralhostrangedb/last_update.html && adduser --disabled-password --gecos '' kiwi && chown -R kiwi:kiwi /code
USER kiwi
\ No newline at end of file
......@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2019-11-04 15:28+0100\n"
"POT-Creation-Date: 2019-11-08 13:37+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -333,7 +333,10 @@ msgstr ""
"or:\n"
"T4\tid_of_t4\n"
"Virus_2\tNC_001416\n"
"Virus 3\t&lt;blank&gt;"
"\n"
"or:\n"
"T4;id of t4\n"
"Virus_2;NC_001416"
msgid "host_placeholder_in_form"
msgstr ""
......@@ -349,6 +352,9 @@ msgid "delimiter_not_found_please_specify_it"
msgstr ""
"We were not able to guess to separator, you should consider providing it."
msgid "Recommended responses scheme"
msgstr ""
msgid "ContactOwnerForm__recipient"
msgstr "Recipient"
......@@ -687,6 +693,15 @@ msgstr ""
msgid "Sort hosts on their infection ratio"
msgstr ""
msgid "L'Institut Pasteur"
msgstr ""
msgid "Bioinformatics and Biostatistics HUB"
msgstr ""
msgid "Interactions Bacteriophages Bacteria in Animals"
msgstr ""
msgid "Browse it"
msgstr "Explore it"
......@@ -811,7 +826,8 @@ msgstr ""
msgid "ViralHostRangeDB_main_title"
msgstr ""
"Viral Host Range database, a resource for virus-host interactions&nbsp;studies"
"Viral Host Range database, a resource for virus-host interactions&nbsp;"
"studies"
#, python-format
msgid ""
......@@ -840,24 +856,27 @@ msgstr ""
msgid "hosts responses to viruses"
msgstr ""
msgid "Search"
msgstr ""
msgid "in viruses, hosts and data sources"
msgstr "viruses, hosts and data sources"
msgid "Contribute"
msgstr ""
msgid "with your data"
msgstr ""
msgid "Search"
msgstr ""
msgid "in viruses, hosts and data sources"
msgstr "viruses, hosts and data sources"
msgid "title_subset_of_data_source"
msgstr "Recently updated data sources"
msgid "From"
msgstr ""
msgid "Open"
msgstr ""
msgid "access_to_virus_host_and_data_source"
msgstr "Direct access to the content of the database"
......@@ -1123,15 +1142,73 @@ msgstr ""
"\" href=\"http://hub.pages.pasteur.fr/12765-viralhostrangedb/compatible_file."
"html\">documentation</a>.</p>"
msgid "step_message_LiveInput"
#, python-format
msgid ""
"step_message_Template%(template_file_url)s%(scheme)s%(table_template)s"
"%(table_example)s"
msgstr ""
"<div class=\"row\"><div class=\"col text-center\">%(table_template)s</"
"div><div class=\"col-1 text-center h1 mt-4\"><i class=\"fa fa-arrow-circle-"
"right mt-4\"></i></div><div class=\"col text-center\">%(table_example)s</"
"div></div>Template filling process:<ul><li>Download the template file <a "
"href=\"%(template_file_url)s\" target=\"_blank\">(<i class=\"fa fa-download"
"\"> link)</i></a>.</li><li>Fill and adapte the template (Fig 1) with your "
"hosts, viruses and responses: <ul><li>Note that colors are only indicative "
"and are not sufficient.</li><li>Infection status is entered as a number and "
"the recommended values to use are: %(scheme)s.</li><li><b>NCBI identifiers</"
"b> of host and virus can be provided within the excel file between "
"parenthesis after the name of host or virus (see example in Fig 2). </"
"li><li>Fig 2 is an example of what you could achieve. </li></ul></"
"li><li>Upload the file here bellow</li></ul>"
msgid "Fig 1: the template<br/>"
msgstr ""
msgid "download template file"
msgstr ""
msgid "Fig 2: How the template could be filled<br/>"
msgstr ""
msgid "step_message_LiveInput when is_bound"
msgstr ""
"You can now provide the list of viruses and hosts you want to add to your "
"data source. <br/>If you have your elements along with their identifiers, "
"you can copy/paste them from a spreadsheet in a format with two column where "
"the first column is the name, and the second column is the identifier. <br/"
">If there is more than one element per line, we will try to guess what is "
"the separator between <code title=\"tabulation\">\\t</code>, <code title="
"\"comma\">,</code> or <code title=\"semicolon\">;</code>. If not we will ask "
"you."
#. Translators : when data is provided i.e: when we were not able to guess what is the separator,
#. so give more details
msgid "step_message_LiveInput when not is_bound"
msgstr ""
"You can now provide the list of viruses and hosts you want to add to your "
"data source. <br/>If there is more than one element per line, we will try to "
"guess what is the separator between <code title=\"tabulation\">\\t</code>, "
"<code title=\"comma\">,</code> or <code title=\"semicolon\">;</code>.<br/>If "
"you have your elements along with their identifiers, you can copy/paste them "
"in a format with two column where the first column is the name, and the "
"second column is the identifier."
"data source. <br/>If you have your elements along with their identifiers, "
"you can copy/paste them from a spreadsheet in a format with two column where "
"the first column is the name, and the second column is the identifier."
msgid "Fig 1: A (subset of) your template<br/>"
msgstr ""
#, python-format
msgid "step_message_LiveTemplate%(scheme)s%(template_file_url)s"
msgstr ""
"<p>A template have been generated, and a sample is displayed on the right. "
"You now have to download the template <a href=\"%(template_file_url)s\" "
"target=\"_blank\">(<i class=\"fa fa-download\"> link)</i></a>, fill it with "
"responses, and upload the filled template here bellow.</p><p>Note that a "
"response is entered as a number and the recommended values to use are: "
"%(scheme)s. <b>NCBI identifiers</b> of host and virus can still be provided "
"within the spreadsheet file between parenthesis after the name of host or "
"virus : <code class=\"p-2 d-inline-block\"><span title=\"Name\" class=\"border border-"
"secondary\">Virus 1</span> (<span title=\"Identifier\" class=\"border border-"
"secondary\">id of Virus 1</span>)</code></p>"
msgid "download complete template"
msgstr ""
msgid "and"
msgstr ""
......@@ -1226,6 +1303,3 @@ msgstr ""
"You can contact a data source owner with this form. We will send on your "
"behalf the email you are about to write. The owner will get the email you "
"wrote, your name and email address, alloing him/her to answer you directly."
#~ msgid "SearchForm.owner.label"
#~ msgstr "Narrow your search to data shared by only some users."
......@@ -18,7 +18,7 @@ ErrorLog "|/usr/bin/rotatelogs /code/persistent_volume/apache.%Y-%m-%d-%H_%M_%S.
WSGIProcessGroup ${PROJECT_NAME}
Alias ${STATIC_URL}/ /code/.static/
#Alias ${MEDIA_URL}/ /code/.media/
Alias ${MEDIA_URL}/ /code/.media/
Alias /favicon.ico /code/.static/favicon.ico
Alias /robots.txt /code/.static/robots.txt
Redirect permanent "/apple-touch-icon-precomposed.png" "${STATIC_URL}/favicon-256.png"
......
......@@ -8,6 +8,11 @@ if [ "$1" == "test" ]; then
msg_info "Running tests"
pip install coverage
cp viralhostrange/settings.example.ini viralhostrange/settings.ini || exit 2
MEDIA_ROOT_DIR=$(python manage.py shell -c "from django.conf import settings; print(settings.MEDIA_ROOT)" | grep -v django.db.backends)
mkdir -p $MEDIA_ROOT_DIR
chmod 777 $MEDIA_ROOT_DIR
python manage.py collectstatic --noinput
python manage.py compilemessages || exit 6
coverage run --source='.' manage.py test || exit 3
......@@ -78,7 +83,7 @@ else
msg_warning "csscompressor missing, passed"
fi
MEDIA_ROOT_DIR=$(python manage.py shell -c "from django.conf import settings; print(settings.MEDIA_ROOT)")
MEDIA_ROOT_DIR=$(python manage.py shell -c "from django.conf import settings; print(settings.MEDIA_ROOT)" | grep -v django.db.backends)
msg_info "Creating media root at $MEDIA_ROOT_DIR"
if [ "$MEDIA_ROOT_DIR" != "" ]; then
mkdir -p $MEDIA_ROOT_DIR
......@@ -87,11 +92,15 @@ else
msg_warning "settings.MEDIA_ROOT missing, passed"
fi
sudo /usr/sbin/service cron start
msg_info "Adding cron task to dump db every day"
cat <(crontab -l) <(echo "") <(echo "* * * * * find $MEDIA_ROOT_DIR/ -type f -name 'Template*.xlsx' -mtime +1 -exec rm {} \; >> /code/persistent_volume/django-crontab.log 2>> /code/persistent_volume/django-crontab.err") | crontab -
# Setting up cron tasks
msg_info "Registering cron tasks"
if [ "$(pip freeze | grep django-crontab | wc -l )" == "1" ]; then
python manage.py crontab add
sudo /usr/sbin/service cron start
#sudo /usr/sbin/service cron start
grep = /code/resources/default.ini /code/resources/local.ini -h | sed "s/^\(.*\)$/export \1/g" > /home/kiwi/env.sh
echo "export ALLOWED_HOSTS=$ALLOWED_HOSTS" >> /home/kiwi/env.sh
echo "export DEBUG=$DEBUG" >> /home/kiwi/env.sh
......@@ -100,6 +109,8 @@ else
msg_warning "django-crontab missing, passed"
fi
crontab -l
if [ "$1" == "do_not_start" ]; then
msg_info "Entrypoint have been run successfully, we are not starting the project as requested"
elif [ "$1" == "django" ]; then
......
{
"C1 (HG738867.1)": {
"r1 (NC_001416)": "1"
},
"C2 (HGC2738867.3)": {
"r1 (NC_001416)": "2"
}
}
\ No newline at end of file
......@@ -156,6 +156,7 @@ USE_TZ = True
STATIC_ROOT = os.path.join(BASE_DIR, '.static')
STATIC_URL = config('STATIC_URL', default='/static') + '/'
MEDIA_ROOT = os.path.join(BASE_DIR, '.media')
MEDIA_URL = config('MEDIA_URL', default='/media') + '/'
################################################################################
# LOGGING
......
......@@ -35,3 +35,6 @@ if not settings.DEBUG:
urlpatterns.append(
url(r'^static/(?P<path>.*)$', serve, {'document_root': settings.STATIC_ROOT})
)
urlpatterns.append(
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT})
)
......@@ -357,24 +357,6 @@ def import_file(*, data_source, file, importation_observer: ImportationObserver
return data_source
def merge_entries(entry_to_keep, entry_to_merge):
if type(entry_to_keep) != type(entry_to_merge):
raise ValueError("Can't merge entries with different type")
if isinstance(entry_to_keep, models.Host):
key = "host"
elif isinstance(entry_to_keep, models.Virus):
key = "virus"
else:
raise ValueError("Can't merge entries of type %s" % type(entry_to_keep))
for o in entry_to_merge.responseindatasource.all():
setattr(o, key, entry_to_keep)
o.save()
for o in entry_to_merge.data_source.all():
entry_to_keep.data_source.add(o)
entry_to_merge.delete()
return entry_to_keep
def reset_mapping(queryset):
not_mapped_yet = models.GlobalViralHostResponseValue.get_not_mapped_yet()
for o in queryset:
......
import csv
import io
import os
import random
import string
import pandas as pd
from basetheme_bootstrap.user_preferences import get_user_preferences_for_user
from crispy_forms import helper as crispy_forms_helper
from crispy_forms import layout as crispy_forms_layout
from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, Layout, Row, Column
from django import forms
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Q, Min, Max
from django.forms import widgets
from django.utils import timezone
from django.utils.datastructures import MultiValueDictKeyError
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _, ugettext
from viralhostrangedb import models, business_process
from viralhostrangedb.business_process import ImportationObserver
from viralhostrangedb.business_process import ImportationObserver, explicit_item
from viralhostrangedb.mixins import only_public_or_granted_or_owned_queryset_filter
......@@ -1042,6 +1047,7 @@ class UploadOrLiveInput(forms.Form):
label=_("upload_or_live_input__label"),
choices=(
("upload", _("UploadOrLiveInput__upload__choice")),
# ("template", _("UploadOrLiveInput__template__choice")),
("live", _("UploadOrLiveInput__live_input__choice")),
),
required=True,
......@@ -1071,6 +1077,7 @@ DELIMITER_CHOICES = [
(',', mark_safe(ugettext("delimiter__comma"))),
(';', mark_safe(ugettext("delimiter__semicolon"))),
('two_col\t', mark_safe(ugettext("delimiter__two_col_tabulation_format"))),
('two_col ', mark_safe(ugettext("delimiter__two_col_tabulation_format"))),
('two_col,', mark_safe(ugettext("delimiter__two_col_comma_format"))),
('two_col;', mark_safe(ugettext("delimiter__two_col_semicolon_format"))),
# (' ', mark_safe(ugettext("delimiter__space"))),
......@@ -1082,7 +1089,7 @@ class LiveInputVirusHostForm(forms.Form):
label=_("Virus"),
help_text=_("LiveVirusHostInput__Virus__help_text"),
required=True,
widget=forms.widgets.Textarea()
widget=forms.widgets.Textarea({'rows': '8'})
)
virus_delimiter = forms.ChoiceField(
label=_("LiveVirusHostInput__virus_delimiter__label"),
......@@ -1096,7 +1103,7 @@ class LiveInputVirusHostForm(forms.Form):
label=_("Host"),
help_text=_("LiveVirusHostInput__Host__help_text"),
required=True,
widget=forms.widgets.Textarea()
widget=forms.widgets.Textarea({'rows': '8'})
)
host_delimiter = forms.ChoiceField(
label=_("LiveVirusHostInput__host_delimiter__label"),
......@@ -1107,8 +1114,8 @@ class LiveInputVirusHostForm(forms.Form):
widget=forms.Select,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, data=None, *args, **kwargs):
super().__init__(data=data, *args, **kwargs)
self.fields["virus"].widget.attrs["placeholder"] = mark_safe(_("virus_placeholder_in_form"))
self.fields["host"].widget.attrs["placeholder"] = _("host_placeholder_in_form")
self.helper = crispy_forms_helper.FormHelper()
......@@ -1128,6 +1135,9 @@ class LiveInputVirusHostForm(forms.Form):
),
)
self.helper.form_tag = False
if not data:
self.fields["host_delimiter"].widget = forms.HiddenInput()
self.fields["virus_delimiter"].widget = forms.HiddenInput()
def get_as_list(self, key):
f = io.StringIO(self.cleaned_data[key])
......@@ -1176,19 +1186,63 @@ class LiveInputVirusHostForm(forms.Form):
if self.cleaned_data[delimiter_key] is None:
self.add_error(key, _("delimiter_not_found_please_check_input"))
self.add_error(delimiter_key, _("delimiter_not_found_please_specify_it"))
# else:
# self.add_error(key, "eee")
def get_template_files(self):
col_header = [explicit_item(n, i) for n, i in self.get_as_list('host')]
row_header = [explicit_item(n, i) for n, i in self.get_as_list('virus')]
# Prepare the legend
mapping = models.GlobalViralHostResponseValue.objects_mappable().order_by('value')
legend_name = []
legend_value = []
legend = []
for m in models.GlobalViralHostResponseValue.objects_mappable().order_by('value'):
legend_name.append([m.name])
legend_value.append(m.value)
legend.append([m.name, m.value])
df_legend = pd.DataFrame(legend_name, columns=[str(_("Recommended responses scheme"))], index=legend_value)
filename = 'Template %(seed)s generated on %(date)s at %(time)s' % dict(
date=timezone.now().strftime('%Y-%m-%d'),
time=timezone.now().strftime('%Hh%Mm%Ss'),
seed="".join([random.choice(string.ascii_letters) for i in range(4)])
)
file_path = os.path.join(settings.MEDIA_ROOT, filename + ".xlsx")
with pd.ExcelWriter(file_path) as writer:
df_data = pd.DataFrame(
data=[["<response>"] * len(col_header)] * len(row_header),
columns=col_header,
index=row_header,
)
df_data.to_excel(writer)
df_legend.to_excel(writer, startcol=1, startrow=len(row_header) + 3)
os.chmod(file_path, 0o777)
@transaction.atomic
def save(self, data_source):
for key, its_class in (("host", models.Host), ("virus", models.Virus),):
for name, identifier in self.get_as_list(key):
try:
# search obj in db
obj = its_class.objects.filter(name=name, identifier=identifier)[0]
except IndexError:
# create obj in db
obj = its_class.objects.create(name=name, identifier=identifier)
obj.data_source.add(data_source)
obj.save()
if len(col_header) > 4:
col_header = col_header[:4] + ['...', ]
data = ["<response>"] * 4 + ['...', ]
else:
data = ["<response>"] * len(col_header)
if len(row_header) > 4:
row_header = row_header[:4] + ['...', ]
data = [data] * 4 + [['...'] * len(data), ]
else:
data = [data, ] * len(row_header)
file_path = os.path.join(settings.MEDIA_ROOT, filename + ".sample.xlsx")
with pd.ExcelWriter(file_path) as writer:
df_data = pd.DataFrame(
data=data,
columns=col_header,
index=row_header,
)
df_data.to_excel(writer)
os.chmod(file_path, 0o777)
return dict(
file_url=settings.MEDIA_URL + filename + ".xlsx",
sample_path=os.path.join(settings.MEDIA_ROOT, filename + ".sample.xlsx"),
)
class ResponseUpdateForm(forms.ModelForm):
......
<div class="col-12 mt-2 mb-0"><p class="alert alert-warning text-center">
You are running branch <code>$CI_COMMIT_REF_SLUG</code>, any data changes made here are not to be kept.
You are running branch <code>$CI_COMMIT_REF_SLUG</code> commit <code>$CI_COMMIT_SHORT_SHA</code> on $WHEN, any data changes made here are not to be kept.
</p></div>
\ No newline at end of file
......@@ -397,26 +397,6 @@ class MergeTestCase(TestCase):
models.Virus.objects.update(identifier="")
models.Host.objects.update(identifier="")
def test_distinct_type_fails(self):
self.assertRaises(
ValueError,
business_process.merge_entries,
self.three_reponse_simple,
self.three_reponse_simple.virus_set.first(),
)
self.assertRaises(
ValueError,
business_process.merge_entries,
self.three_reponse_simple,
self.three_reponse_simple.host_set.first(),
)
self.assertRaises(
ValueError,
business_process.merge_entries,
self.three_reponse_simple.virus_set.first(),
self.three_reponse_simple.host_set.first(),
)
class ResetMappingTestCase(ViewTestCase):
def test_reset_mapping_ok(self):
......
......@@ -1063,6 +1063,9 @@ class DataSourceWizard(wizard_views.NamedUrlSessionWizardView):
def upload_data(self):
return self.get_upload_or_live_input() == 'upload'
def live_template_data(self):
return self.get_upload_or_live_input() == 'live'
instance = None
form_list = [
("Intro", forms.EmptyForm),
......@@ -1072,10 +1075,12 @@ class DataSourceWizard(wizard_views.NamedUrlSessionWizardView):
("UploadOrLiveInput", forms.UploadOrLiveInput),
("Upload", forms.UploadDataSourceForm),
("LiveInput", forms.LiveInputVirusHostForm),
("LiveTemplate", forms.UploadDataSourceForm),
]
condition_dict = {
"Visibility": show_visibility,
"LiveInput": live_input,
"LiveTemplate": live_template_data,
"Upload": upload_data,
}
template_name = 'viralhostrangedb/wizard_form.html'
......@@ -1090,7 +1095,7 @@ class DataSourceWizard(wizard_views.NamedUrlSessionWizardView):
context["title"] = _("Provide a new data source")
if step is None:
step = self.steps.current
if step == "Upload" or step == "LiveInput":
if step == "Upload" or step == "LiveTemplate":
context["submit_text"] = _("Save")
elif step == "UploadOrLiveInput":
context["submit_text"] = _("Proceed")
......@@ -1124,8 +1129,30 @@ class DataSourceWizard(wizard_views.NamedUrlSessionWizardView):
ugettext("step_message_Upload%(scheme)s") % dict(scheme=self.compute_scheme())
)
elif step == "LiveInput":
context["step_message"] = mark_safe(_("step_message_LiveInput"))
if form.is_bound:
context["step_message"] = mark_safe(_("step_message_LiveInput when is_bound"))
else:
# Translators : when data is provided i.e: when we were not able to guess what is the separator,
# so give more details
context["step_message"] = mark_safe(_("step_message_LiveInput when not is_bound"))
context["custom_css_width"] = "col-lg-10 offset-lg-1 "
elif step == "LiveTemplate":
f = self.get_form("LiveInput", self.storage.get_step_data("LiveInput"))
f.is_valid()
templates = f.get_template_files()
context["step_message"] = self.get_decorated_message_with_table_example(
ugettext("step_message_LiveTemplate%(scheme)s%(template_file_url)s") % dict(
scheme=self.compute_scheme(),
template_file_url=templates["file_url"],
),
table=self.get_table_for_file(
file_path=templates["sample_path"],
file_url=templates["file_url"],
legend=ugettext("Fig 1: A (subset of) your template<br/>"),
download_message=ugettext('download complete template'),
use_color=False,
),
)
if context.get("step_message", None) == "_":
del context["step_message"]
return context
......@@ -1148,23 +1175,27 @@ class DataSourceWizard(wizard_views.NamedUrlSessionWizardView):
return scheme
@staticmethod
def get_decorated_message_with_table_example(msg):
def get_table_for_file(file_path, file_url=None, legend="", download_message=None, use_color=True):
table = None
try:
vhrs_dict = business_process.to_dict(
business_process.parse_file(
os.path.join(settings.STATIC_ROOT, 'media/example.xlsx')
),
transposed=True,
)
except FileNotFoundError:
vhrs_dict = {}
table = ""
vhrs_dict = business_process.to_dict(
business_process.parse_file(file_path),
transposed=True,
)
hosts = []
for v, h_r in vhrs_dict.items():
for h, r in h_r.items():
if h not in hosts:
hosts.append(h)
for v, h_r in vhrs_dict.items():
if table is None:
table = '<table class="table table-responsive table-sm text-center use-global-scheme">'
table = ['<div class="text-center"><table class="table table-sm text-center', ]
if use_color:
table += " use-global-scheme"
else:
table += " table-bordered"
table += "\">"
table += "<tr><td></<td>"
for h, r in h_r.items():
for h in hosts:
table += '<th>'
table += h.replace(" ", "&nbsp;").replace("&nbsp;(", " (")
table += '</th>'
......@@ -1172,24 +1203,31 @@ class DataSourceWizard(wizard_views.NamedUrlSessionWizardView):
table += "<tr><th>"
table += v.replace(" ", "&nbsp;").replace("&nbsp;(", " (")
table += "</th>"
for h, r in h_r.items():
for h in hosts:
r = h_r.get(h, "")
table += '<td class="schema-'
table += r
table += escape(r)
table += '">'
table += r
table += escape(r)
table += '</td>'
table += "</tr>"
table += "</table>"
return mark_safe(
'<div style="display:flex"><div>' +