diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..5a59bba47c6f5ff8b9d0e573e748420de4d98d27
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,11 @@
+ippisite/ippidb/static/academicons-1.8.6/* linguist-vendored
+ippisite/ippidb/static/bootstrap-slider-master/* linguist-vendored
+ippisite/ippidb/static/bootstrap/* linguist-vendored
+ippisite/ippidb/static/chartjs/* linguist-vendored
+ippisite/ippidb/static/fontawesome/* linguist-vendored
+ippisite/ippidb/static/fonts/* linguist-vendored
+ippisite/ippidb/static/jquery/* linguist-vendored
+ippisite/ippidb/static/marvinjs-18/* linguist-vendored
+ippisite/ippidb/static/smilesdrawer/* linguist-vendored
+ippisite/ippidb/static/typeahead/* linguist-vendored
+ippisite/ippidb/static/url-polyfill/* linguist-vendored
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 3fd6a2438cc99ab5a8c25b128319d59622cc7cfc..34b597969b7b0ac464bc046798e0438184c6e44f 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,6 +1,7 @@
 stages:
   - test
   - deploy
+
 test-style:
   image: python:3.6
   stage: test
@@ -9,6 +10,7 @@ test-style:
   - cd ippisite
   - pip install flake8
   - flake8 --config=.flake8 
+
 test-ansible:
     image: python:3.5
     stage: test
@@ -18,6 +20,7 @@ test-ansible:
       - whoami
       - ansible-playbook system.yaml --syntax-check 
       - ansible-playbook deploy.yaml --syntax-check 
+
 test-centos7:
   services:
     - redis  
@@ -56,6 +59,27 @@ test-centos7:
   - python3.6 manage.py test
   - coverage run --source='.' manage.py test
   - coverage report
+  - coverage html
+  - pip3.6 install sphinx sphinx-argparse sphinxcontrib.bibtex sphinx_rtd_theme
+  - cd docs
+  - make html
+  artifacts:
+    paths:
+      - ippisite/htmlcov
+      - ippisite/docs/build/html
+
+pages:
+  stage: deploy
+  dependencies:
+    - test-centos7
+  script:
+    - 'mkdir -p public/$CI_COMMIT_REF_NAME'
+    - 'mv ippisite/htmlcov public/$CI_COMMIT_REF_NAME/' 
+    - 'mv ippisite/docs/build/html/ public/$CI_COMMIT_REF_NAME/'
+  artifacts:
+    paths:
+      - public
+
 deploy-webserver-targetcentric:
   image: python:3.5
   stage: deploy
@@ -78,6 +102,7 @@ deploy-webserver-targetcentric:
       --extra-vars "deploy_user_name=ippidb repo_api_token=JZS-4cH7bWkFkHa2rAVf marvinjs_apikey=$MARVINJS_APIKEY_targetcentric galaxy_base_url=$GALAXY_BASE_URL_targetcentric galaxy_apikey=$GALAXY_APIKEY_targetcentric galaxy_compoundproperties_workflowid=$GALAXY_COMPOUNDPROPERTIES_WORKFLOWID_targetcentric secret_key=$SECRET_KEY_targetcentric dbname=$DBNAME_targetcentric dbuser=$DBUSER_targetcentric dbpassword=$DBPASSWORD_targetcentric dbhost=$DBHOST_targetcentric dbport=$DBPORT_targetcentric http_port=$HTTP_PORT_targetcentric branch=targetcentric"
   only:
     - targetcentric
+
 deploy-webserver-test:
   image: python:3.5
   stage: deploy
@@ -97,9 +122,10 @@ deploy-webserver-test:
     - cd ansible
     - whoami
     - ansible-playbook -vvv -i ./hosts_master deploy.yaml 
-      --extra-vars "deploy_user_name=ippidb repo_api_token=JZS-4cH7bWkFkHa2rAVf marvinjs_apikey=$MARVINJS_APIKEY_master galaxy_base_url=$GALAXY_BASE_URL_master galaxy_apikey=$GALAXY_APIKEY_master galaxy_compoundproperties_workflowid=$GALAXY_COMPOUNDPROPERTIES_WORKFLOWID_master secret_key=$SECRET_KEY_master dbname=$DBNAME_master dbuser=$DBUSER_master dbpassword=$DBPASSWORD_master dbhost=$DBHOST_master dbport=$DBPORT_master http_port=$HTTP_PORT_master branch=$CI_COMMIT_REF_NAME"
+      --extra-vars "deploy_user_name=ippidb repo_api_token=JZS-4cH7bWkFkHa2rAVf marvinjs_apikey=$MARVINJS_APIKEY_master galaxy_base_url=$GALAXY_BASE_URL_master galaxy_apikey=$GALAXY_APIKEY_master galaxy_compoundproperties_workflowid=$GALAXY_COMPOUNDPROPERTIES_WORKFLOWID_master secret_key=$SECRET_KEY_master dbname=$DBNAME_master dbuser=$DBUSER_master dbpassword=$DBPASSWORD_master dbhost=$DBHOST_master dbport=$DBPORT_master http_port=$HTTP_PORT_master branch=$CI_COMMIT_REF_NAME gacode=$GACODE_master"
   only:
     - master
+
 deploy-webserver-production:
   image: python:3.5
   stage: deploy
@@ -119,6 +145,6 @@ deploy-webserver-production:
     - cd ansible
     - whoami
     - ansible-playbook -vvv -i ./hosts_release deploy.yaml 
-      --extra-vars "deploy_user_name=ippidb repo_api_token=JZS-4cH7bWkFkHa2rAVf marvinjs_apikey=$MARVINJS_APIKEY_release galaxy_base_url=$GALAXY_BASE_URL_release galaxy_apikey=$GALAXY_APIKEY_release galaxy_compoundproperties_workflowid=$GALAXY_COMPOUNDPROPERTIES_WORKFLOWID_release secret_key=$SECRET_KEY_release dbname=$DBNAME_release dbuser=$DBUSER_release dbpassword=$DBPASSWORD_release dbhost=$DBHOST_release dbport=$DBPORT_release http_port=$HTTP_PORT_release branch=$CI_COMMIT_REF_NAME"
+      --extra-vars "deploy_user_name=ippidb repo_api_token=JZS-4cH7bWkFkHa2rAVf marvinjs_apikey=$MARVINJS_APIKEY_release galaxy_base_url=$GALAXY_BASE_URL_release galaxy_apikey=$GALAXY_APIKEY_release galaxy_compoundproperties_workflowid=$GALAXY_COMPOUNDPROPERTIES_WORKFLOWID_release secret_key=$SECRET_KEY_release dbname=$DBNAME_release dbuser=$DBUSER_release dbpassword=$DBPASSWORD_release dbhost=$DBHOST_release dbport=$DBPORT_release http_port=$HTTP_PORT_release branch=$CI_COMMIT_REF_NAME  gacode=$GACODE_release"
   only:
     - release
diff --git a/ansible/celery.service b/ansible/celery.service
index 7c3374d1b34723773f75e4c0aa02ce4253312896..9db00b330cad7acbfbaa4ce382487fc56ddab23f 100644
--- a/ansible/celery.service
+++ b/ansible/celery.service
@@ -1,19 +1,21 @@
 [Unit]
-Description=Celery Service
+Description=Celery Service for iPPI-DB running on port {{ http_port }}
 After=network.target
 
 [Service]
 Type=forking
-User=celery
+User=celery-{{ http_port }}
 Group=ippidb
 EnvironmentFile=-/etc/default/ippidb-{{ http_port }}-celeryd
 WorkingDirectory=/home/ippidb/ippidb-web-{{ http_port }}/ippisite
 ExecStart=/bin/sh -c '${CELERY_BIN} multi start ${CELERYD_NODES} \
+  -Q ${CELERYD_QUEUE} \
   -A ${CELERY_APP} --pidfile=${CELERYD_PID_FILE} \
   --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS}'
 ExecStop=/bin/sh -c '${CELERY_BIN} multi stopwait ${CELERYD_NODES} \
   --pidfile=${CELERYD_PID_FILE}'
 ExecReload=/bin/sh -c '${CELERY_BIN} multi restart ${CELERYD_NODES} \
+  -Q ${CELERYD_QUEUE} \
   -A ${CELERY_APP} --pidfile=${CELERYD_PID_FILE} \
   --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS}'
 
diff --git a/ansible/celeryd b/ansible/celeryd
index 0f9f367a637ee253a82b5d24531d0700e1eee6a4..1ce118adaf50ca45fb3b116eb7a2f314d492a201 100644
--- a/ansible/celeryd
+++ b/ansible/celeryd
@@ -6,8 +6,9 @@ CELERYD_OPTS="--time-limit=3000 --concurrency=1 --max-tasks-per-child=1"
 CELERYD_LOG_FILE="/var/ippidb-{{ http_port }}-celery/celery%n%I.log"
 CELERYD_PID_FILE="/var/ippidb-{{ http_port }}-celery/celery%n.pid"
 CELERYD_LOG_LEVEL="DEBUG"
-CELERYD_USER="celery"
+CELERYD_USER="celery-{{ http_port }}"
 CELERYD_GROUP="ippidb"
+CELERYD_QUEUE="celery-{{ http_port }}"
 CELERY_CREATE_DIRS=1
 SYSTEMD_LOG_LEVEL=debug
 DJANGO_SETTINGS_MODULE=ippisite.{{ ansible_hostname }}_settings
diff --git a/ansible/deploy.yaml b/ansible/deploy.yaml
index 64d6571448b04ec01e54d533c526f1146513f461..346a6d21bfc032a9ef37451742a3e7b04ea133f9 100644
--- a/ansible/deploy.yaml
+++ b/ansible/deploy.yaml
@@ -10,7 +10,7 @@
       selinux:
         state: disabled
     - name: Create celery user
-      user: name=celery groups={{ deploy_user_name }} append=yes state=present createhome=yes 
+      user: name=celery-{{ http_port }} groups={{ deploy_user_name }} append=yes state=present createhome=yes 
       become: true
       register: newuser  
     # Install basic non-virtualenv requirements
@@ -130,11 +130,11 @@
     - name: stop "generic" httpd service if relevant
       systemd: state=stopped name=httpd
     - name: stop iPPIDB service if relevant
-      systemd: state=stopped name=ippidb-web
+      systemd: state=stopped name=ippidb{{ http_port }}-web
       #ignore fail (i.e. when service does not exist yet)
       ignore_errors: yes
     - name: stop celery service
-      systemd: state=stopped name=celery enabled=true
+      systemd: state=stopped name=celery-{{ http_port }} enabled=true
       ignore_errors: yes
     #
     # Set up celery service
@@ -160,7 +160,7 @@
     - name: copy celery systemd service
       template:
         src: celery.service
-        dest: /lib/systemd/system/celery.service
+        dest: /lib/systemd/system/celery-{{ http_port }}.service
         force: yes
         owner: root
         group: root
@@ -214,6 +214,19 @@
               }
           }
         marker: "# {mark} ANSIBLE MANAGED DATABASE SETTINGS"
+    - name: Configure the CELERY QUEUE to submit tasks to from Django
+      blockinfile:
+        path: "{{ checkout_path }}/ippisite/ippisite/{{ ansible_hostname }}_settings.py"
+        block: |
+          CELERY_TASK_DEFAULT_QUEUE = "celery-{{ http_port }}"
+        marker: "# {mark} ANSIBLE MANAGED CELERY DEFAULT TASK QUEUE"
+    - name: Add database settings to iPPI-DB settings
+      blockinfile:
+        path: "{{ checkout_path }}/ippisite/ippisite/{{ ansible_hostname }}_settings.py"
+        block: |
+          GA_CODE = "{{ gacode }}"
+        marker: "# {mark} ANSIBLE MANAGED GOOGLE ANALYTICS ID"
+      when: gacode is defined
     - name: Add email/debug settings to iPPI-DB settings
       blockinfile:
         path: "{{ checkout_path }}/ippisite/ippisite/{{ ansible_hostname }}_settings.py"
@@ -282,7 +295,7 @@
         BABEL_LIBDIR: "/usr/lib64/openbabel/"
     - name: create mod_wsgi configuration
       django_manage:
-        command: "runmodwsgi --setup-only --port={{ http_port }} --user ippidb --group wheel --server-root={{ service_conf_path }}"
+        command: "runmodwsgi --setup-only --port={{ http_port }} --user ippidb --group wheel --server-root={{ service_conf_path }} --url-alias /media {{ checkout_path }}/ippisite/media"
         app_path: "{{ checkout_path }}/ippisite"
         settings: "ippisite.{{ ansible_hostname }}_settings"
       environment:
@@ -338,4 +351,4 @@
     # Start celery service
     #
     - name: start celery service if relevant
-      systemd: state=started name=celery enabled=true daemon_reload=true
+      systemd: state=started name=celery-{{ http_port }} enabled=true daemon_reload=true
diff --git a/ippisite/docs/source/conf.py b/ippisite/docs/source/conf.py
index 56faa9fd2f94c12ab8d1c36cbc3c7ff26a87640a..2a36bfceca8a59f6951aaaa54dda5e4e70c9656f 100644
--- a/ippisite/docs/source/conf.py
+++ b/ippisite/docs/source/conf.py
@@ -18,6 +18,10 @@
 import os
 import sys
 
+import matplotlib
+# avoid using tkinter with matplotlib
+matplotlib.use('agg')
+
 sys.path.insert(0, os.path.abspath('../..'))
 import django
 os.environ['DJANGO_SETTINGS_MODULE'] = 'ippisite.settings'
@@ -208,5 +212,7 @@ napoleon_use_rtype = False  # More legible
 
 # The suffix of source filenames.
 autosummary_generate = True
-
 exclude_patterns = ['_build']
+
+#do not try to import tkinter for sphinx
+autodoc_mock_imports = ['_tkinter']
\ No newline at end of file
diff --git a/ippisite/ippidb/admin.py b/ippisite/ippidb/admin.py
index 45385a302e94dc4d7baa6b881daa52ed54d19646..10ced876180be13ee0d6aabc6dda5946331ea94d 100644
--- a/ippisite/ippidb/admin.py
+++ b/ippisite/ippidb/admin.py
@@ -27,6 +27,7 @@ from .models import (
     Ppi,
     ProteinDomainComplex,
     Contribution,
+    Job,
 )
 from .tasks import launch_validate_contributions
 
@@ -60,6 +61,62 @@ class ViewOnSiteModelAdmin(admin.ModelAdmin):
     )
 
 
+@admin.register(Job)
+class JobModelAdmin(admin.ModelAdmin):
+    date_hierarchy = "task_result__date_done"
+    list_display = (
+        "task_result_task_name",
+        "task_result_task_id",
+        "task_result_status",
+        "task_result_date_created",
+        "task_result_date_done",
+    )
+    list_filter = (
+        "task_result__status",
+        "task_result__date_done",
+        "task_result__task_name",
+    )
+    readonly_fields = (
+        "task_result_task_name",
+        "task_result_task_id",
+        "task_result_status",
+        "task_result_date_created",
+        "task_result_date_done",
+    )
+    search_fields = (
+        "task_result__task_name",
+        "task_result__task_id",
+        "task_result__status",
+    )
+    fields = (
+        ("task_result_task_name", "task_result_task_id"),
+        "task_result_status",
+        ("task_result_date_created", "task_result_date_done"),
+        ("std_out", "std_err"),
+    )
+
+    def task_result_task_id(self, x):
+        return x.task_result.task_id
+
+    def task_result_task_name(self, x):
+        return x.task_result.task_name
+
+    def task_result_date_done(self, x):
+        return x.task_result.date_done
+
+    def task_result_status(self, x):
+        return x.task_result.status
+
+    def task_result_date_created(self, x):
+        return x.task_result.date_created
+
+    task_result_task_id.short_description = "task_id"
+    task_result_task_name.short_description = "task_name"
+    task_result_date_done.short_description = "date_done"
+    task_result_status.short_description = "status"
+    task_result_date_created.short_description = "date_created"
+
+
 @admin.register(Bibliography)
 class BibliographyModelAdmin(ViewOnSiteModelAdmin):
     list_display = ("authors_list", "title", "journal_name", "biblio_year", "id_source")
@@ -134,7 +191,7 @@ class ContributionModelAdmin(ViewOnSiteModelAdmin):
 
     def validate_contributions(self, request, queryset):
         ids = [id for id in queryset.values_list("id", flat=True)]
-        launch_validate_contributions.delay(ids)
+        launch_validate_contributions(ids)
         self.message_user(
             request,
             f"validation started for contributions(s) "
diff --git a/ippisite/ippidb/forms.py b/ippisite/ippidb/forms.py
index 4e3bea06d5c1dec611d54aee50906ae33e440f5e..45fd7a56747c5177228710cbbbc652c40061cd6a 100644
--- a/ippisite/ippidb/forms.py
+++ b/ippisite/ippidb/forms.py
@@ -465,9 +465,9 @@ class PpiModelForm(ModelForm):
         label=_("Total number of pockets in the complex"), required=True
     )
     family_name = CharFieldDataList(
-        # data_class=models.PpiFamily,
+        data_class=models.PpiFamily,
         data_list=[],
-        # data_attr='name',
+        data_attr="name",
         label="PPI Family",
         max_length=30,
         required=True,
@@ -839,7 +839,7 @@ class BaseInlineNestedFormSet(forms.BaseInlineFormSet):
 class CompoundActivityResultForm(ModelForm):
     compound_name = forms.ChoiceField(choices=(), required=True)
     activity_mol = forms.DecimalField(
-        label="Activity", required=True, max_digits=12, decimal_places=10, min_value=0,
+        label="Activity", required=True, max_digits=15, decimal_places=10, min_value=0,
     )
     activity_unit = forms.CharField(
         label="Activity unit",
@@ -937,10 +937,20 @@ class CompoundActivityResultForm(ModelForm):
 
 class CompoundActivityResultBaseInlineNestedFormSet(BaseInlineNestedFormSet):
     __compound_names = []
+    # pIC50, pEC50, etc. activity types are labelled below as IC50, EC50, etc.
+    # specifically because the user enters them in these forms as the former
+    # value but they are converted to and stored in the latter.
+    __activity_types = [
+        ("pIC50", "IC50 (half maximal inhibitory concentration)"),
+        ("pEC50", "EC50 (half maximal effective concentration)"),
+        ("pKd", "Kd (dissociation constant)"),
+        ("pKi", "Ki (inhibition constant)"),
+    ]
 
     def add_fields(self, form, index):
         super().add_fields(form, index)
         form.fields["compound_name"].choices = self.__compound_names
+        form.fields["activity_type"].choices = self.__activity_types
 
     def set_modulation_type(self, modulation_type):
         for form in self.forms:
@@ -1074,7 +1084,7 @@ class TestActivityDescriptionForm(forms.ModelForm):
 
     def clean(self):
         cleaned_data = super().clean()
-        if "test_type" in cleaned_data and cleaned_data["test_type"] == "CELL":
+        if "test_type" in cleaned_data and cleaned_data["test_type"] != "CELL":
             cleaned_data["cell_line_name"] = ""
         return cleaned_data
 
@@ -1088,7 +1098,10 @@ class TestActivityDescriptionForm(forms.ModelForm):
         """
         # right
         if hasattr(self, "cleaned_data"):
-            if "cell_line_name" in self.cleaned_data:
+            if (
+                "cell_line_name" in self.cleaned_data
+                and self.cleaned_data["cell_line_name"] != ""
+            ):
                 cell_line, created = models.CellLine.objects.get_or_create(
                     name=self.cleaned_data["cell_line_name"]
                 )
diff --git a/ippisite/ippidb/management/commands/compute_compound_properties.py b/ippisite/ippidb/management/commands/compute_compound_properties.py
deleted file mode 100644
index 22153cacb941501cc32c490117c2e4f4201edaee..0000000000000000000000000000000000000000
--- a/ippisite/ippidb/management/commands/compute_compound_properties.py
+++ /dev/null
@@ -1,321 +0,0 @@
-import argparse
-from datetime import datetime
-from itertools import islice
-import json
-import re
-import time
-import tempfile
-
-from bioblend.galaxy import GalaxyInstance
-from django.conf import settings
-from django.core.management import BaseCommand
-from django.forms.models import model_to_dict
-import pandas as pd
-import requests
-
-from ippidb.models import Compound
-from ippidb.utils import smi2sdf
-
-# disable insecure HTTP request warnings (used by bioblend)
-import urllib3
-
-urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
-
-BASE_URL = settings.GALAXY_BASE_URL
-KEY = settings.GALAXY_APIKEY
-WORKFLOW_ID = settings.GALAXY_COMPOUNDPROPERTIES_WORKFLOWID
-
-
-class GalaxyCompoundPropertiesRunner(object):
-    def __init__(self, galaxy_instance):
-        self.galaxy_instance = galaxy_instance
-
-    def compute_properties_for_sdf_file(self, sdf_file_path):
-        # create a history to store the workflow results
-        now = datetime.now()
-        date_time = now.strftime("%Y/%m/%d-%H:%M:%S")
-        history_name = "compoundpropertiesjobrun_%s" % date_time
-        history = self.galaxy_instance.histories.create_history(name=history_name)
-        history_id = history["id"]
-        if history["state"] not in ["new", "ok"]:
-            raise Exception(
-                f'Error creating history "{history_name}" (id {history_id})'
-            )
-        # launch data upload job
-        upload_response = self.galaxy_instance.tools.upload_file(
-            path=sdf_file_path, file_type="sdf", history_id=history_id
-        )
-        upload_data_id = upload_response["outputs"][0]["id"]
-        upload_job = upload_response["jobs"][0]
-        upload_job_id = upload_job["id"]
-        # monitor data upload until completed or on error
-        while upload_job["state"] not in ["ok"]:
-            time.sleep(2)
-            upload_job = self.galaxy_instance.jobs.show_job(upload_job_id)
-            if upload_job["state"] in ["error", "deleted", "discarded"]:
-                data = self.galaxy_instance.datasets.show_dataset(upload_data_id)
-                raise Exception(
-                    f"Error during Galaxy data upload job - name : "
-                    f"{data['name']}, id : {upload_data_id}, "
-                    f"error : {data['misc_info']}"
-                )
-        # check uploaded dataset status
-        data = self.galaxy_instance.datasets.show_dataset(upload_data_id)
-        if data["state"] not in ["ok"]:
-            raise Exception(
-                f"Error during Galaxy data upload result - name : "
-                f"{data['name']}, id : {upload_data_id}, "
-                f"error : {data['misc_info']}"
-            )
-        # submit compound properties computation job
-        dataset_map = {"0": {"src": "hda", "id": upload_data_id}}
-        workflow_job = self.galaxy_instance.workflows.invoke_workflow(
-            WORKFLOW_ID, inputs=dataset_map, history_id=history_id
-        )
-        workflow_job_id = workflow_job["id"]
-        while workflow_job["state"] not in ["ok", "scheduled"]:
-            time.sleep(2)
-            workflow_job = self.galaxy_instance.workflows.show_invocation(
-                "dad6103ff71ca4fe", workflow_job_id
-            )
-            if workflow_job["state"] in ["error", "deleted", "discarded"]:
-                raise Exception(
-                    f"Error during Galaxy workflow job - name : "
-                    f"id : {workflow_job_id}, "
-                )
-        datasets = self.galaxy_instance.histories.show_history(
-            history_id, contents=True
-        )
-        actual_result_dataset = None
-        for dataset in datasets:
-            if dataset["extension"] == "json":
-                actual_result_dataset = dataset
-        if actual_result_dataset is None:
-            raise Exception(
-                f"Result for galaxy workflow invocation {workflow_job_id} not found in"
-                f" history {history_id}"
-            )
-        dataset = self.galaxy_instance.datasets.show_dataset(
-            actual_result_dataset["id"]
-        )
-        while dataset["state"] not in ["ok"]:
-            time.sleep(2)
-            dataset = self.galaxy_instance.datasets.show_dataset(
-                actual_result_dataset["id"]
-            )
-        download_url = dataset["download_url"]
-        contents_resp = requests.get(BASE_URL + download_url, verify=False)
-        contents = contents_resp.json()
-        return contents
-
-
-def idrange_type(s, pat=re.compile(r"^(\d+)-(\d+)$")):
-    m = pat.match(s)
-    if not m:
-        raise argparse.ArgumentTypeError(
-            "please specify ID range as [start number]-[endnumber]"
-        )
-    return (int(m.groups()[0]), int(m.groups()[1]))
-
-
-def dec(decimal_places):
-    def func(number):
-        return round(float(number), decimal_places)
-
-    return func
-
-
-def chunks(data, size=10):
-    it = iter(data)
-    for i in range(0, len(data), size):
-        yield {k: data[k] for k in islice(it, size)}
-
-
-class Command(BaseCommand):
-
-    help = "Compute compound physicochemical properties"
-
-    def add_arguments(self, parser):
-        parser.add_argument(
-            "mode", choices=["update", "compare", "print"], default="update"
-        )
-        selection = parser.add_mutually_exclusive_group(required=True)
-        selection.add_argument(
-            "--all", action="store_true", help="Process all compounds in the database"
-        )
-        selection.add_argument(
-            "--ids",
-            nargs="+",
-            type=int,
-            help="Process the compounds for the specified IDs",
-        )
-        selection.add_argument(
-            "--idrange",
-            type=idrange_type,
-            help="Process the compounds for the specified ID range",
-        )
-        parser.add_argument(
-            "--json",
-            type=argparse.FileType("r"),
-            help="Process precomputed results stored in a JSON file",
-        )
-        parser.add_argument(
-            "--xls", type=argparse.FileType("w"), help="Store results in Excel file"
-        )
-
-    def handle(self, *args, **options):
-        # select the compounds that need to be processed
-        smiles_dict = {}
-        compounds = []
-        pc_properties = {}
-        already_done_ids = []
-        if options["json"] is not None:
-            pc_properties_dict = json.load(open(options["json"].name, "r"))
-            ids = [
-                int(key)
-                for key, item in pc_properties_dict.items()
-                if "IUPAC" not in item
-            ]
-            already_done_ids = [
-                int(key) for key, item in pc_properties_dict.items() if "IUPAC" in item
-            ]
-        if options["all"] is True:
-            ids = Compound.objects.all().values("id")
-        elif options["ids"]:
-            ids = Compound.objects.filter(id__in=options["ids"]).values("id")
-        elif options["idrange"]:
-            ids = Compound.objects.filter(
-                id__gte=options["idrange"][0], id__lte=options["idrange"][1]
-            ).values("id")
-        else:
-            compounds = Compound.objects.filter(iupac_name__isnull=True).values("id")
-        ids = [row["id"] for row in ids]
-        ids = list(set(ids) - set(already_done_ids))
-        compounds = Compound.objects.filter(id__in=ids)
-        for c in compounds:
-            smiles_dict[c.id] = c.canonical_smile
-        # create or reuse existing JSON file to save new results
-        if options["json"]:
-            json_file = options["json"].name
-        else:
-            json_fh = tempfile.NamedTemporaryFile(mode="w", delete=False)
-            json.dump({c.id: {} for c in compounds}, json_fh)
-            json_file = json_fh.name
-            json_fh.close()
-        self.stderr.write(self.style.SUCCESS(f"Compound properties file: {json_file}"))
-        if len(compounds) > 0:
-            self.stderr.write(f"Now processing {len(compounds)} compounds")
-            # set up Galaxy computation environment
-            gi = GalaxyInstance(url=BASE_URL, key=KEY, verify=False)
-            gi.nocache = True
-            runner = GalaxyCompoundPropertiesRunner(gi)
-            chunk_size = 3
-            for smiles_dict_chunk in chunks(smiles_dict, chunk_size):
-                # create SDF file for the selection
-                sdf_string = smi2sdf(smiles_dict_chunk)
-                fh = tempfile.NamedTemporaryFile(mode="w", delete=False)
-                fh.write(sdf_string)
-                fh.close()
-                self.stderr.write(
-                    self.style.SUCCESS(
-                        f"Galaxy input SDF file for compounds {smiles_dict_chunk.keys()}: {fh.name}"
-                    )
-                )
-                # run computations on Galaxy
-                pc_properties = runner.compute_properties_for_sdf_file(fh.name)
-                new_pc_properties_dict = {
-                    compound["Name"]: compound for compound in pc_properties
-                }
-                pc_properties_dict = json.load(open(json_file, "r"))
-                pc_properties_dict.update(new_pc_properties_dict)
-                fh = open(json_file, "w")
-                json.dump(pc_properties_dict, fh, indent=4)
-                fh.close()
-                self.stderr.write(
-                    self.style.SUCCESS(
-                        f"Properties added for compounds {smiles_dict_chunk.keys()} in JSON file: {json_file}"
-                    )
-                )
-        # report and update database
-        property_mapping = {
-            "CanonicalSmile": ("canonical_smile", str),
-            "IUPAC": ("iupac_name", str),
-            "TPSA": ("tpsa", dec(2)),
-            "NbMultBonds": ("nb_multiple_bonds", int),
-            "BalabanIndex": ("balaban_index", dec(2)),
-            "NbDoubleBonds": ("nb_double_bonds", int),
-            "RDF070m": ("rdf070m", dec(2)),
-            "SumAtomPolar": ("sum_atom_polar", dec(2)),
-            "SumAtomVolVdW": ("sum_atom_vol_vdw", dec(2)),
-            "MolecularWeight": ("molecular_weight", dec(2)),
-            "NbCircuits": ("nb_circuits", int),
-            "NbAromaticsSSSR": ("nb_aromatic_sssr", int),
-            "NbAcceptorH": ("nb_acceptor_h", int),
-            "NbF": ("nb_f", int),
-            "Ui": ("ui", dec(2)),
-            "NbO": ("nb_o", int),
-            "NbCl": ("nb_cl", int),
-            "NbBonds": ("nb_bonds", int),
-            "LogP": ("a_log_p", dec(2)),
-            "RandicIndex": ("randic_index", dec(2)),
-            "NbBondsNonH": ("nb_bonds_non_h", int),
-            "NbAromaticsEther": ("nb_aromatic_ether", int),
-            "NbChiralCenters": ("nb_chiral_centers", int),
-            "NbBenzLikeRings": ("nb_benzene_like_rings", int),
-            "RotatableBondFraction": ("rotatable_bond_fraction", dec(2)),
-            "LogD": ("log_d", dec(2)),
-            "WienerIndex": ("wiener_index", int),
-            "NbN": ("nb_n", int),
-            "NbC": ("nb_c", int),
-            "NbAtom": ("nb_atom", int),
-            "NbAromaticsBonds": ("nb_aromatic_bonds", int),
-            "MeanAtomVolVdW": ("mean_atom_vol_vdw", dec(2)),
-            "AromaticRatio": ("aromatic_ratio", dec(2)),
-            "NbAtomNonH": ("nb_atom_non_h", int),
-            "NbDonorH": ("nb_donor_h", int),
-            "NbI": ("nb_i", int),
-            "NbRotatableBonds": ("nb_rotatable_bonds", int),
-            "NbRings": ("nb_rings", int),
-            "NbCsp2": ("nb_csp2", int),
-            "NbCsp3": ("nb_csp3", int),
-            "NbBr": ("nb_br", int),
-            "GCMolarRefractivity": ("gc_molar_refractivity", dec(2)),
-            "NbAliphaticsAmines": ("nb_aliphatic_amines", int),
-        }
-        properties_list = []
-        computed_properties = ["id"] + [
-            value[0] for key, value in property_mapping.items()
-        ]
-        ippidb_convs = {value[0]: value[1] for key, value in property_mapping.items()}
-        ippidb_convs["id"] = int
-        pc_properties_dict = json.load(open(json_file))
-        for cid, item in pc_properties_dict.items():
-            compound = Compound.objects.get(id=cid)
-            ippidb_dict = model_to_dict(Compound.objects.get(id=cid))
-            ippidb_dict = {
-                key: ippidb_convs[key](value)
-                for key, value in ippidb_dict.items()
-                if key in computed_properties and value is not None
-            }
-            ippidb_dict["source"] = "iPPI-DB"
-            galaxy_dict = {"id": int(cid), "source": "Galaxy"}
-            for galaxy_prop, prop in property_mapping.items():
-                ippidb_prop = prop[0]
-                ippidb_conv = prop[1]
-                try:
-                    galaxy_dict[ippidb_prop] = ippidb_conv(item[galaxy_prop])
-                except ValueError as ve:
-                    self.stderr.write(
-                        self.style.ERROR(
-                            f"Error setting property {ippidb_prop} to {item[galaxy_prop]}"
-                            f" in compound {compound.id} \ndetails:{ve}"
-                        )
-                    )
-            properties_list.append(ippidb_dict)
-            properties_list.append(galaxy_dict)
-        properties_df = pd.DataFrame(properties_list)
-        properties_df.set_index(["id", "source"], inplace=True)
-        properties_df.sort_index(inplace=True)
-        if options["xls"]:
-            properties_df.to_excel(options["xls"].name)
-        self.stderr.write(self.style.SUCCESS("All done!"))
diff --git a/ippisite/ippidb/management/commands/lle_le.py b/ippisite/ippidb/management/commands/lle_le.py
index 0ac9cbad35a80f4c91e14cb65d06b45a1df30251..73aac5064dd9e80689d8c4fa32d46084ae9d5955 100644
--- a/ippisite/ippidb/management/commands/lle_le.py
+++ b/ippisite/ippidb/management/commands/lle_le.py
@@ -1,8 +1,6 @@
-import json
-
 from django.core.management import BaseCommand
 
-from ippidb.models import Compound, LeLleBiplotData
+from ippidb.tasks import generate_le_lle_plot
 
 
 class Command(BaseCommand):
@@ -11,28 +9,7 @@ class Command(BaseCommand):
 
     def handle(self, *args, **options):
         self.stdout.write(self.style.SUCCESS("Generating the LE vs. LLE biplot..."))
-        le_lle_data = []
-        LeLleBiplotData.objects.all().delete()
-        self.stdout.write(self.style.SUCCESS("Successfully flushed LE-LLE biplot data"))
-        for comp in Compound.objects.validated():
-            if comp.le is not None:
-                le = round(comp.le, 7)
-                lle = round(comp.lle, 7)
-                le_lle_data.append(
-                    {
-                        "x": le,
-                        "y": lle,
-                        "id": comp.id,
-                        "family_name": comp.best_activity_ppi_family_name,
-                        "smiles": comp.canonical_smile,
-                    }
-                )
-            else:
-                self.stdout.write(self.style.WARNING("compound %s has no LE" % comp.id))
-        le_lle_json = json.dumps(le_lle_data, separators=(",", ":"))
-        new = LeLleBiplotData()
-        new.le_lle_biplot_data = le_lle_json
-        new.save()
+        generate_le_lle_plot()
         self.stdout.write(
             self.style.SUCCESS("Successfully generated LE-LLE biplot data")
         )
diff --git a/ippisite/ippidb/management/commands/pca.py b/ippisite/ippidb/management/commands/pca.py
index 330d4452dc7b08414105f05d80c2cc9a671dc315..89beebb30b1b7542a0f93674e75b20196ec0db22 100644
--- a/ippisite/ippidb/management/commands/pca.py
+++ b/ippisite/ippidb/management/commands/pca.py
@@ -1,26 +1,6 @@
-import json
-import io
-import base64
-import itertools
-
 from django.core.management import BaseCommand
-from django.forms.models import model_to_dict
-import seaborn as sns
-import matplotlib.pyplot as plt
-import numpy as np
-import pandas as pd
-from sklearn.decomposition import PCA
-from sklearn.preprocessing import StandardScaler
-
-from ippidb.models import Compound, PcaBiplotData
-
 
-def plot_circle():
-    theta = np.linspace(0, 2 * np.pi, 100)
-    r = np.sqrt(1.0)
-    x1 = r * np.cos(theta)
-    x2 = r * np.sin(theta)
-    return x1, x2
+from ippidb.tasks import generate_pca_plot
 
 
 class Command(BaseCommand):
@@ -29,124 +9,5 @@ class Command(BaseCommand):
 
     def handle(self, *args, **options):
         self.stdout.write(self.style.SUCCESS("Generating the PCA biplot..."))
-        pca_data = []
-        features = [
-            "molecular_weight",
-            "a_log_p",
-            "nb_donor_h",
-            "nb_acceptor_h",
-            "tpsa",
-            "nb_rotatable_bonds",
-            "nb_benzene_like_rings",
-            "fsp3",
-            "nb_chiral_centers",
-            "nb_csp3",
-            "nb_atom",
-            "nb_bonds",
-            "nb_atom_non_h",
-            "nb_rings",
-            "nb_multiple_bonds",
-            "nb_aromatic_bonds",
-            "aromatic_ratio",
-        ]
-        PcaBiplotData.objects.all().delete()
-        self.stdout.write(self.style.SUCCESS("Successfully flushed PCA biplot data"))
-        values_list = []
-        for comp in Compound.objects.validated():
-            values = model_to_dict(comp, fields=features + ["id", "family"])
-            values["family"] = comp.best_activity_ppi_family_name
-            values_list.append(values)
-        df = pd.DataFrame(values_list)
-        # drop compounds with undefined property values
-        df.dropna(how="any", inplace=True)
-        # prepare correlation circle figure
-        plt.switch_backend("Agg")
-        fig_, ax = plt.subplots(figsize=(6, 6))
-        x1, x2 = plot_circle()
-        plt.plot(x1, x2)
-        ax.set_aspect(1)
-        ax.yaxis.set_label_coords(-0.1, 0.5)
-        ax.xaxis.set_label_coords(0.5, -0.08)
-        # do not process the data unless there are compounds in the dataframe
-        if len(df.index) > 0:
-            x = df.loc[:, features].values
-            y = df.loc[:, ["family"]].values
-            x = StandardScaler().fit_transform(x)
-            n = x.shape[0]  # retrieve number of individuals
-            p = x.shape[1]  # retrieve number of variables
-            pca = PCA(n_components=p)
-            principal_components = pca.fit_transform(x)
-            # compute correlation circle
-            variance_ratio = pd.Series(pca.explained_variance_ratio_)
-            coef = np.transpose(pca.components_)
-            cols = ["PC-" + str(x) for x in range(len(variance_ratio))]
-            pc_infos = pd.DataFrame(coef, columns=cols, index=features)
-            # we might remove the line below if the PCA remains grayscale
-            pal = itertools.cycle(  # noqa: F841
-                sns.color_palette("dark", len(features))
-            )
-            # compute the length of each feature arrow in the correlation circle
-            pc_infos["DIST"] = pc_infos[["PC-0", "PC-1"]].pow(2).sum(1).pow(0.5)
-            # store the maximal length for normalization purposes
-            best_distance = max(pc_infos["DIST"])
-            # compute corvar for the correlation circle
-            eigval = (float(n) - 1) / float(n) * pca.explained_variance_
-            sqrt_eigval = np.sqrt(eigval)
-            sqrt_eigval = np.sqrt(eigval)
-            corvar = np.zeros((p, p))
-            for k in range(p):
-                corvar[:, k] = pca.components_[k, :] * sqrt_eigval[k]
-            for idx in range(len(pc_infos["PC-0"])):
-                x = corvar[idx, 0]  # use corvar to plot the variable map
-                y = corvar[idx, 1]  # use corvar to plot the variable map
-                color = "black"
-                # alpha is the feature length normalized
-                # to the longest feature's length
-                alpha = pc_infos["DIST"][idx] / best_distance
-                plt.arrow(0.0, 0.0, x, y, head_width=0.02, color="black", alpha=alpha)
-                plt.annotate(
-                    Compound._meta.get_field(pc_infos.index[idx]).verbose_name,
-                    xy=(x, y),
-                    xycoords="data",
-                    xytext=np.asarray((x, y)) + (0.02, -0.02),
-                    fontsize=6,
-                    color=color,
-                    alpha=alpha,
-                )
-            plt.xlabel("PC-1 (%s%%)" % str(variance_ratio[0])[:4].lstrip("0."))
-            plt.ylabel("PC-2 (%s%%)" % str(variance_ratio[1])[:4].lstrip("0."))
-            plt.xlim((-1, 1))
-            plt.ylim((-1, 1))
-            principal_df = pd.DataFrame(data=principal_components)
-            # only select the two first dimensions for the plot, and rename them to x and y
-            principal_df = principal_df.iloc[:, 0:2]
-            principal_df = principal_df.rename(columns={0: "x", 1: "y"})
-            final_df = pd.concat([principal_df, df[["family", "id"]]], axis=1)
-            for index, row in final_df.iterrows():
-                smiles = Compound.objects.get(id=row.id).canonical_smile
-                pca_data.append(
-                    {
-                        "x": row.x,
-                        "y": row.y,
-                        "id": row.id,
-                        "family_name": row.family,
-                        "smiles": smiles,
-                    }
-                )
-        else:
-            pca_data = []
-        # save correlation circle PNG
-        my_string_io_bytes = io.BytesIO()
-        plt.savefig(my_string_io_bytes, format="png", dpi=600, bbox_inches="tight")
-        my_string_io_bytes.seek(0)
-        # figdata_png is the correlation circle figure, base 64-encoded
-        figdata_png = base64.b64encode(my_string_io_bytes.read())
-        pca_data_cc = {
-            "data": pca_data,
-            "correlation_circle": figdata_png.decode("utf-8"),
-        }
-        pca_json = json.dumps(pca_data_cc, separators=(",", ":"))
-        new = PcaBiplotData()
-        new.pca_biplot_data = pca_json
-        new.save()
+        generate_pca_plot()
         self.stdout.write(self.style.SUCCESS("Successfully generated PCA biplot data"))
diff --git a/ippisite/ippidb/migrations/0041_job.py b/ippisite/ippidb/migrations/0041_job.py
new file mode 100644
index 0000000000000000000000000000000000000000..58db99c1390c1eec38a2cedaa8a2a532b0a2534e
--- /dev/null
+++ b/ippisite/ippidb/migrations/0041_job.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.2.1 on 2020-03-18 23:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ippidb', '0040_compound_families'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Job',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(help_text='task name', max_length=125)),
+                ('status', models.CharField(choices=[('INIT', 'init'), ('RUNNING', 'running'), ('SUCCESS', 'success'), ('ERROR', 'error')], db_index=True, default='INIT', help_text='The status of this job.', max_length=30, verbose_name='job status')),
+                ('std_out', models.TextField(blank=True, help_text='task standard output', null=True)),
+                ('std_err', models.TextField(blank=True, help_text='task error output', null=True)),
+            ],
+        ),
+    ]
diff --git a/ippisite/ippidb/migrations/0042_auto_20200319_1032.py b/ippisite/ippidb/migrations/0042_auto_20200319_1032.py
new file mode 100644
index 0000000000000000000000000000000000000000..da4735659484fcba18ef7b2906a5fa3d19065cdc
--- /dev/null
+++ b/ippisite/ippidb/migrations/0042_auto_20200319_1032.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.2.1 on 2020-03-19 10:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ippidb', '0041_job'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='job',
+            name='std_err',
+            field=models.TextField(blank=True, default='', help_text='task error output', null=True),
+        ),
+        migrations.AlterField(
+            model_name='job',
+            name='std_out',
+            field=models.TextField(blank=True, default='', help_text='task standard output', null=True),
+        ),
+    ]
diff --git a/ippisite/ippidb/migrations/0043_job_task_id.py b/ippisite/ippidb/migrations/0043_job_task_id.py
new file mode 100644
index 0000000000000000000000000000000000000000..1c5ccd79f1f6656197e846ba42e3e228e6506b9f
--- /dev/null
+++ b/ippisite/ippidb/migrations/0043_job_task_id.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.1 on 2020-03-20 17:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ippidb', '0042_auto_20200319_1032'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='job',
+            name='task_id',
+            field=models.CharField(blank=True, help_text='task id', max_length=50, null=True),
+        ),
+    ]
diff --git a/ippisite/ippidb/migrations/0044_auto_20200323_1110.py b/ippisite/ippidb/migrations/0044_auto_20200323_1110.py
new file mode 100644
index 0000000000000000000000000000000000000000..09d458a4c876f69cfb6c924b899bbb8535800765
--- /dev/null
+++ b/ippisite/ippidb/migrations/0044_auto_20200323_1110.py
@@ -0,0 +1,34 @@
+# Generated by Django 2.2.1 on 2020-03-23 11:10
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('django_celery_results', '0007_remove_taskresult_hidden'),
+        ('ippidb', '0043_job_task_id'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='job',
+            name='name',
+        ),
+        migrations.RemoveField(
+            model_name='job',
+            name='status',
+        ),
+        migrations.RemoveField(
+            model_name='job',
+            name='task_id',
+        ),
+        migrations.AddField(
+            model_name='job',
+            name='task_result',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='django_celery_results.TaskResult'),
+            preserve_default=False,
+        ),
+    ]
diff --git a/ippisite/ippidb/migrations/0045_auto_20200323_1235.py b/ippisite/ippidb/migrations/0045_auto_20200323_1235.py
new file mode 100644
index 0000000000000000000000000000000000000000..48fe4e5376c4fb923297bc57cd72de0681f1ddbd
--- /dev/null
+++ b/ippisite/ippidb/migrations/0045_auto_20200323_1235.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2.1 on 2020-03-23 12:35
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ippidb', '0044_auto_20200323_1110'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='job',
+            name='task_result',
+            field=models.OneToOneField(default=None, on_delete=django.db.models.deletion.CASCADE, to='django_celery_results.TaskResult'),
+        ),
+    ]
diff --git a/ippisite/ippidb/migrations/0046_compoundjob.py b/ippisite/ippidb/migrations/0046_compoundjob.py
new file mode 100644
index 0000000000000000000000000000000000000000..d348a47d372117e6b6e0e0b44cb278c9c11cbdf5
--- /dev/null
+++ b/ippisite/ippidb/migrations/0046_compoundjob.py
@@ -0,0 +1,22 @@
+# Generated by Django 2.2.1 on 2020-03-29 17:52
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ippidb', '0045_auto_20200323_1235'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CompoundJob',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('compound', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ippidb.Compound')),
+                ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ippidb.Job')),
+            ],
+        ),
+    ]
diff --git a/ippisite/ippidb/migrations/0047_auto_20200416_1359.py b/ippisite/ippidb/migrations/0047_auto_20200416_1359.py
new file mode 100644
index 0000000000000000000000000000000000000000..b01f69c00c98eb09e2f47cffdf594cb4e94b0f35
--- /dev/null
+++ b/ippisite/ippidb/migrations/0047_auto_20200416_1359.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.1 on 2020-04-16 13:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ippidb', '0046_compoundjob'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='compound',
+            name='iupac_name',
+            field=models.TextField(blank=True, null=True, verbose_name='IUPAC name'),
+        ),
+    ]
diff --git a/ippisite/ippidb/migrations/0048_auto_20200417_1001.py b/ippisite/ippidb/migrations/0048_auto_20200417_1001.py
new file mode 100644
index 0000000000000000000000000000000000000000..8107f6adff28c7da94e20229659503c3158363f2
--- /dev/null
+++ b/ippisite/ippidb/migrations/0048_auto_20200417_1001.py
@@ -0,0 +1,27 @@
+# Generated by Django 2.2.1 on 2020-04-17 10:01
+
+from django.db import migrations
+
+def remove_compound_drugbank_duplicates(apps, schema_editor):
+    DrugbankCompoundTanimoto = apps.get_model("ippidb", "DrugbankCompoundTanimoto")
+    db_alias = schema_editor.connection.alias
+    last = None, None
+    for dct in DrugbankCompoundTanimoto.objects.using(db_alias).order_by('compound','drugbank_compound'):
+        if dct.compound == last[0] and dct.drugbank_compound == last[1]:
+            dct.delete()
+        else:
+            last = (dct.compound, dct.drugbank_compound)
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ippidb', '0047_auto_20200416_1359'),
+    ]
+
+    operations = [
+        migrations.RunPython(remove_compound_drugbank_duplicates),
+        migrations.AlterUniqueTogether(
+            name='drugbankcompoundtanimoto',
+            unique_together={('compound', 'drugbank_compound')},
+        ),
+    ]
diff --git a/ippisite/ippidb/migrations/0049_auto_20200419_2005.py b/ippisite/ippidb/migrations/0049_auto_20200419_2005.py
new file mode 100644
index 0000000000000000000000000000000000000000..a6794849e3b3abc5ebf1cf1977e81ea37e144496
--- /dev/null
+++ b/ippisite/ippidb/migrations/0049_auto_20200419_2005.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2.1 on 2020-04-19 20:05
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ippidb', '0048_auto_20200417_1001'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='testactivitydescription',
+            name='cell_line',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='ippidb.CellLine'),
+        ),
+    ]
diff --git a/ippisite/ippidb/models.py b/ippisite/ippidb/models.py
index ddf55fdbf85643a96037f666acb241ca99989ac4..8f01f1afa9841144ce999e6d4790943fd7a11a47 100644
--- a/ippisite/ippidb/models.py
+++ b/ippisite/ippidb/models.py
@@ -6,6 +6,7 @@ from __future__ import unicode_literals
 
 import operator
 import re
+import sys
 
 from django.conf import settings
 from django.contrib.auth import get_user_model
@@ -16,6 +17,9 @@ from django.db.models import Max, Count, F, Q, Case, When, Subquery, OuterRef
 from django.db.models.functions import Cast
 from django.urls import reverse
 from django.utils.translation import ugettext_lazy as _
+from django_celery_results.models import TaskResult
+from django.dispatch import receiver
+from django.db.models.signals import post_save
 
 from .utils import FingerPrinter, smi2inchi, smi2inchikey
 from .ws import (
@@ -713,9 +717,7 @@ class Compound(AutoFillableModel):
     chembl_id = models.CharField(
         verbose_name="Chembl ID", max_length=30, blank=True, null=True
     )
-    iupac_name = models.CharField(
-        verbose_name="IUPAC name", max_length=255, blank=True, null=True
-    )
+    iupac_name = models.TextField(verbose_name="IUPAC name", blank=True, null=True)
     ligand_id = models.CharField("PDB Ligand ID", max_length=3, blank=True, null=True)
     pubs = models.IntegerField(
         verbose_name="Number of publications", null=True, blank=True
@@ -832,6 +834,7 @@ class Compound(AutoFillableModel):
                     tanimoto=tanimoto,
                 )
             )
+        DrugbankCompoundTanimoto.objects.filter(compound=self).delete()
         DrugbankCompoundTanimoto.objects.bulk_create(dbcts)
 
     @property
@@ -854,18 +857,16 @@ class Compound(AutoFillableModel):
                 pfam_ids.add(bound_complex.complex.domain.pfam_id)
         return pfam_ids
 
-    @property
-    def best_pXC50_activity(self):
-        return self.compoundactivityresult_set.aggregate(Max("activity"))[
-            "activity__max"
-        ]
-
     @property
     def best_pXC50_compound_activity_result(self):
         best_pXC50_activity = self.best_activity
         if best_pXC50_activity is None:
             return None
-        return self.compoundactivityresult_set.filter(activity=best_pXC50_activity)[0]
+        best_cars = self.compoundactivityresult_set.filter(activity=best_pXC50_activity)
+        if len(best_cars) > 0:
+            return best_cars[0]
+        else:
+            return None
 
     @property
     def bioch_tests_count(self):
@@ -926,6 +927,9 @@ class Compound(AutoFillableModel):
         ):
             self.add_error("common_name", "A compound with this name already exists")
 
+    def get_jobs(self):
+        return CompoundJob.objects.filter(compound=self)
+
 
 class CompoundTanimoto(models.Model):
     canonical_smiles = models.TextField("Canonical Smile")
@@ -1035,7 +1039,7 @@ class TestActivityDescription(models.Model):
         verbose_name="Total number of active compounds"
     )
     cell_line = models.ForeignKey(
-        CellLine, on_delete=models.CASCADE, blank=True, null=True
+        CellLine, on_delete=models.SET_NULL, null=True
     )
 
     def get_complexes(self):
@@ -1279,6 +1283,9 @@ class DrugbankCompoundTanimoto(models.Model):
     drugbank_compound = models.ForeignKey(DrugBankCompound, models.CASCADE)
     tanimoto = models.DecimalField("Tanimoto value", max_digits=5, decimal_places=4)
 
+    class Meta:
+        unique_together = (("compound", "drugbank_compound"),)
+
 
 class Contribution(models.Model):
     contributor = models.ForeignKey(to=get_user_model(), on_delete=models.PROTECT)
@@ -1321,16 +1328,6 @@ def update_compound_cached_properties(compounds_queryset=None):
             .annotate(_best_activity=Max("compoundactivityresult__activity"))
             .values("_best_activity")[:1]
         ),
-        best_activity_ppi_family_name=Subquery(
-            CompoundActivityResult.objects.filter(compound_id=OuterRef("id"))
-            .filter(activity=OuterRef("best_activity"))
-            .annotate(
-                _best_activity_ppi_family_name=F(
-                    "test_activity_description__ppi__family__name"
-                )
-            )
-            .values("_best_activity_ppi_family_name")[:1]
-        ),
         le=Subquery(
             compounds_queryset.filter(id=OuterRef("id"))
             .annotate(
@@ -1638,6 +1635,18 @@ def update_compound_cached_properties(compounds_queryset=None):
             .values("_tests_av")[:1]
         ),
     )
+    compounds_queryset.update(
+        best_activity_ppi_family_name=Subquery(
+            CompoundActivityResult.objects.filter(compound_id=OuterRef("id"))
+            .filter(activity=OuterRef("best_activity"))
+            .annotate(
+                _best_activity_ppi_family_name=F(
+                    "test_activity_description__ppi__family__name"
+                )
+            )
+            .values("_best_activity_ppi_family_name")[:1]
+        ),
+    )
     compounds_queryset.update(
         lipinsky_score=Subquery(
             compounds_queryset.filter(id=OuterRef("id"))
@@ -1691,3 +1700,46 @@ def update_compound_cached_properties(compounds_queryset=None):
             .values("_lipinsky")[:1]
         )
     )
+
+
+class Job(models.Model):
+    task_result = models.OneToOneField(
+        TaskResult, on_delete=models.CASCADE, default=None
+    )
+    std_out = models.TextField(
+        null=True, default="", blank=True, help_text="task standard output"
+    )
+    std_err = models.TextField(
+        null=True, default="", blank=True, help_text="task error output"
+    )
+
+    def update_output(self, message, output="std_out"):
+        """
+        Save log information in std_out or std_err,
+        limit the log information size saved to 100 lines max
+        """
+        if output == "std_out":
+            print(message, file=sys.stdout)
+            split_std_out = self.std_out.split("\n")
+            split_std_out = split_std_out + str(message).split("\n")
+            self.std_out = "\n".join(split_std_out[-100:])
+            self.save()
+        elif output == "std_err":
+            print(message, file=sys.stderr)
+            split_std_err = self.std_err.split("\n")
+            split_std_err = split_std_err + str(message).split("\n")
+            self.std_err = "\n".join(split_std_err[-100:])
+            self.save()
+        else:
+            raise Exception("output doesn't exist")
+
+
+class CompoundJob(models.Model):
+    compound = models.ForeignKey(Compound, on_delete=models.CASCADE)
+    job = models.ForeignKey(Job, on_delete=models.CASCADE)
+
+
+@receiver(post_save, sender=TaskResult)
+def post_save_taskresult(sender, instance, created, *args, **kwargs):
+    if created:
+        Job.objects.create(task_result=instance)
diff --git a/ippisite/ippidb/static/js/ippidb-charts.js b/ippisite/ippidb/static/js/ippidb-charts.js
index e9396ead328c39d1e939bf9b1214a68de8d4a441..1cb0f55cbcf48a7393c9bcf89e9b23d9b8c5f6ed 100644
--- a/ippisite/ippidb/static/js/ippidb-charts.js
+++ b/ippisite/ippidb/static/js/ippidb-charts.js
@@ -101,27 +101,18 @@ var prepareCompoundFamilyBiplotData = function (compoundId, compoundFamily, plot
 }
 
 var getPpiFamilyColor = function (ppiName) {
-    return {
-        'BCL2-Like / BAX': '#0E5C97',
-        'Beta-catenin / TCF-4': '#FAB362',
-        'Bromodomain / Histone': '#C9862D',
-        'CD4 / gp120': '#FE8E85',
-        'CD80 / CD28': '#DB0016',
-        'E2 / E1': '#C4ADD1',
-        'FAK / VEGFR3': '#6700FF',
-        'IL2 / IL2R': '#FE6C00',
-        'LEDGF / IN': '#971D19',
-        'LFA / ICAM': '#9DD87E',
-        'MDM2-Like / P53': '#96C5DC',
-        'MENIN / MLL': '#EDBBFF',
-        'Myc / Max': '#C04DC4',
-        'NRP / VEGF': '#A8184B',
-        'UPAR / UPA': '#CBDCFF',
-        'VEGF / VEGFR': '#FED215',
-        'WDR5/MLL': '#F5F6F7',
-        'XIAP / Smac': '#128C6D',
-        'ZipA / ftsZ': '#FEFE01',
-    }[ppiName];
+    var hash = 0;
+    if (ppiName == null || ppiName.length === 0) return hash;
+    for (var i = 0; i < ppiName.length; i++) {
+        hash = ppiName.charCodeAt(i) + ((hash << 5) - hash);
+        hash = hash & hash;
+    }
+    var color = '#';
+    for (var i = 0; i < 3; i++) {
+        var value = (hash >> (i * 8)) & 255;
+        color += ('00' + value.toString(16)).substr(-2);
+    }
+    return color;
 }
 
 var preparePerFamilyBiplotData = function (plotData) {
diff --git a/ippisite/ippidb/tasks.py b/ippisite/ippidb/tasks.py
index becdf53d629f528b16f3b222823eb255bb781e48..5776ea4e8a231db1ffc2b08f9f538b84ea2b82bb 100644
--- a/ippisite/ippidb/tasks.py
+++ b/ippisite/ippidb/tasks.py
@@ -4,11 +4,15 @@ import tempfile
 import io
 import base64
 import itertools
+import time
+import random
 
 
-from celery import shared_task
-import seaborn as sns
+from celery import task, states, chain, group
+from ippisite.decorator import MonitorTask
 import matplotlib.pyplot as plt
+
+import seaborn as sns
 import numpy as np
 import pandas as pd
 from sklearn.decomposition import PCA
@@ -19,7 +23,9 @@ from django.forms.models import model_to_dict
 
 from .models import (
     Compound,
+    CompoundJob,
     Contribution,
+    Job,
     update_compound_cached_properties,
     LeLleBiplotData,
     PcaBiplotData,
@@ -27,6 +33,8 @@ from .models import (
 from .utils import smi2sdf
 from .gx import GalaxyCompoundPropertiesRunner
 
+plt.switch_backend("Agg")
+
 
 def dec(decimal_places):
     def func(number):
@@ -123,9 +131,8 @@ def compute_compound_properties(compound_ids):
         compound.save()
 
 
-def compute_drugbank_similarity(compound_ids):
-    compounds = Compound.objects.filter(id__in=compound_ids)
-    for c in compounds:
+def compute_drugbank_similarity(qs):
+    for c in qs:
         c.save(autofill=True)
     pass
 
@@ -206,6 +213,8 @@ def generate_pca_plot():
     df = pd.DataFrame(values_list)
     # drop compounds with undefined property values
     df.dropna(how="any", inplace=True)
+    # reset index so that it can be joined with PCA results
+    df.reset_index(inplace=True)
     # prepare correlation circle figure
     plt.switch_backend("Agg")
     fig_, ax = plt.subplots(figsize=(6, 6))
@@ -267,6 +276,7 @@ def generate_pca_plot():
         principal_df = principal_df.iloc[:, 0:2]
         principal_df = principal_df.rename(columns={0: "x", 1: "y"})
         final_df = pd.concat([principal_df, df[["family", "id"]]], axis=1)
+        final_df.dropna(how="any", inplace=True)
         for index, row in final_df.iterrows():
             smiles = Compound.objects.get(id=row.id).canonical_smile
             pca_data.append(
@@ -297,67 +307,126 @@ def generate_pca_plot():
     print("Successfully generated PCA biplot data")
 
 
-@shared_task
-def validate_compounds(compound_ids):
+@task(base=MonitorTask, bind=True)
+def launch_test_command_caching(self):
     """
-    This task will perform all computations and validate the compound
-    It also regenerates the LE-LLE and PCA plots
+    Test task of IppidbTask
     """
-    compute_compound_properties(compound_ids)
-    update_compound_cached_properties(Compound.objects.filter(id__in=compound_ids))
-    compute_drugbank_similarity(compound_ids)
+    self.write(std_out="Before first sleep, state={}".format(self.state))
+    time.sleep(30)
+    self.update_state(state=states.STARTED)
+    self.write(std_out="After first sleep, state={}".format(self.state))
+    num = random.random()
+    if num > 0.5:
+        raise Exception("ERROR: {} is greater than 0.5".format(num))
+
+
+@task(base=MonitorTask, bind=True)
+def run_compute_compound_properties(self, compound_id):
+    self.update_state(state=states.STARTED)
+    cj = CompoundJob()
+    cj.compound = Compound.objects.get(id=compound_id)
+    cj.job = Job.objects.get(task_result__task_id=self.task_id)
+    cj.save()
+    self.write(std_out=f"Starting computation of compound properties for {compound_id}")
+    compute_compound_properties([compound_id])
+    self.write(std_out=f"Finished computation of compound properties for {compound_id}")
+
+
+@task(base=MonitorTask, bind=True)
+def run_update_compound_cached_properties(self, compound_ids=None):
+    if compound_ids:
+        qs = Compound.objects.filter(id__in=compound_ids)
+    else:
+        qs = Compound.objects.validated()
+    self.update_state(state=states.STARTED)
+    self.write(
+        std_out=f"Starting caching of compound properties for {compound_ids or 'all compounds'}"
+    )
+    update_compound_cached_properties(qs)
+    self.write(
+        std_out=f"Finished caching of compound properties for {compound_ids or 'all compounds'}"
+    )
+
+
+@task(base=MonitorTask, bind=True)
+def run_compute_drugbank_similarity(self, compound_ids=None):
+    if compound_ids:
+        qs = Compound.objects.filter(id__in=compound_ids)
+    else:
+        qs = Compound.objects.validated()
+    self.update_state(state=states.STARTED)
+    self.write(
+        std_out=f"Starting computing Drugbank similarity for {compound_ids or 'all compounds'}"
+    )
+    compute_drugbank_similarity(qs)
+    self.write(
+        std_out=f"Finished computing Drugbank similarity for {compound_ids or 'all compounds'}"
+    )
+
+
+@task(base=MonitorTask, bind=True)
+def run_validate(self, compound_ids):
+    self.update_state(state=states.STARTED)
+    self.write(std_out=f"Starting validation of compounds {compound_ids}")
     validate(compound_ids)
-    generate_le_lle_plot()
-    generate_pca_plot()
+    self.write(std_out=f"Finished validation of compounds {compound_ids}")
 
 
-@shared_task
-def launch_validate_contributions(contribution_ids):
-    """
-    This task will perform, for a given set of contributions,
-    all computations on the compounds and validate the contributions
-    It also regenerates the LE-LLE and PCA plots
-    """
-    for cont in Contribution.objects.filter(id__in=contribution_ids):
-        try:
-            for compound_action in cont.ppi.compoundaction_set.all():
-                compound = compound_action.compound
-                compute_compound_properties([compound.id])
-                update_compound_cached_properties(
-                    Compound.objects.filter(id__in=[compound.id])
-                )
-                compute_drugbank_similarity([compound.id])
-                validate([compound.id])
-        except Exception:
-            print(compound.id)
+@task(base=MonitorTask, bind=True)
+def run_le_lle_plot(self):
+    self.update_state(state=states.STARTED)
+    self.write(std_out=f"Starting computing LE-LLE plot")
     generate_le_lle_plot()
+    self.write(std_out=f"Finished computing LE-LLE plot")
+
+
+@task(base=MonitorTask, bind=True)
+def run_pca_plot(self):
+    self.update_state(state=states.STARTED)
+    self.write(std_out=f"Starting computing PCA plot")
     generate_pca_plot()
+    self.write(std_out=f"Finished computing PCA plot")
 
 
-@shared_task
-def launch_compound_properties_caching():
-    """
-    This task will perform, for all already validated compounds,
-    the caching of the properties.
-    """
-    validated_compounds = Compound.objects.validated()
-    update_compound_cached_properties(validated_compounds)
+def launch_validate_contributions(contribution_ids):
+    contribution_jobs = []
+    for cont in Contribution.objects.filter(id__in=contribution_ids):
+        compound_ids = [
+            compound_action.compound.id
+            for compound_action in cont.ppi.compoundaction_set.all()
+        ]
+        # build a group of jobs, one job for each compound properties computation
+        run_compounds_properties_computation_group = group(
+            [
+                run_compute_compound_properties.si(compound_id)
+                for compound_id in compound_ids
+            ]
+        )
+        # build the "main" job
+        compounds_properties_computation_group = chain(
+            run_compounds_properties_computation_group,
+            run_update_compound_cached_properties.si(compound_ids),
+            run_compute_drugbank_similarity.si(compound_ids),
+            run_validate.si(compound_ids),
+        )
+        contribution_jobs.append(compounds_properties_computation_group)
+        # compounds_properties_computation_group.delay()
+    run_contribution_jobs = group(contribution_jobs)
+    workflow = chain(run_contribution_jobs, run_le_lle_plot.si(), run_pca_plot.si())
+    workflow.delay()
 
 
-@shared_task
-def launch_drugbank_similarity_computing():
+def launch_update_compound_cached_properties():
     """
-    This task will perform, for all already validated compounds,
-    the computing of drugbank similarity.
+    This task will launch the caching of properties on all compounds
     """
-    validated_compounds = Compound.objects.validated()
-    compute_drugbank_similarity(validated_compounds)
+    run_update_compound_cached_properties.delay()
 
 
-@shared_task
 def launch_plots_computing():
     """
     This task will perform the computing of LE-LLE and PCA plots.
     """
-    generate_le_lle_plot()
-    generate_pca_plot()
+    workflow = chain(run_le_lle_plot.si(), run_pca_plot.si())
+    workflow.delay()
diff --git a/ippisite/ippidb/templates/about-general.html b/ippisite/ippidb/templates/about-general.html
index ed2ee10e8e7abfa8a044f58d180be82cb694e684..5bdc1267f2001912b62cc5c09a2de32711286c6c 100644
--- a/ippisite/ippidb/templates/about-general.html
+++ b/ippisite/ippidb/templates/about-general.html
@@ -32,4 +32,42 @@
 	</div>
 </div>
 
+<h2 id="terms-privacy" class="mt-5 text-center">Terms and Privacy</h2>
+
+<h5 class="row justify-content-center">What personal data is collected?</h5>
+
+<div class="row justify-content-center">
+	<div class="col-md-auto">
+	  We collect some of your personal data:
+  <ul class="list-styled">
+	  <li>IP address, date and time of visit to the service, operating system, browser user agent, for all users</li>
+	  <li>ORCID ID or email for registered users</li>
+  </ul>
+</div>
+</div>
+
+
+<h5 class="row justify-content-center">Cookies and analytics</h5>
+
+<div class="row justify-content-center">
+	<div class="col-md-auto">
+		<p>We use cookies to manage sessions and user authentication across this website.</p>
+
+	{% if gacode %}
+
+	<p>We use <a href="https://analytics.google.com" target="_blank" class="ml-1 mr-1">Google Analytics</a> to collect statistics about iPPI-DB.</p>
+	<p>These statistics are not used to track users individually, but rather to monitor the performance and the usage of the website.</p>
+	<p>Therefore, some data regarding your use of this website is sent to this third party, including:</p>
+	<ul class="list-styled">
+		<li>IP address</li>
+		<li>Date and time of a visit to the service</li>
+		<li>Operating system</li>
+		<li>Browser</li>
+		<li>Amount of data transmitted</li>
+	</ul>
+	{% endif %}
+	</div>
+</div>
+
+
 {% endblock %}
diff --git a/ippisite/ippidb/templates/about-le-lle.html b/ippisite/ippidb/templates/about-le-lle.html
index b22b7944fae92f0ca6294e5eae22475edb4146a2..5527f56dd4e222567342a704995b95421384ac8b 100644
--- a/ippisite/ippidb/templates/about-le-lle.html
+++ b/ippisite/ippidb/templates/about-le-lle.html
@@ -6,7 +6,7 @@
 {% block pagetitle %}Efficiencies: iPPI-DB biplot LE versus LLE{% endblock%}
 
 {% block view_content %}
-
+{% if le_lle_biplot_data %}
 <div class="row">
 	<div class="col-md-12">
 		<canvas id="le_lle_biplot"></canvas>
@@ -21,4 +21,9 @@
 <script>
 drawCompoundsBiplotChart('le_lle_biplot', preparePerFamilyBiplotData({{ le_lle_biplot_data | safe }}), 'pharmacology', {'legend':{'position':'right'}});
 </script>
+{% else %}
+  <div class="alert alert-danger" role="alert">
+	The LE-LLE data are not currently available
+  </div>
+{% endif %}
 {% endblock %}
diff --git a/ippisite/ippidb/templates/about-pca.html b/ippisite/ippidb/templates/about-pca.html
index 104fbd1099d896233e0185174f9dfd3506629fa4..ec381448ac030c6c4743782429700a21a05846e0 100644
--- a/ippisite/ippidb/templates/about-pca.html
+++ b/ippisite/ippidb/templates/about-pca.html
@@ -5,6 +5,7 @@
 {% block pagetitle %}PHYSICOCHEMISTRY{% endblock%}
 
 {% block view_content %}
+{% if pca_biplot_data %}
 <div class="row">
 	<canvas id="pca_biplot"></canvas>
 </div>
@@ -17,4 +18,9 @@
 <script>
 	drawCompoundsBiplotChart('pca_biplot', preparePerFamilyBiplotData({{ pca_biplot_data | safe }}.data), 'physicochemical', { 'legend': { 'position': 'right' } });
 </script>
+{% else %}
+  <div class="alert alert-danger" role="alert">
+	The PCA data are not currently available
+  </div>
+{% endif %}
 {% endblock %}
\ No newline at end of file
diff --git a/ippisite/ippidb/templates/base.html b/ippisite/ippidb/templates/base.html
index adf6495e81c32ec4296e1dc3930bdde8dc4d50eb..492d83ef61524dfb716b77162f4a9d42c67e8d32 100644
--- a/ippisite/ippidb/templates/base.html
+++ b/ippisite/ippidb/templates/base.html
@@ -6,6 +6,19 @@
     <head>
         <title>IPPI-DB {% block title %}{% endblock %}</title>
 
+        {% if gacode %}
+        <!-- Global site tag (gtag.js) - Google Analytics -->
+        <script async src="https://www.googletagmanager.com/gtag/js?id={{ gacode }}"></script>
+        <script>
+        window.dataLayer = window.dataLayer || [];
+        function gtag(){dataLayer.push(arguments);}
+        gtag('js', new Date());
+
+        gtag('config', '{{ gacode }}');
+        </script>
+        {% endif %}
+
+
         <link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
         <link rel="stylesheet" href="/static/css/fonts.css">
         <link rel="stylesheet" href="/static/css/main.css">
@@ -32,6 +45,23 @@
     </head>
 
     <body {% if debug %}class="debug"{%endif%} style="min-height:100vh; display:flex; flex-direction:column;">
+        {% if gacode %}
+        <div id="consent" class="fixed-bottom pl-5 pr-5 pt-2 pb-2 text-light bg-dark text-center">
+            By using the site you are agreeing to the use of third party cookies for statistical purposes. You can read more about our policy <a href="/about-general/#terms-privacy"><b>here</b></a>.
+            <button id="consentBtn" class="btn btn-dark ml-2" type="button" aria-label="Accept">Accept
+            </button>
+        </div>
+        <script>
+            if(localStorage.getItem('consent') == 'true'){
+                $('#consent').hide();
+            }
+            var consent = function(){
+                localStorage.setItem('consent', 'true');
+                $('#consent').hide();
+            }
+            $('#consentBtn').click(consent);
+        </script>
+        {% endif %}
         <div class="jumbotron logoJum">
             <div class="container">
                 <a href="/" id="home">
diff --git a/ippisite/ippidb/templates/citation.html b/ippisite/ippidb/templates/citation.html
deleted file mode 100644
index f5e6e1b434a7460005e4ee54ee62dba8154f6864..0000000000000000000000000000000000000000
--- a/ippisite/ippidb/templates/citation.html
+++ /dev/null
@@ -1,42 +0,0 @@
-{% extends "index.html" %}
-
-    
-{% block title %}How to cite?{% endblock %}
-
-{% block content %}
-<div class="inner-wrap">
-	<div id="main-wrapper" class="page">
-		<div id="main">
-			<div id="content" class="main-content">
-				<div class="section">
-					<main role="main">
-						<h1 class="page-title"> {% block pagetitle %}HOW TO CITE?{% endblock%} </h1>
-						<div class="tabs"></div>
-						<div class="main__inner">
-							<div class="region region-content">
-								<div class="page-intro">
-								</div>
-								<div class="color-wrap">
-									<div class="region region-content">
-										<div class="block block-system block-system-main">
-											<div class="content">
-												<div class="view-content">
-{% block view_content %}
-<div>
-    
-</div>
-{% endblock %}																											
-												</div>
-											</div>
-										</div>
-									</div>
-								</div>
-							</div>
-						</div>
-					</main><!-- .site-main -->
-				</div>
-			</div>
-		</div><!-- div main -->
-	</div>
-</div>
-{% endblock %}
diff --git a/ippisite/ippidb/templates/compound_form_content.html b/ippisite/ippidb/templates/compound_form_content.html
index 38bdb0b6e503d4d766170b589c24ea1526c47121..98254065db4d7bbbbc209b40de73418956951245 100644
--- a/ippisite/ippidb/templates/compound_form_content.html
+++ b/ippisite/ippidb/templates/compound_form_content.html
@@ -41,7 +41,7 @@
                     {{form.common_name.label}}
                 </label>
             </div>
-            {%if form.is_a_ligand != None %}
+            {%if form.show_is_a_ligand != None %}
             <div class="input-inline">
                 <input type="text" name="{{form.ligand_id.html_name}}"
                     {%if form.ligand_id.value %}value="{{form.ligand_id.value}}" {%endif%}
diff --git a/ippisite/ippidb/templates/credits.html b/ippisite/ippidb/templates/credits.html
deleted file mode 100644
index 99412a4aed01daca7e8d9147384188d62b439fd1..0000000000000000000000000000000000000000
--- a/ippisite/ippidb/templates/credits.html
+++ /dev/null
@@ -1,42 +0,0 @@
-{% extends "index.html" %}
-
-    
-{% block title %}Credits{% endblock %}
-
-{% block content %}
-<div class="inner-wrap">
-	<div id="main-wrapper" class="page">
-		<div id="main">
-			<div id="content" class="main-content">
-				<div class="section">
-					<main role="main">
-						<h1 class="page-title"> {% block pagetitle %}CREDITS{% endblock%} </h1>
-						<div class="tabs"></div>
-						<div class="main__inner">
-							<div class="region region-content">
-								<div class="page-intro">
-								</div>
-								<div class="color-wrap">
-									<div class="region region-content">
-										<div class="block block-system block-system-main">
-											<div class="content">
-												<div class="view-content">
-{% block view_content %}
-<div>
-    
-</div>
-{% endblock %}																											
-												</div>
-											</div>
-										</div>
-									</div>
-								</div>
-							</div>
-						</div>
-					</main><!-- .site-main -->
-				</div>
-			</div>
-		</div><!-- div main -->
-	</div>
-</div>
-{% endblock %}
diff --git a/ippisite/ippidb/templates/ippidb/refcompoundbiblio_table.html b/ippisite/ippidb/templates/ippidb/refcompoundbiblio_table.html
index 02b9d8614641a0c7c69f4caf8345fe40394cc36e..bfc18ca1fe3b751aed83646235f45981703e4107 100644
--- a/ippisite/ippidb/templates/ippidb/refcompoundbiblio_table.html
+++ b/ippisite/ippidb/templates/ippidb/refcompoundbiblio_table.html
@@ -7,6 +7,7 @@
         <th>{{sample|verbose_name:'compound_name'}}</th>
         <th>{{sample.compound|verbose_name:'canonical_smile'}}</th>
         <th>{{sample.compound|verbose_name:'iupac_name'}}</th>
+        <th>{{sample.compound|verbose_name:'ligand_id'}}</th>
         <th><i class="fa fa-link"></i>
     </tr>
     {%endwith%}
@@ -19,7 +20,8 @@
             <canvas width="250" height="250" data-smiles="{{o.compound.canonical_smile}}"></canvas>
             {{o.compound.canonical_smile}}
         </td>
-        <td>{{o.compound.iupac_name}}</td>
+        <td>{{o.compound.iupac_name  | default_if_none:""}}</td>
+        <td><a href="https://www.rcsb.org/ligand/{{ o.compound.ligand_id }}" target="_blank">{{ o.compound.ligand_id | default_if_none:""}}</a></td>
         <td>
             <a target="_blank" href="{% url 'compound_card' pk=o.compound.pk %}"><i class="fa fa-link"></i></a>
         </td>
diff --git a/ippisite/ippidb/templates/news.html b/ippisite/ippidb/templates/news.html
deleted file mode 100644
index d629e62d04d10d3055cf75b853c61e917af59f66..0000000000000000000000000000000000000000
--- a/ippisite/ippidb/templates/news.html
+++ /dev/null
@@ -1,42 +0,0 @@
-{% extends "index.html" %}
-
-    
-{% block title %}Latest news{% endblock %}
-
-{% block content %}
-<div class="inner-wrap">
-	<div id="main-wrapper" class="page">
-		<div id="main">
-			<div id="content" class="main-content">
-				<div class="section">
-					<main role="main">
-						<h1 class="page-title"> {% block pagetitle %}LATEST NEWS{% endblock%} </h1>
-						<div class="tabs"></div>
-						<div class="main__inner">
-							<div class="region region-content">
-								<div class="page-intro">
-								</div>
-								<div class="color-wrap">
-									<div class="region region-content">
-										<div class="block block-system block-system-main">
-											<div class="content">
-												<div class="view-content">
-{% block view_content %}
-<div>
-    
-</div>
-{% endblock %}																											
-												</div>
-											</div>
-										</div>
-									</div>
-								</div>
-							</div>
-						</div>
-					</main><!-- .site-main -->
-				</div>
-			</div>
-		</div><!-- div main -->
-	</div>
-</div>
-{% endblock %}
diff --git a/ippisite/ippidb/tests/__init__.py b/ippisite/ippidb/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/ippisite/ippidb/tests/test_activity_computation_and_storage_pIC50_2380_002_e-9.yaml b/ippisite/ippidb/tests/test_activity_computation_and_storage_pIC50_2380_002_e-9.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..842a1db3b1005febf7a9428c0043816b08c0011d
--- /dev/null
+++ b/ippisite/ippidb/tests/test_activity_computation_and_storage_pIC50_2380_002_e-9.yaml
@@ -0,0 +1,43 @@
+activity_tests:
+- cell_line_name: ''
+  compound_activity_results:
+  - activity_mol: 2380.002
+    activity_type: pIC50
+    activity_unit: 1e-9
+    compound_name: toto
+    modulation_type: I
+  is_primary: true
+  nb_active_compounds: 2
+  protein_bound_construct: F
+  protein_complex: '2'
+  test_modulation_type: I
+  test_name: test
+  test_type: BIOCH
+complex:
+- cc_nb: 1
+  complex_type: Partner
+  domain_pfam_acc: PF05053
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: ''
+  uniprot_id: O00255
+- cc_nb: 1
+  complex_type: Bound
+  domain_pfam_acc: PF05965
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: 1
+  uniprot_id: Q03164
+complexChoice: inhibited
+complexType: Inhib_Hetero2merAB
+compounds:
+- common_name: super
+  compound_name: toto
+  molecule_smiles: CCC
+diseases:
+- ;toto;1;
+- ;titi;sfddf;
+- ;tata;3232;
+family_name: Menin (PF05053)
+id_source: '15072770'
+in_silico: true
+pdb_id: 3u85
+source: PM
diff --git a/ippisite/ippidb/tests/test_activity_computation_and_storage_pIC50_4_9846073_e-6.yaml b/ippisite/ippidb/tests/test_activity_computation_and_storage_pIC50_4_9846073_e-6.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..0a2e61b2d45c324e1ce6c7f00592a3ce046abe87
--- /dev/null
+++ b/ippisite/ippidb/tests/test_activity_computation_and_storage_pIC50_4_9846073_e-6.yaml
@@ -0,0 +1,43 @@
+activity_tests:
+- cell_line_name: ''
+  compound_activity_results:
+  - activity_mol: 4.9846073
+    activity_type: pIC50
+    activity_unit: 1e-6
+    compound_name: toto
+    modulation_type: I
+  is_primary: true
+  nb_active_compounds: 2
+  protein_bound_construct: F
+  protein_complex: '2'
+  test_modulation_type: I
+  test_name: test
+  test_type: BIOCH
+complex:
+- cc_nb: 1
+  complex_type: Partner
+  domain_pfam_acc: PF05053
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: ''
+  uniprot_id: O00255
+- cc_nb: 1
+  complex_type: Bound
+  domain_pfam_acc: PF05965
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: 1
+  uniprot_id: Q03164
+complexChoice: inhibited
+complexType: Inhib_Hetero2merAB
+compounds:
+- common_name: super
+  compound_name: toto
+  molecule_smiles: CCC
+diseases:
+- ;toto;1;
+- ;titi;sfddf;
+- ;tata;3232;
+family_name: Menin (PF05053)
+id_source: '15072770'
+in_silico: true
+pdb_id: 3u85
+source: PM
diff --git a/ippisite/ippidb/tests/test_activity_computation_and_storage_pIC50_639_39406_e-3.yaml b/ippisite/ippidb/tests/test_activity_computation_and_storage_pIC50_639_39406_e-3.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..459d512dd9b0f3557f5bfb3e50dcaf0140eae7a0
--- /dev/null
+++ b/ippisite/ippidb/tests/test_activity_computation_and_storage_pIC50_639_39406_e-3.yaml
@@ -0,0 +1,43 @@
+activity_tests:
+- cell_line_name: ''
+  compound_activity_results:
+  - activity_mol: 639.39406
+    activity_type: pIC50
+    activity_unit: 1e-3
+    compound_name: toto
+    modulation_type: I
+  is_primary: true
+  nb_active_compounds: 2
+  protein_bound_construct: F
+  protein_complex: '2'
+  test_modulation_type: I
+  test_name: test
+  test_type: BIOCH
+complex:
+- cc_nb: 1
+  complex_type: Partner
+  domain_pfam_acc: PF05053
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: ''
+  uniprot_id: O00255
+- cc_nb: 1
+  complex_type: Bound
+  domain_pfam_acc: PF05965
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: 1
+  uniprot_id: Q03164
+complexChoice: inhibited
+complexType: Inhib_Hetero2merAB
+compounds:
+- common_name: super
+  compound_name: toto
+  molecule_smiles: CCC
+diseases:
+- ;toto;1;
+- ;titi;sfddf;
+- ;tata;3232;
+family_name: Menin (PF05053)
+id_source: '15072770'
+in_silico: true
+pdb_id: 3u85
+source: PM
diff --git a/ippisite/ippidb/tests/test_activity_computation_and_storage_pIC50_6_85_e-0.yaml b/ippisite/ippidb/tests/test_activity_computation_and_storage_pIC50_6_85_e-0.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..b61f094338e568128575ea14d577a93e7f9b7088
--- /dev/null
+++ b/ippisite/ippidb/tests/test_activity_computation_and_storage_pIC50_6_85_e-0.yaml
@@ -0,0 +1,43 @@
+activity_tests:
+- cell_line_name: ''
+  compound_activity_results:
+  - activity_mol: 6.85
+    activity_type: pIC50
+    activity_unit: 1e-0
+    compound_name: toto
+    modulation_type: I
+  is_primary: true
+  nb_active_compounds: 2
+  protein_bound_construct: F
+  protein_complex: '2'
+  test_modulation_type: I
+  test_name: test
+  test_type: BIOCH
+complex:
+- cc_nb: 1
+  complex_type: Partner
+  domain_pfam_acc: PF05053
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: ''
+  uniprot_id: O00255
+- cc_nb: 1
+  complex_type: Bound
+  domain_pfam_acc: PF05965
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: 1
+  uniprot_id: Q03164
+complexChoice: inhibited
+complexType: Inhib_Hetero2merAB
+compounds:
+- common_name: super
+  compound_name: toto
+  molecule_smiles: CCC
+diseases:
+- ;toto;1;
+- ;titi;sfddf;
+- ;tata;3232;
+family_name: Menin (PF05053)
+id_source: '15072770'
+in_silico: true
+pdb_id: 3u85
+source: PM
diff --git a/ippisite/ippidb/tests/test_basic_entry.yaml b/ippisite/ippidb/tests/test_basic_entry.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..c1baf06500ae82f25c3fa1063b0373433336c7f8
--- /dev/null
+++ b/ippisite/ippidb/tests/test_basic_entry.yaml
@@ -0,0 +1,43 @@
+activity_tests:
+- cell_line_name: ''
+  compound_activity_results:
+  - activity_mol: 6.85
+    activity_type: pIC50
+    activity_unit: 1e-6
+    compound_name: toto
+    modulation_type: I
+  is_primary: true
+  nb_active_compounds: 2
+  protein_bound_construct: F
+  protein_complex: '2'
+  test_modulation_type: I
+  test_name: test
+  test_type: BIOCH
+complex:
+- cc_nb: 1
+  complex_type: Partner
+  domain_pfam_acc: PF05053
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: ''
+  uniprot_id: O00255
+- cc_nb: 1
+  complex_type: Bound
+  domain_pfam_acc: PF05965
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: 1
+  uniprot_id: Q03164
+complexChoice: inhibited
+complexType: Inhib_Hetero2merAB
+compounds:
+- common_name: super
+  compound_name: toto
+  molecule_smiles: CCC
+diseases:
+- ;toto;1;
+- ;titi;sfddf;
+- ;tata;3232;
+family_name: Menin (PF05053)
+id_source: '15072770'
+in_silico: true
+pdb_id: 3u85
+source: PM
diff --git a/ippisite/ippidb/tests/test_complex_no_pfam.yaml b/ippisite/ippidb/tests/test_complex_no_pfam.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..03e6c2cf616df4f93a3aefccbeb06d7a7c47b682
--- /dev/null
+++ b/ippisite/ippidb/tests/test_complex_no_pfam.yaml
@@ -0,0 +1,40 @@
+activity_tests:
+- cell_line_name: ''
+  compound_activity_results:
+  - activity_mol: 6.85
+    activity_type: pIC50
+    activity_unit: 1e-3
+    compound_name: toto
+    modulation_type: I
+  is_primary: true
+  nb_active_compounds: 2
+  protein_bound_construct: F
+  protein_complex: '1'
+  test_modulation_type: I
+  test_name: test
+  test_type: BIOCH
+complex:
+- cc_nb: 1
+  complex_type: Bound
+  domain_pfam_acc: PF00400
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: 1
+  uniprot_id: P61964
+- cc_nb: 1
+  complex_type: Partner
+  domain_pfam_acc: null
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: ''
+  uniprot_id: Q03164
+complexChoice: inhibited
+complexType: Inhib_Hetero2merAB
+compounds:
+- common_name: super
+  compound_name: toto
+  molecule_smiles: C
+diseases: []
+family_name: WD40 (PF00400)
+id_source: '26958703'
+in_silico: true
+pdb_id: 3emh
+source: PM
diff --git a/ippisite/ippidb/tests/test_entry_28.yaml b/ippisite/ippidb/tests/test_entry_28.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..d22b2cb8274900a4497432bd1b2b68df806fe256
--- /dev/null
+++ b/ippisite/ippidb/tests/test_entry_28.yaml
@@ -0,0 +1,41 @@
+activity_tests:
+- cell_line_name: ''
+  compound_activity_results:
+  - activity_mol: 3.59
+    activity_type: pKd
+    activity_unit: 1e-9
+    compound_name: '2'
+    modulation_type: I
+  is_primary: true
+  nb_active_compounds: 1
+  protein_bound_construct: F
+  protein_complex: '1'
+  test_modulation_type: B
+  test_name: biolayer interferometry assay
+  test_type: BIOCH
+complex:
+- cc_nb: 1
+  complex_type: Partner
+  domain_pfam_acc: null
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: 1
+  uniprot_id: Q60795
+- cc_nb: 1
+  complex_type: Bound
+  domain_pfam_acc: PF01344
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: 1
+  uniprot_id: Q9Z2X8
+complexChoice: inhibited
+complexType: Inhib_Hetero2merAB
+compounds:
+- common_name: ''
+  compound_name: '2'
+  molecule_smiles: c1cc2c(cc1)c(ccc2N(S(=O)(=O)c1ccc(cc1)OC)CC(=O)O)N(S(=O)(=O)c1ccc(cc1)OC)CC(=O)O
+diseases:
+- ;cancer;MONDO:0004992;
+family_name: KEAP1 / NRF2
+id_source: '24512214 '
+in_vitro: true
+pdb_id: 3wn7
+source: PM
diff --git a/ippisite/ippidb/tests/test_simple_heterodimer.yaml b/ippisite/ippidb/tests/test_simple_heterodimer.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..c3c5f7c7e926a4ff22dea3dc30fe61645921323b
--- /dev/null
+++ b/ippisite/ippidb/tests/test_simple_heterodimer.yaml
@@ -0,0 +1,51 @@
+activity_tests:
+- cell_line_name: ''
+  compound_activity_results:
+  - activity_mol: 6.85
+    activity_type: pIC50
+    activity_unit: 1e-3
+    compound_name: 16d
+    modulation_type: I
+  is_primary: true
+  nb_active_compounds: 2
+  protein_bound_construct: F
+  protein_complex: '1'
+  test_modulation_type: I
+  test_name: test
+  test_type: BIOCH
+complex:
+- cc_nb: 1
+  complex_type: Bound
+  domain_pfam_acc: PF00400
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: 1
+  uniprot_id: P61964
+- cc_nb: 1
+  complex_type: Partner
+  domain_pfam_acc: null
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: ''
+  uniprot_id: Q03164
+complexChoice: inhibited
+complexType: Inhib_Hetero2merAB
+compounds:
+- common_name: officially 16d
+  compound_name: 16d
+  ligand_id: ABC
+  molecule_smiles: CN1CCN(CC1)C1=C(C=C(C=C1)C1=CC(=CC=C1)CN1CCOCC1)NC(=O)C1=CNC(C=C1C(F)(F)F)=O
+- common_name: officially 14a
+  compound_name: 14a
+  is_macrocycle: false
+  molecule_iupac: N-(2-(4-Methylpiperazin-1-yl)-5-nitrophenyl)-6-oxo-4-(trifluoromethyl)-1,6-dihydropyridine-3-carboxamide
+- common_name: stuck
+  compound_name: lili
+  is_macrocycle: false
+  molecule_smiles: C1CCC1
+diseases: []
+family_name: WD40 (PF00400)
+id_source: '26958703'
+in_silico: true
+in_vitro: true
+pdb_id: 3emh
+source: PM
+xray: true
diff --git a/ippisite/ippidb/tests/test_simple_heterodimer_208.yaml b/ippisite/ippidb/tests/test_simple_heterodimer_208.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..13859df89dc9adc196434a2d499cd82418d011a2
--- /dev/null
+++ b/ippisite/ippidb/tests/test_simple_heterodimer_208.yaml
@@ -0,0 +1,57 @@
+source: DO
+id_source: '10.1021/jm300608w'
+in_vitro: true
+in_cellulo: true
+xray: true
+pdb_id: 1g5j
+complexChoice: inhibited
+complexType: Inhib_Hetero2merAB
+complex:
+  - cc_nb: 1
+    complex_type: Partner
+    domain_pfam_acc: null
+    ppc_copy_nb: 1
+    ppp_copy_nb_per_p: ''
+    uniprot_id: Q92934
+  - cc_nb: 1
+    complex_type: Bound
+    domain_pfam_acc: null
+    ppc_copy_nb: 1
+    ppp_copy_nb_per_p: 1
+    uniprot_id: Q07817
+family_name: BCL-XL / Bad
+diseases:
+- ;cancer;MONDO:0004992;
+compounds:
+  - common_name: 
+    compound_name: compound 12
+    ligand_id: 03B
+    molecule_smiles: CN(C)CC[C@H](CSc1ccccc1)Nc2ccc(cc2[N+]([O-])=O)[S](=O)(=O)Nc3ccc(cc3)N4CCN(CC4)c5cccc(c5)c6c(n(C)c(C)c6C(O)=O)c7ccc(CI)cc7
+activity_tests:
+  - test_type: BIOCH
+    test_name: Fluorescence Polarization
+    protein_bound_construct: U
+    test_modulation_type: I
+    nb_active_compounds: 1
+    cell_line_name: ''
+    protein_complex: '1'
+    compound_activity_results:
+    - activity_mol: 6
+      activity_type: pIC50
+      activity_unit: 1e-9
+      compound_name: 'compound 12'
+      modulation_type: I
+  - test_type: CELL
+    test_name: cell growth inhibition
+    protein_bound_construct: U
+    test_modulation_type: I
+    nb_active_compounds: 1
+    cell_line_name: 'Human small-cell lung cancer'
+    protein_complex: '1'
+    compound_activity_results:
+    - activity_mol: 61
+      activity_type: pIC50
+      activity_unit: 1e-9
+      compound_name: 'compound 12'
+      modulation_type: I
+  
\ No newline at end of file
diff --git a/ippisite/ippidb/tests/test_simple_stabilized_heterodimer.yaml b/ippisite/ippidb/tests/test_simple_stabilized_heterodimer.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..1d79dd1e8f4868957a4919195a9feecb6eae92fd
--- /dev/null
+++ b/ippisite/ippidb/tests/test_simple_stabilized_heterodimer.yaml
@@ -0,0 +1,44 @@
+activity_tests:
+- cell_line_name: ''
+  compound_activity_results:
+  - activity_mol: 0.25
+    activity_type: pIC50
+    activity_unit: 1e-6
+    compound_name: FC
+    modulation_type: S
+  is_primary: true
+  nb_active_compounds: 2
+  protein_bound_construct: F
+  protein_complex: '1'
+  test_modulation_type: I
+  test_name: pull down
+  test_type: BIOCH
+complex:
+- cc_nb: 1
+  complex_type: Bound
+  domain_pfam_acc: PF00244
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: 1
+  uniprot_id: P31947
+- cc_nb: 1
+  complex_type: Bound
+  domain_pfam_acc: null
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: 1
+  uniprot_id: P03372
+complexChoice: stabilized
+complexType: Stab_Hetero2merAB
+compounds:
+- common_name: fusicoccin
+  compound_name: FC
+  ligand_id: FSC
+  molecule_smiles: COC[C@H]1CC[C@H]2[C@@H](C)[C@@H](O)[C@H](O[C@H]3O[C@H](COC(C)(C)C=C)[C@@H](O)[C@H](OC(C)=O)[C@H]3O)C3=C(C[C@H](O)[C@]3(C)\C=C1/2)[C@H](C)COC(C)=O
+diseases:
+- ;breast cancer;MONDO:0007254;
+family_name: 14-3-3 / ER
+id_source: '23676274'
+in_cellulo: true
+in_vitro: true
+pdb_id: 4jc3
+source: PM
+xray: true
diff --git a/ippisite/ippidb/tests/test_stabilizer_204.yaml b/ippisite/ippidb/tests/test_stabilizer_204.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..59cd362476b8f5d6fe7f041faa8fec36b90a23c2
--- /dev/null
+++ b/ippisite/ippidb/tests/test_stabilizer_204.yaml
@@ -0,0 +1,40 @@
+source: PM
+id_source: '23808890'
+in_vitro: true
+xray: true
+pdb_id: 4ihl
+complexChoice: stabilized
+complexType: Stab_Hetero2merAB
+complex:
+  - cc_nb: 1
+    complex_type: Bound
+    domain_pfam_acc: PF00244
+    ppc_copy_nb: 1
+    ppp_copy_nb_per_p: 1
+    uniprot_id: P63104
+  - cc_nb: 1
+    complex_type: Bound
+    domain_pfam_acc: null
+    ppc_copy_nb: 1
+    ppp_copy_nb_per_p: 1
+    uniprot_id: P04049
+family_name: 14-3-3 / C-RAF
+diseases:
+  - ;melanoma;(MONDO_0005105);
+compounds:
+  - common_name: Cotylenin A
+    compound_name: Cotylenin A
+    molecule_smiles: COC[C@H]1O[C@H](O[C@H]2[C@H](O)[C@H](C)[C@@H]3CC[C@](O)(COC)C3=C[C@@]3(C)CCC(C(C)C)=C23)[C@H](O)[C@H]2O[C@H]3O[C@@]12O[C@@]3(C)[C@@H]1CO1
+activity_tests:
+- test_type: BIOCH
+  test_name: Fluorescence Polarization
+  protein_bound_construct: F
+  test_modulation_type: B
+  nb_active_compounds: 1
+  protein_complex: '1'
+  compound_activity_results:
+  - activity_mol: 20
+    activity_type: pKd
+    activity_unit: 1e-10
+    compound_name: Cotylenin A
+    modulation_type: S
\ No newline at end of file
diff --git a/ippisite/ippidb/tests/test_with_all_tests.yaml b/ippisite/ippidb/tests/test_with_all_tests.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..3b8fefd311af4176a2e92deb7d9f5cef535b9cd5
--- /dev/null
+++ b/ippisite/ippidb/tests/test_with_all_tests.yaml
@@ -0,0 +1,72 @@
+activity_tests:
+- cell_line_name: ''
+  compound_activity_results:
+  - activity_mol: 6.85
+    activity_type: pIC50
+    activity_unit: 1e-3
+    compound_name: toto
+    modulation_type: I
+  is_primary: true
+  nb_active_compounds: 2
+  protein_bound_construct: F
+  protein_complex: '2'
+  test_modulation_type: I
+  test_name: test
+  test_type: BIOCH
+complex:
+- cc_nb: 1
+  complex_type: Partner
+  domain_pfam_acc: PF05053
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: ''
+  uniprot_id: O00255
+- cc_nb: 1
+  complex_type: Bound
+  domain_pfam_acc: PF05965
+  ppc_copy_nb: 1
+  ppp_copy_nb_per_p: 1
+  uniprot_id: Q03164
+complexChoice: inhibited
+complexType: Inhib_Hetero2merAB
+compounds:
+- common_name: super
+  compound_name: toto
+  molecule_smiles: CCC
+cytotox: true
+cytotox_tests:
+- cell_line_name: '1043SK '
+  compound_concentration: '2'
+  compound_cytotox_results:
+  - compound_name: toto
+    toxicity: 'True'
+  test_name: test
+diseases:
+- ''
+- ;titi;sfddf;
+- ;tutu;5456456;
+- ;tutu;5456456;
+- ''
+family_name: Menin (PF05053)
+id_source: '15072770'
+in_silico: true
+pdb_id: 3u85
+pharmacokinetic: true
+pharmacokinetic_tests:
+- administration_mode: IV
+  cell_line_name: '1043SK '
+  compound_pk_results:
+  - auc: '1'
+    c_max: '5.2'
+    clearance: '1.2'
+    compound_name: toto
+    oral_bioavailability: '22'
+    t_demi: '5'
+    t_max: '5'
+    tolerated: 'True'
+    voldistribution: '5.5'
+  concentration: '1.2'
+  dose: '3.5'
+  dose_interval: '2'
+  organism: 1
+  test_name: test
+source: PM
diff --git a/ippisite/ippidb/tests.py b/ippisite/ippidb/tests/tests.py
similarity index 85%
rename from ippisite/ippidb/tests.py
rename to ippisite/ippidb/tests/tests.py
index 49a365999ad7d392c71193e9e0d5dcf4699520bc..83715ed56fc6432998ddf76a662df71c6b599720 100644
--- a/ippisite/ippidb/tests.py
+++ b/ippisite/ippidb/tests/tests.py
@@ -8,13 +8,24 @@ from django.contrib.auth import get_user_model
 from django.core.management import call_command
 from django.test import TestCase
 from django.urls import reverse
-from openbabel import vectorUnsignedInt, OBFingerprint
 import requests
 
 
-from ippidb import ws, models
-from ippidb.ws import get_uniprot_info
-from .models import (
+from ippidb import models
+from ippidb.ws import (
+    get_uniprot_info,
+    get_doi_info,
+    get_pfam_info,
+    get_google_patent_info,
+    PatentNotFound,
+    get_pubmed_info,
+    get_pdb_uniprot_mapping,
+    get_pdb_pfam_mapping,
+    EntryNotFoundError,
+    convert_iupac_to_smiles_and_inchi,
+    convert_smiles_to_iupac,
+)
+from ippidb.models import (
     Compound,
     CompoundTanimoto,
     create_tanimoto,
@@ -24,14 +35,14 @@ from .models import (
     Contribution,
     LeLleBiplotData,
     PcaBiplotData,
+    DrugBankCompound,
+    Protein,
 )
-from .models import DrugBankCompound, Protein
-from .utils import FingerPrinter, mol2smi, smi2mol, smi2inchi, smi2inchikey
 
 
 class MolSmiTestCase(TestCase):
     """
-    Test MOL to SMILES and SMILES to MOL format conversion functions
+    Test MOL to SMILES and SMILES to MOL format conversion iPPI-DB web services
     """
 
     def setUp(self):
@@ -44,12 +55,6 @@ class MolSmiTestCase(TestCase):
             " 0  0  0  0  0  0  0  0\nM  END\n"
         )
 
-    def test_mol2smi(self):
-        self.assertEqual(mol2smi(self.mol), self.smiles)
-
-    def test_smi2mol2smi(self):
-        self.assertTrue(re.compile(self.mol).match(smi2mol(self.smiles)))
-
     def test_view_smi2mol_valid(self):
         url = reverse("smi2mol")
         response = self.client.get(url, {"smiString": self.smiles})
@@ -89,94 +94,6 @@ class MolSmiTestCase(TestCase):
         self.assertEqual(response.status_code, 400)
 
 
-class SmiInchi(TestCase):
-    """
-    Test INCHI and INCHIKEY generation functions
-    """
-
-    def setUp(self):
-        self.smiles_str = (
-            "CC(C)C(=O)C1=C(C(=C(C(=C1)C(=O)C2=CC=C(C=C2)" "OC3=CC=CC=C3)O)O)O"
-        )
-        self.inchi_str = (
-            "InChI=1S/C23H20O6/c1-13(2)19(24)17-12-18(22"
-            "(27)23(28)21(17)26)20(25)14-8-10-16(11-9-14)29-15-6-4-3-"
-            "5-7-15/h3-13,26-28H,1-2H3"
-        )
-        self.inchikey_str = "CVVQMBDTMYUWTR-UHFFFAOYSA-N"
-
-    def test_smi2inchi(self):
-        self.assertEqual(smi2inchi(self.smiles_str), self.inchi_str)
-
-    def test_smi2inchikey(self):
-        self.assertEqual(smi2inchikey(self.smiles_str), self.inchikey_str)
-
-
-class FingerPrinterTestCase(TestCase):
-    """
-    Test FingerPrinter class
-    """
-
-    def setUp(self):
-        self.fingerprinter = FingerPrinter("FP4")
-        self.smiles = "CC"
-        self.fp = vectorUnsignedInt([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
-        self.smiles_dict = {1: "CC", 2: "CCC"}
-        self.fp_dict = {
-            1: vectorUnsignedInt([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
-            2: vectorUnsignedInt([3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
-        }
-        self.tanimoto_dict = {1: 1.0, 2: 0.5}
-
-    def test_fingerprints_available(self):
-        # test that all necessary fingerprints are available
-        self.assertIsNotNone(OBFingerprint.FindFingerprint("FP2"))
-        self.assertIsNotNone(OBFingerprint.FindFingerprint("FP4"))
-        self.assertIsNotNone(OBFingerprint.FindFingerprint("ECFP4"))
-
-    def assertEqualVUI(self, vui_one, vui_two):
-        return self.assertEqual(list(vui_one), list(vui_two))
-
-    def assertEqualVUIdict(self, vuidict_one, vuidict_two):
-        vuidict_one = {id_: list(vui) for id_, vui in vuidict_one.items()}
-        vuidict_two = {id_: list(vui) for id_, vui in vuidict_two.items()}
-        return self.assertEqual(vuidict_one, vuidict_two)
-
-    def test_fp(self):
-        self.assertEqualVUI(self.fingerprinter.fp(self.smiles), self.fp)
-
-    def test_fp_dict(self):
-        self.assertEqualVUIdict(
-            self.fingerprinter.fp_dict(self.smiles_dict), self.fp_dict
-        )
-
-    def test_tanimoto_fps(self):
-        self.assertEqual(
-            self.fingerprinter.tanimoto_fps(self.smiles, self.fp_dict),
-            self.tanimoto_dict,
-        )
-
-    def test_tanimoto_smiles(self):
-        self.assertEqual(
-            self.fingerprinter.tanimoto_smiles(self.smiles, self.smiles_dict),
-            self.tanimoto_dict,
-        )
-
-
-class FingerPrinterTestCaseCompound1ECFP4(TestCase):
-    def setUp(self):
-        self.fingerprinter = FingerPrinter("ECFP4")
-        self.smiles = "CC(C)C(=O)c1cc(C(=O)c2ccc(Oc3ccccc3)cc2)c(O)c(O)c1O"
-        self.smiles_dict = {1: "CC(C)C(=O)c1cc(C(=O)c2ccc(Oc3ccccc3)cc2)c(O)" "c(O)c1O"}
-        self.tanimoto_dict = {1: 1.0}
-
-    def test_tanimoto_smiles(self):
-        self.assertEqual(
-            self.fingerprinter.tanimoto_smiles(self.smiles, self.smiles_dict),
-            self.tanimoto_dict,
-        )
-
-
 def create_dummy_compound(id_, smiles):
     c = Compound()
     c.id = id_
@@ -634,7 +551,7 @@ class TestGetDoiInfo(TestCase):
 
     def test_get_doi_info(self):
         try:
-            resp = ws.get_doi_info("10.1073/pnas.0805139105")
+            resp = get_doi_info("10.1073/pnas.0805139105")
         except requests.exceptions.HTTPError as he:
             # skip this test if the DOI server throws an error
             # (that happens)
@@ -657,7 +574,7 @@ class TestGetDoiInfo(TestCase):
 class TestGetPfamInfo(TestCase):
     def test_get_pfam_info(self):
         target = {"id": "bZIP_1", "description": "bZIP transcription factor"}
-        resp = ws.get_pfam_info("PF00170")
+        resp = get_pfam_info("PF00170")
         self.assertEqual(resp, target)
 
 
@@ -672,13 +589,13 @@ class TestGetGooglePatentInfo(TestCase):
             " McGrew, Robert T. Bell, "
             "Steven Joseph Rich, Cisco Technology Inc",
         }
-        resp = ws.get_google_patent_info("US8856504")
+        resp = get_google_patent_info("US8856504")
         self.assertEqual(resp, target)
 
     def test_entry_not_found(self):
         self.assertRaises(
-            ws.PatentNotFound,
-            ws.get_google_patent_info,
+            PatentNotFound,
+            get_google_patent_info,
             "US8856504US8856504US885US8856504US8856504",
         )
 
@@ -704,7 +621,7 @@ class TestGetPubMEDIdInfo(TestCase):
             "authors_list": "Brancotte B, Biton A, Bernard-Pierrot I, "
             "Radvanyi F, Reyal F, Cohen-Boulakia S",
         }
-        resp = ws.get_pubmed_info("21349868")
+        resp = get_pubmed_info("21349868")
         self.assertEqual(resp, target)
 
 
@@ -826,13 +743,13 @@ class TestGetPDBUniProtMapping(TestCase):
 
     def test_find_info(self):
         target = sorted(["Q03164", "O00255"])
-        resp = ws.get_pdb_uniprot_mapping("3u85")
+        resp = get_pdb_uniprot_mapping("3u85")
         resp = sorted(resp)
         self.assertEqual(resp, target)
         self.assertEqual(len(resp), len(set(resp)))
 
     def test_entry_not_found(self):
-        self.assertRaises(ws.EntryNotFoundError, ws.get_pdb_uniprot_mapping, "Xu85")
+        self.assertRaises(EntryNotFoundError, get_pdb_uniprot_mapping, "Xu85")
 
 
 class TestGetPDBPfamMapping(TestCase):
@@ -844,11 +761,11 @@ class TestGetPDBPfamMapping(TestCase):
         target = {
             "PF05053": {"identifier": "Menin", "description": "Menin", "name": "Menin"}
         }
-        resp = ws.get_pdb_pfam_mapping("3u85")
+        resp = get_pdb_pfam_mapping("3u85")
         self.assertDictEqual(resp, target)
 
     def test_entry_not_found(self):
-        self.assertRaises(ws.EntryNotFoundError, ws.get_pdb_uniprot_mapping, "Xu85")
+        self.assertRaises(EntryNotFoundError, get_pdb_pfam_mapping, "Xu85")
 
 
 class TestConvertIUPACToSMILESAndMore(TestCase):
@@ -888,7 +805,7 @@ class TestConvertIUPACToSMILESAndMore(TestCase):
             ),
         ]
         for iupac, dict_expected in pairs:
-            dict_returned = ws.convert_iupac_to_smiles_and_inchi(iupac)
+            dict_returned = convert_iupac_to_smiles_and_inchi(iupac)
             self.assertEqual(dict_expected["smiles"], dict_returned["smiles"])
             self.assertEqual(dict_expected["inchi"], dict_returned["inchi"])
             self.assertEqual(dict_expected["stdinchi"], dict_returned["stdinchi"])
@@ -896,13 +813,11 @@ class TestConvertIUPACToSMILESAndMore(TestCase):
 
     def test_invalid_entry(self):
         self.assertRaises(
-            ws.EntryNotFoundError,
-            ws.convert_iupac_to_smiles_and_inchi,
+            EntryNotFoundError,
+            convert_iupac_to_smiles_and_inchi,
             "3-{1-oxo-6-[4-(piperid",
         )
-        self.assertRaises(
-            ws.EntryNotFoundError, ws.convert_iupac_to_smiles_and_inchi, None
-        )
+        self.assertRaises(EntryNotFoundError, convert_iupac_to_smiles_and_inchi, None)
 
 
 class TestConvertSMILESToIUPAC(TestCase):
@@ -914,7 +829,7 @@ class TestConvertSMILESToIUPAC(TestCase):
         smiles_to_iupacs = {"CCC": "propane"}
         for smiles, expected_iupac in smiles_to_iupacs.items():
             self.assertEqual(
-                ws.convert_smiles_to_iupac(smiles).lower(), expected_iupac.lower()
+                convert_smiles_to_iupac(smiles).lower(), expected_iupac.lower()
             )
 
 
diff --git a/ippisite/ippidb/tests/tests_contribute.py b/ippisite/ippidb/tests/tests_contribute.py
new file mode 100644
index 0000000000000000000000000000000000000000..5ecf48a3b84c3d319a703bbc6b52bce956c8bc9a
--- /dev/null
+++ b/ippisite/ippidb/tests/tests_contribute.py
@@ -0,0 +1,126 @@
+"""
+iPPI-DB contribution module tests
+"""
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Permission
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase
+from django.urls import reverse
+
+
+from ippidb import models
+from ippidb.forms import PDBForm
+from live_settings import live_settings
+
+import requests_cache
+
+requests_cache.install_cache("tests_contribute_cache", backend="sqlite")
+
+
+class BibliographyIDTestCase(TestCase):
+    def test_long_doi(self):
+        b = models.Bibliography()
+        b.source = "DO"
+        b.id_source = "10.1016/j.bmcl.2013.03.013"
+        b.save(autofill=True)
+
+
+class ContributionViewsAccessTestCase(TestCase):
+    def setUp(self):
+        self.url = reverse("admin-session-add")
+
+        self.user = get_user_model().objects.create(username="user")
+
+        self.user_granted = get_user_model().objects.create(username="user_granted")
+        content_type = ContentType.objects.get_for_model(models.Contribution)
+        permission = Permission.objects.get(
+            codename="add_contribution", content_type=content_type
+        )
+        self.user_granted.user_permissions.add(permission)
+
+    def test_force_auth(self):
+        response = self.client.get(self.url)
+        self.assertRedirects(
+            response,
+            # expected_url=reverse('login') + "?next=" + url
+            expected_url="/accounts/login/?next=%s" % self.url,
+        )
+
+    def test_403_without_perm_and_access_closed(self):
+        self.client.force_login(self.user)
+        response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 403)
+
+    def test_ok_with_perm_and_access_closed(self):
+        live_settings.open_access_to_contribution = "False"
+        self.test_ok_with_perm_and_access_closed_by_default()
+
+    def test_ok_with_perm_and_access_closed_by_default(self):
+        self.client.force_login(self.user_granted)
+        response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 302)
+
+    def test_ok_without_perm_and_access_open(self):
+        live_settings.open_access_to_contribution = "True"
+        self.client.force_login(self.user)
+        response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 302)
+
+    def test_ok_with_perm_and_access_open(self):
+        live_settings.open_access_to_contribution = "True"
+        self.client.force_login(self.user_granted)
+        response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 302)
+
+    def test_ok_with_superuser(self):
+        self.user.is_superuser = True
+        self.user.save()
+        self.client.force_login(self.user)
+        response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 302)
+
+    def test_ko_with_staff(self):
+        self.user.is_staff = True
+        self.user.save()
+        self.client.force_login(self.user)
+        response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 403)
+
+
+class PDBFormTestCase(TestCase):
+    """
+    Test the PDB form data validation
+    """
+
+    def test_valid(self):
+        form = PDBForm({"pdb_id": "3wn7"})
+        self.assertTrue(form.is_valid(), "valid PDB code should be accepted")
+
+    def test_invalid_PDB_format(self):
+        form = PDBForm({"pdb_id": "33wn7"})
+        self.assertFalse(form.is_valid(), "5 char long PDB code should be rejected")
+        self.assertTrue(
+            form.has_error("pdb_id", code="max_length"),
+            "5 char long PDB code should be rejected with max_length error code",
+        )
+
+    def test_invalid_PDB_entry(self):
+        form = PDBForm({"pdb_id": "9aa9"})
+        self.assertFalse(
+            form.is_valid(), "inexistent PDB (9aa9) code should be rejected"
+        )
+        self.assertTrue(
+            form.has_error("pdb_id", code="not_found"),
+            "inexistent PDB (9aa9) code should be rejected with not_found error code",
+        )
+
+    def test_invalid_nouniprot(self):
+        form = PDBForm({"pdb_id": "3wn8"})
+        self.assertFalse(
+            form.is_valid(),
+            "PDB code with no Uniprot mapping (3wn8) should be rejected",
+        )
+        self.assertTrue(
+            form.has_error("pdb_id", code="no_mapping"),
+            "PDB code with no Uniprot mapping (3wn8) should be rejected with no_mappping error code",
+        )
diff --git a/ippisite/ippidb/tests/tests_contribute_e2e.py b/ippisite/ippidb/tests/tests_contribute_e2e.py
new file mode 100644
index 0000000000000000000000000000000000000000..7ace673400148b6341cc7604d64a28cd0512863c
--- /dev/null
+++ b/ippisite/ippidb/tests/tests_contribute_e2e.py
@@ -0,0 +1,628 @@
+"""
+iPPI-DB contribution module "end to end" tests
+"""
+import math
+import os
+from decimal import Decimal
+from tempfile import NamedTemporaryFile
+import yaml
+import glob
+
+from django.contrib.auth import get_user_model
+from django.db.models import Value
+from django.db.models.functions import Concat
+from django.test import TestCase
+from django.urls import reverse
+from parameterized import parameterized
+import requests_cache
+
+from ippidb import models
+from ippidb.admin import grant_contribution_permission
+from ippidb.ws import get_uniprot_info
+
+
+requests_cache.install_cache("tests_contribute_cache", backend="sqlite")
+
+
+def compute_ppi_name(entry_data):
+    """
+    return the expected PPI name from a given contribution entry_data
+    """
+    bound_uniprots = [
+        item["uniprot_id"]
+        for item in entry_data["complex"]
+        if item["complex_type"] == "Bound"
+    ]
+    partner_uniprots = [
+        item["uniprot_id"]
+        for item in entry_data["complex"]
+        if item["complex_type"] == "Partner"
+    ]
+    bound_protein_names = [
+        get_uniprot_info(uniprot_id)["short_name"] for uniprot_id in bound_uniprots
+    ]
+    partner_protein_names = [
+        get_uniprot_info(uniprot_id)["short_name"] for uniprot_id in partner_uniprots
+    ]
+    bound_protein_names.sort()
+    partner_protein_names.sort()
+    bound_str = ",".join(bound_protein_names)
+    partner_str = ",".join(partner_protein_names)
+    name = bound_str
+    if partner_str != "":
+        name += " / " + partner_str
+    return name
+
+
+class ContributionE2ETestCase(TestCase):
+    """
+    This class tests the Contribution Wizard by interacting with it
+    in the same way a user would do.
+    """
+
+    def setUp(self):
+        login = "contributor"
+        password = "12345"
+        User = get_user_model()
+        User.objects.create_user(username=login, password=password)
+        self.client.login(username=login, password=password)
+        grant_contribution_permission(None, None, User.objects.all())
+        symmetry = models.Symmetry()
+        symmetry.code = "AS"
+        symmetry.description = "asymmetric"
+        symmetry.save()
+        models.Taxonomy.objects.create(taxonomy_id=9606, name="Homo sapiens")
+
+    @staticmethod
+    def get_step_url(step_id):
+        step_url = reverse("admin-session-add")
+        if step_id is not None:
+            step_url += step_id + "/"
+        return step_url
+
+    def _process_contribution_wizard(self, entry_data):
+        future_expected_equals = [
+            (
+                models.Bibliography.objects.count,
+                models.Bibliography.objects.count() + 1,
+                "Bibliography count",
+            ),
+            (
+                models.Contribution.objects.count,
+                models.Contribution.objects.count() + 1,
+                "Contribution count",
+            ),
+            (
+                models.Compound.objects.count,
+                models.Compound.objects.count() + len(entry_data["compounds"]),
+                "Compounds count",
+            ),
+            (
+                models.Compound.objects.validated().count,
+                models.Compound.objects.validated().count(),
+                "Validated Compounds count should remains the same",
+            ),
+            (
+                models.CompoundAction.objects.count,
+                models.CompoundAction.objects.count() + len(entry_data["compounds"]),
+                "Compound Actions count",
+            ),
+            (
+                models.TestActivityDescription.objects.count,
+                models.TestActivityDescription.objects.count()
+                + len(entry_data.get("activity_tests", [])),
+                "Activity tests count",
+            ),
+            (
+                models.TestCytotoxDescription.objects.count,
+                models.TestCytotoxDescription.objects.count()
+                + len(entry_data.get("cytotox_tests", [])),
+                "Cytotox tests count",
+            ),
+            (
+                models.TestPKDescription.objects.count,
+                models.TestPKDescription.objects.count()
+                + len(entry_data.get("pharmacokinetic_tests", [])),
+                "Pharmacokinetic tests count",
+            ),
+            (
+                lambda: models.PpiFamily.objects.get().name,
+                entry_data.get("family_name"),
+                "Ppi family name",
+            ),
+            (
+                lambda: models.Ppi.objects.get().family.name,
+                entry_data.get("family_name"),
+                "Ppi family name from PPI",
+            ),
+            (
+                lambda: models.Ppi.objects.get().name,
+                compute_ppi_name(entry_data),
+                "Ppi name",
+            ),
+        ]
+        for activity_tests in entry_data["activity_tests"]:
+            for compound_activity_results in activity_tests[
+                "compound_activity_results"
+            ]:
+                if (
+                    "activity_mol" in compound_activity_results
+                    and compound_activity_results["activity_mol"] != ""
+                ):
+                    compound_activity_results["activity"] = -math.log10(
+                        Decimal(str(compound_activity_results["activity_mol"]))
+                        * Decimal(str(compound_activity_results["activity_unit"]))
+                    )
+
+        self._process_contribution_wizard_without_sanity_check(entry_data)
+
+        for fcn, results, msg in future_expected_equals:
+            self.assertEqual(fcn(), results, msg=msg)
+        post_validation_expected_equals = [
+            (
+                models.Compound.objects.validated().count,
+                models.Compound.objects.count(),
+                "Validated Compounds count should have increased",
+            )
+        ]
+        contribution_to_be_validated = models.Contribution.objects.get(validated=False)
+        contribution_to_be_validated.validated = True
+        contribution_to_be_validated.save()
+        for fcn, results, msg in post_validation_expected_equals:
+            self.assertEqual(fcn(), results, msg=msg)
+
+    def _process_contribution_wizard_without_sanity_check(
+        self, entry_data, error_expected_in_step=None
+    ):
+        """
+        The contribution add "wizard"
+        returns a 200
+        """
+        wizard_data = self._generate_wizard_data(entry_data)
+        for step in wizard_data:
+            step_url = self.get_step_url(step.get("step-id"))
+            response = self.client.get(step_url)
+            # post form data
+            if step.get("form-data") is not None:
+                err_msg = (
+                    f"Response code not ok when getting form "
+                    f"for {step.get('step-id')} at {step_url}"
+                )
+                self.assertEqual(response.status_code, 200, err_msg)
+                form_data = {
+                    step["step-id"] + "-" + param_name: value
+                    for param_name, value in step.get("form-data").items()
+                }
+                form_data["ippi_wizard-current_step"] = step["step-id"]
+                response = self.client.post(step_url, form_data)
+            error_is_now = (
+                error_expected_in_step == step.get("step-id")
+                and response.status_code != 302
+            )
+            if (
+                response.status_code != 302
+                and step.get("step-id") != "done"
+                or error_is_now
+            ):
+                file_path = self.write_in_tmp_file(response)
+                err_msg = (
+                    f"Response code not ok when getting form for "
+                    f"{step.get('step-id')} at {step_url}. Server"
+                    f" response is stored in {file_path}"
+                )
+                if error_is_now:
+                    self.assertEqual(response.status_code, 200, err_msg)
+                    return
+                self.assertEqual(response.status_code, 302, err_msg)
+            # check redirect to next step (if there is one)
+            if step.get("next") is not None:
+                redirect_url = self.get_step_url(step.get("next"))
+                self.assertEqual(
+                    response.url,
+                    redirect_url,
+                    f"wrong redirection URL after step {step['step-id']}",
+                )
+        self.assertEqual(
+            response.url,
+            reverse("contribution-detail", kwargs={"contribution_pk": 1}),
+            f"wrong final URL, should be the contribution permanent URL",
+        )
+
+    def _generate_wizard_data(self, entry_data):
+        """
+        Generate the wizard form data dynamically based on iPPI-DB entry data
+        """
+
+        def get_id_form():
+            return {
+                "source": entry_data["source"],
+                "id_source": entry_data["id_source"],
+            }
+
+        def get_bibliography_form():
+            ret = {
+                "source": entry_data["source"],
+                "id_source": entry_data["id_source"],
+                "title": "Opportunistic+amoebae:+challenges"
+                "+in+prophylaxis+and+treatment.",
+                "journal_name": "Drug+resistance+updates+:+reviews+and+"
+                "commentaries+in+antimicrobial+and+anticancer"
+                "+chemotherapy",
+                "authors_list": "Schuster+FL,+Visvesvara+GS",
+                "biblio_year": "2004",
+            }
+            for k in [
+                "cytotox",
+                "xray",
+                "in_silico",
+                "in_vitro",
+                "in_cellulo",
+                "in_vivo",
+                "pharmacokinetic",
+            ]:
+                if entry_data.get(k, False):
+                    ret[k] = "on"
+            return ret
+
+        def get_pdb_form():
+            return {"pdb_id": entry_data["pdb_id"]}
+
+        def get_complex_type_form():
+            return {
+                "complexType": entry_data["complexType"],
+                "complexChoice": entry_data["complexChoice"],
+            }
+
+        def get_complex_form():
+            data = {
+                "TOTAL_FORMS": len(entry_data["complex"]),
+                "INITIAL_FORMS": len(entry_data["complex"]),
+                "MIN_NUM_FORMS": 0,
+                "MAX_NUM_FORMS": 1000,
+            }
+            for idx, entry_complex in enumerate(entry_data["complex"]):
+                data["{}-protein".format(idx)] = models.Protein.objects.get(
+                    uniprot_id=entry_complex["uniprot_id"]
+                ).id
+                data["{}-complex_type".format(idx)] = entry_complex["complex_type"]
+                if entry_complex["domain_pfam_acc"] is not None:
+                    try:
+                        data["{}-domain".format(idx)] = models.Domain.objects.get(
+                            pfam_acc=entry_complex["domain_pfam_acc"]
+                        ).id
+                    except models.Domain.DoesNotExist as dne:
+                        print(
+                            "No domain in DB for PFAM ACC {}".format(
+                                entry_complex["domain_pfam_acc"]
+                            )
+                        )
+                        raise dne
+                else:
+                    data["{}-domain".format(idx)] = ""
+                data["{}-ppc_copy_nb".format(idx)] = entry_complex["ppc_copy_nb"]
+                data["{}-cc_nb".format(idx)] = entry_complex["cc_nb"]
+                data["{}-ppp_copy_nb_per_p".format(idx)] = entry_complex[
+                    "ppp_copy_nb_per_p"
+                ]
+            return data
+
+        def get_ppi_form():
+            return {
+                "family": "",
+                "pdb_id": entry_data["pdb_id"],
+                "family_name": entry_data["family_name"],
+                "symmetry": 1,  # FIXME
+                "pockets_nb": 1,  # FIXME
+                "selected_diseases": "\n".join(entry_data["diseases"]),
+            }
+
+        def get_compound_form():
+            data = {
+                "TOTAL_FORMS": len(entry_data["compounds"]),
+                # FIXME there should be a way to list more than one compound
+                "INITIAL_FORMS": len(entry_data["compounds"]),
+                "MIN_NUM_FORMS": 1,
+                "MAX_NUM_FORMS": 1000,
+            }
+            for idx, compound_data_item in enumerate(entry_data["compounds"]):
+                data[f"{idx}-common_name"] = (compound_data_item["common_name"],)
+                data[f"{idx}-compound_name"] = (compound_data_item["compound_name"],)
+                if "molecule_smiles" in compound_data_item:
+                    data[f"{idx}-molecule_smiles"] = (
+                        compound_data_item["molecule_smiles"],
+                    )
+                elif "molecule_iupac" in compound_data_item:
+                    # if a IUPAC is provided for tests assume convert it to SMILES
+                    # molecule_smiles = convert_iupac_to_smiles(
+                    #    compound_data_item["molecule_iupac"]
+                    # )
+                    data[f"{idx}-molecule_iupac"] = compound_data_item["molecule_iupac"]
+                data[f"{idx}-is_macrocycle"] = compound_data_item.get(
+                    "is_macrocycle", False
+                )
+                if "ligand_id" in compound_data_item:
+                    data[f"{idx}-ligand_id"] = compound_data_item["ligand_id"]
+            return data
+
+        def get_activity_description_form():
+            data = {
+                "TOTAL_FORMS": len(entry_data.get("activity_tests", [])),
+                "INITIAL_FORMS": 0,
+                "MIN_NUM_FORMS": 0,
+                "MAX_NUM_FORMS": 1000,
+            }
+            for idx, activity_test in enumerate(entry_data.get("activity_tests", [])):
+                data[f"{idx}-ppi"] = ""
+                data[f"{idx}-test_name"] = activity_test["test_name"]
+                data[f"{idx}-is_primary"] = activity_test.get("is_primary", False)
+                data[f"{idx}-protein_bound_construct"] = activity_test[
+                    "protein_bound_construct"
+                ]
+                data[f"{idx}-test_type"] = activity_test["test_type"]
+                data[f"{idx}-test_modulation_type"] = activity_test[
+                    "test_modulation_type"
+                ]
+                data[f"{idx}-nb_active_compounds"] = activity_test[
+                    "nb_active_compounds"
+                ]
+                if "cell_line_name" in activity_test:
+                    data[f"{idx}-cell_line_name"] = activity_test["cell_line_name"]
+                data[f"{idx}-protein_complex"] = activity_test["protein_complex"]
+                data[
+                    f"{idx}-compoundactivityresult_set-activity-results-TOTAL_FORMS"
+                ] = 1  # len(activity_test.get("compound_activity_results",[]))
+                data[
+                    f"{idx}-compoundactivityresult_set-activity-results-INITIAL_FORMS"
+                ] = 0
+                data[
+                    f"{idx}-compoundactivityresult_set-activity-results-MIN_NUM_FORMS"
+                ] = 0
+                data[
+                    f"{idx}-compoundactivityresult_set-activity-results-MAX_NUM_FORMS"
+                ] = 1000
+                for nidx, compound_activity_result in enumerate(
+                    activity_test["compound_activity_results"]
+                ):
+                    data[
+                        f"{idx}-compoundactivityresult_set-activity-results-{nidx}-compound_name"
+                    ] = compound_activity_result["compound_name"]
+                    data[
+                        f"{idx}-compoundactivityresult_set-activity-results-{nidx}-activity_type"
+                    ] = compound_activity_result["activity_type"]
+                    try:
+                        data[
+                            f"{idx}-compoundactivityresult_set-activity-results-{nidx}-activity_mol"
+                        ] = compound_activity_result["activity_mol"]
+                        data[
+                            f"{idx}-compoundactivityresult_set-activity-results-{nidx}-activity_unit"
+                        ] = compound_activity_result["activity_unit"]
+                    except KeyError:
+                        pass
+                    data[
+                        f"{idx}-compoundactivityresult_set-activity-results-{nidx}-modulation_type"
+                    ] = compound_activity_result["modulation_type"]
+                    # "id": "",
+                    # "test_activity_description": "",
+            return data
+
+        def get_cytotox_description_form():
+            data = {
+                "TOTAL_FORMS": len(entry_data.get("cytotox_tests", [])),
+                "INITIAL_FORMS": 0,
+                "MIN_NUM_FORMS": 1,
+                "MAX_NUM_FORMS": 1000,
+            }
+            for idx, cytotox_test in enumerate(entry_data.get("cytotox_tests", [])):
+                data[f"{idx}-test_name"] = cytotox_test["test_name"]
+                data[f"{idx}-compound_concentration"] = cytotox_test[
+                    "compound_concentration"
+                ]
+                data[f"{idx}-cell_line_name"] = cytotox_test["cell_line_name"]
+
+                data[
+                    f"{idx}-compoundcytotoxicityresult_set-cytotox-results-TOTAL_FORMS"
+                ] = len(cytotox_test.get("compound_cytotox_results", []))
+                data[
+                    f"{idx}-compoundcytotoxicityresult_set-cytotox-results-INITIAL_FORMS"
+                ] = 0
+                data[
+                    f"{idx}-compoundcytotoxicityresult_set-cytotox-results-MIN_NUM_FORMS"
+                ] = 0
+                data[
+                    f"{idx}-compoundcytotoxicityresult_set-cytotox-results-MAX_NUM_FORMS"
+                ] = 1000
+                for nidx, result in enumerate(cytotox_test["compound_cytotox_results"]):
+                    data[
+                        f"{idx}-compoundcytotoxicityresult_set-cytotox-results-{nidx}-compound_name"
+                    ] = result["compound_name"]
+                    data[
+                        f"{idx}-compoundcytotoxicityresult_set-cytotox-results-{nidx}-toxicity"
+                    ] = result["toxicity"]
+            return data
+
+        def get_pk_description_form():
+            data = {
+                "TOTAL_FORMS": len(entry_data.get("pharmacokinetic_tests", [])),
+                "INITIAL_FORMS": 0,
+                "MIN_NUM_FORMS": 1,
+                "MAX_NUM_FORMS": 1000,
+            }
+            for idx, pk_test in enumerate(entry_data.get("pharmacokinetic_tests", [])):
+                data[f"{idx}-test_name"] = pk_test["test_name"]
+                data[f"{idx}-organism"] = pk_test["organism"]
+                data[f"{idx}-administration_mode"] = pk_test["administration_mode"]
+                data[f"{idx}-concentration"] = pk_test["concentration"]
+                data[f"{idx}-dose"] = pk_test["dose"]
+                data[f"{idx}-dose_interval"] = pk_test["dose_interval"]
+                data[f"{idx}-cell_line_name"] = pk_test["cell_line_name"]
+
+                data[f"{idx}-compoundpkresult_set-pk-results-TOTAL_FORMS"] = len(
+                    pk_test.get("compound_pk_results", [])
+                )
+                data[f"{idx}-compoundpkresult_set-pk-results-INITIAL_FORMS"] = 0
+                data[f"{idx}-compoundpkresult_set-pk-results-MIN_NUM_FORMS"] = 0
+                data[f"{idx}-compoundpkresult_set-pk-results-MAX_NUM_FORMS"] = 1000
+                for nidx, result in enumerate(pk_test["compound_pk_results"]):
+                    data[
+                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-tolerated"
+                    ] = result["tolerated"]
+                    data[f"{idx}-compoundpkresult_set-pk-results-{nidx}-auc"] = result[
+                        "auc"
+                    ]
+                    data[
+                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-clearance"
+                    ] = result["clearance"]
+                    data[
+                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-c_max"
+                    ] = result["c_max"]
+                    data[
+                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-oral_bioavailability"
+                    ] = result["oral_bioavailability"]
+                    data[
+                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-t_demi"
+                    ] = result["t_demi"]
+                    data[
+                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-t_max"
+                    ] = result["t_max"]
+                    data[
+                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-voldistribution"
+                    ] = result["voldistribution"]
+                    data[
+                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-compound_name"
+                    ] = result["compound_name"]
+            return data
+
+        form_callables = {
+            None: lambda: None,
+            "IdForm": get_id_form,
+            "BibliographyForm": get_bibliography_form,
+            "PDBForm": get_pdb_form,
+            "ProteinDomainComplexTypeForm": get_complex_type_form,
+            "ProteinDomainComplexForm": get_complex_form,
+            "PpiForm": get_ppi_form,
+            "CompoundForm": get_compound_form,
+            "ActivityDescriptionFormSet": get_activity_description_form,
+        }
+        if entry_data.get("cytotox", False):
+            form_callables[
+                "TestCytotoxDescriptionFormSet"
+            ] = get_cytotox_description_form
+        if entry_data.get("pharmacokinetic", False):
+            form_callables["TestPKDescriptionFormSet"] = get_pk_description_form
+
+        # Adding a step for "done" in order to also save the data in the db
+        form_callables["done"] = lambda: None
+
+        form_ids = iter(list(form_callables.keys())[1:])
+        for step in form_callables.keys():
+            item = {
+                "step-id": step,
+                "form-data": form_callables[step](),
+                "next": next(form_ids, None),
+            }
+            yield item
+
+    @parameterized.expand(
+        [
+            entry_path
+            for entry_path in glob.glob(
+                os.path.join(os.path.dirname(__file__), "*.yaml")
+            )
+        ]
+    )
+    def test_entry(self, entry_path):
+        # load the test data
+        entry_data = yaml.load(open(entry_path, "r"), Loader=yaml.FullLoader)
+        # process the wizard
+        self._process_contribution_wizard(entry_data)
+        # test diseases
+        input_diseases = set(entry_data["diseases"])
+        input_diseases.discard("")
+        self.assertSetEqual(
+            input_diseases,
+            set(
+                models.Ppi.objects.get(pdb_id=entry_data["pdb_id"])
+                .diseases.annotate(
+                    raw=Concat(Value(";"), "name", Value(";"), "identifier", Value(";"))
+                )
+                .values_list("raw", flat=True)
+            ),
+        )
+        # test compounds
+        for c in entry_data["compounds"]:
+            if "molecule_smiles" in c:
+                comp = models.Compound.objects.get(canonical_smile=c["molecule_smiles"])
+            elif "molecule_iupac" in c:
+                comp = models.Compound.objects.get(iupac_name=c["molecule_iupac"])
+            # test ligand ID has been stored if the X ray data option is checked
+            if entry_data.get("xray", False) is True:
+                self.assertEqual(comp.ligand_id, c.get("ligand_id", None))
+        # test activity
+        bibliography = models.Bibliography.objects.get(
+            id_source=entry_data["id_source"].strip()
+        )
+        for activity_test in entry_data["activity_tests"]:
+            tad_filters = {
+                "test_type": activity_test["test_type"],
+                "test_name": activity_test["test_name"],
+                "protein_bound_construct": activity_test["protein_bound_construct"],
+                "test_modulation_type": activity_test["test_modulation_type"],
+                "nb_active_compounds": activity_test["nb_active_compounds"],
+            }
+            if (
+                "cell_line_name" in activity_test
+                and activity_test["cell_line_name"] != ""
+            ):
+                tad_filters["cell_line__name"] = activity_test["cell_line_name"]
+            for tad in models.TestActivityDescription.objects.all():
+                print(
+                    tad.test_type,
+                    tad.test_name,
+                    tad.protein_bound_construct,
+                    tad.test_modulation_type,
+                    tad.nb_active_compounds,
+                    tad.cell_line,
+                )
+            test_activity_description = models.TestActivityDescription.objects.get(
+                **tad_filters
+            )
+            for compound_activity_results in activity_test["compound_activity_results"]:
+                compound = models.RefCompoundBiblio.objects.get(
+                    bibliography=bibliography,
+                    compound_name=compound_activity_results["compound_name"],
+                ).compound
+                results = models.CompoundActivityResult.objects.get(
+                    activity_type=compound_activity_results["activity_type"],
+                    modulation_type=compound_activity_results["modulation_type"],
+                    compound=compound,
+                    test_activity_description=test_activity_description,
+                )
+                self.assertEqual(
+                    results.activity_type, compound_activity_results["activity_type"]
+                )
+                self.assertEqual(
+                    results.modulation_type,
+                    compound_activity_results["modulation_type"],
+                )
+                self.assertEqual(
+                    Decimal(compound_activity_results["activity"]).quantize(
+                        Decimal(10)
+                        ** -models.CompoundActivityResult._meta.get_field(
+                            "activity"
+                        ).decimal_places
+                    ),
+                    results.activity,
+                )
+
+    def write_in_tmp_file(self, response):
+        """
+        Write an HTTP response contents into a temporary file for debug
+
+        :param response: response that will be saved
+        :type respons: django.http.HttpResponse
+        :return: path to the temporary file created
+        :rtype: str
+        """
+        with NamedTemporaryFile(delete=False, suffix=".html") as f:
+            f.write(response.content)
+            return f.name
diff --git a/ippisite/ippidb/tests/tests_utils.py b/ippisite/ippidb/tests/tests_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..5c73f34e75dc2ebeb17c8b63d4eb9470b7da2a0b
--- /dev/null
+++ b/ippisite/ippidb/tests/tests_utils.py
@@ -0,0 +1,143 @@
+"""
+iPPI-DB unit tests
+"""
+import re
+
+from django.test import TestCase
+from openbabel import vectorUnsignedInt, OBFingerprint
+
+from ippidb.utils import FingerPrinter, mol2smi, smi2mol, smi2inchi, smi2inchikey, smi2sdf
+
+
+class MolSmiTestCase(TestCase):
+    """
+    Test MOL to SMILES and SMILES to MOL format conversion functions
+    """
+
+    def setUp(self):
+        self.smiles = "C"
+        # the MOL version is also a valid regexp to validate arbitrary name in
+        # the openbabel-generated version
+        self.mol = (
+            "\n OpenBabel[0-9]{11}D\n\n  1  0  0  0  0  0  0  0 "
+            " 0  0999 V2000\n    1.0000    0.0000    0.0000 C   0  0  0  0 "
+            " 0  0  0  0  0  0  0  0\nM  END\n"
+        )
+
+    def test_mol2smi(self):
+        self.assertEqual(mol2smi(self.mol), self.smiles)
+
+    def test_smi2mol2smi(self):
+        self.assertTrue(re.compile(self.mol).match(smi2mol(self.smiles)))
+
+
+class SmiInchiTestCase(TestCase):
+    """
+    Test INCHI and INCHIKEY generation functions
+    """
+
+    def setUp(self):
+        self.smiles_str = (
+            "CC(C)C(=O)C1=C(C(=C(C(=C1)C(=O)C2=CC=C(C=C2)" "OC3=CC=CC=C3)O)O)O"
+        )
+        self.inchi_str = (
+            "InChI=1S/C23H20O6/c1-13(2)19(24)17-12-18(22"
+            "(27)23(28)21(17)26)20(25)14-8-10-16(11-9-14)29-15-6-4-3-"
+            "5-7-15/h3-13,26-28H,1-2H3"
+        )
+        self.inchikey_str = "CVVQMBDTMYUWTR-UHFFFAOYSA-N"
+
+    def test_smi2inchi(self):
+        self.assertEqual(smi2inchi(self.smiles_str), self.inchi_str)
+
+    def test_smi2inchikey(self):
+        self.assertEqual(smi2inchikey(self.smiles_str), self.inchikey_str)
+
+
+class FingerPrinterTestCase(TestCase):
+    """
+    Test FingerPrinter class
+    """
+
+    def setUp(self):
+        self.fingerprinter = FingerPrinter("FP4")
+        self.smiles = "CC"
+        self.fp = vectorUnsignedInt([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
+        self.smiles_dict = {1: "CC", 2: "CCC"}
+        self.fp_dict = {
+            1: vectorUnsignedInt([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
+            2: vectorUnsignedInt([3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
+        }
+        self.tanimoto_dict = {1: 1.0, 2: 0.5}
+
+    def test_fingerprints_available(self):
+        # test that all necessary fingerprints are available
+        self.assertIsNotNone(OBFingerprint.FindFingerprint("FP2"))
+        self.assertIsNotNone(OBFingerprint.FindFingerprint("FP4"))
+        self.assertIsNotNone(OBFingerprint.FindFingerprint("ECFP4"))
+
+    def assertEqualVUI(self, vui_one, vui_two):
+        return self.assertEqual(list(vui_one), list(vui_two))
+
+    def assertEqualVUIdict(self, vuidict_one, vuidict_two):
+        vuidict_one = {id_: list(vui) for id_, vui in vuidict_one.items()}
+        vuidict_two = {id_: list(vui) for id_, vui in vuidict_two.items()}
+        return self.assertEqual(vuidict_one, vuidict_two)
+
+    def test_fp(self):
+        self.assertEqualVUI(self.fingerprinter.fp(self.smiles), self.fp)
+
+    def test_fp_dict(self):
+        self.assertEqualVUIdict(
+            self.fingerprinter.fp_dict(self.smiles_dict), self.fp_dict
+        )
+
+    def test_tanimoto_fps(self):
+        self.assertEqual(
+            self.fingerprinter.tanimoto_fps(self.smiles, self.fp_dict),
+            self.tanimoto_dict,
+        )
+
+    def test_tanimoto_smiles(self):
+        self.assertEqual(
+            self.fingerprinter.tanimoto_smiles(self.smiles, self.smiles_dict),
+            self.tanimoto_dict,
+        )
+
+
+class FingerPrinterTestCaseCompound1ECFP4(TestCase):
+    def setUp(self):
+        self.fingerprinter = FingerPrinter("ECFP4")
+        self.smiles = "CC(C)C(=O)c1cc(C(=O)c2ccc(Oc3ccccc3)cc2)c(O)c(O)c1O"
+        self.smiles_dict = {1: "CC(C)C(=O)c1cc(C(=O)c2ccc(Oc3ccccc3)cc2)c(O)" "c(O)c1O"}
+        self.tanimoto_dict = {1: 1.0}
+
+    def test_tanimoto_smiles(self):
+        self.assertEqual(
+            self.fingerprinter.tanimoto_smiles(self.smiles, self.smiles_dict),
+            self.tanimoto_dict,
+        )
+
+
+class ConvertSMILESToSDFTestCase(TestCase):
+    """
+    Test converting a smiles to SDF using openbabel
+    """
+
+    def setUp(self):
+        self.smiles = "C"
+        # the MOL version is also a valid regexp to validate arbitrary name in
+        # the openbabel-generated version
+        self.sdf = (
+            r"1\n"
+            r" OpenBabel[0-9]{11}D\n"
+            r"\n"
+            r"  1  0  0  0  0  0  0  0  0  0999 V2000\n"
+            r"    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0\n"
+            r"M  END\n"
+            r"\$\$\$\$\n"
+        )
+
+    def test_valid(self):
+        result = smi2sdf({1: self.smiles})
+        self.assertTrue(re.compile(self.sdf).search(result))
diff --git a/ippisite/ippidb/tests/tests_views.py b/ippisite/ippidb/tests/tests_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..f228aaef42dd633cc91ead91a11bab1cd000832b
--- /dev/null
+++ b/ippisite/ippidb/tests/tests_views.py
@@ -0,0 +1,39 @@
+"""
+iPPI-DB views tests
+"""
+from django.test import TestCase
+from django.urls import reverse
+from parameterized import parameterized
+
+tested_urlpatterns = [
+    "index",
+    "general",
+    "pharmacology",
+    "le_lle",
+    "physicochemistry",
+    "pca",
+    "compound_list",
+    # "redirect_compound_card",
+    # "compound_card",
+    "tutorials",
+    # "admin-session",
+    # "ippidb_step",
+    # "admin-session-add",
+    # "admin-session-view",
+    # "biblio-view",
+    # "ppi-view",
+    # "contribution-detail",
+    "admin-session-update",
+    # "mol2smi",
+    # "smi2mol",
+    # "smi2iupac",
+    # "iupac2smi",
+    # "getoutputjob",
+]
+
+
+class ViewTest(TestCase):
+    @parameterized.expand([urlpattern for urlpattern in tested_urlpatterns])
+    def test_url(self, url_name):
+        response = self.client.get(reverse(url_name))
+        self.assertEqual(response.status_code, 200)
diff --git a/ippisite/ippidb/tests_contribute.py b/ippisite/ippidb/tests_contribute.py
deleted file mode 100644
index cd123dda54d8d512d8a3e4838a42199bdc60101d..0000000000000000000000000000000000000000
--- a/ippisite/ippidb/tests_contribute.py
+++ /dev/null
@@ -1,1170 +0,0 @@
-"""
-iPPI-DB contribution module tests
-"""
-import math
-from decimal import Decimal
-from tempfile import NamedTemporaryFile
-
-from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Permission
-from django.contrib.contenttypes.models import ContentType
-from django.db.models import Value
-from django.db.models.functions import Concat
-from django.test import TestCase
-from django.urls import reverse
-
-
-from ippidb import models
-from ippidb.admin import grant_contribution_permission
-from ippidb.forms import PDBForm
-from ippidb.ws import convert_iupac_to_smiles, get_uniprot_info
-from live_settings import live_settings
-
-import requests_cache
-
-requests_cache.install_cache("tests_contribute_cache", backend="sqlite")
-
-
-class BibliographyIDTestCase(TestCase):
-    def test_long_doi(self):
-        b = models.Bibliography()
-        b.source = "DO"
-        b.id_source = "10.1016/j.bmcl.2013.03.013"
-        b.save(autofill=True)
-
-
-class ContributionViewsAccessTestCase(TestCase):
-    def setUp(self):
-        self.url = reverse("admin-session-add")
-
-        self.user = get_user_model().objects.create(username="user")
-
-        self.user_granted = get_user_model().objects.create(username="user_granted")
-        content_type = ContentType.objects.get_for_model(models.Contribution)
-        permission = Permission.objects.get(
-            codename="add_contribution", content_type=content_type
-        )
-        self.user_granted.user_permissions.add(permission)
-
-    def test_force_auth(self):
-        response = self.client.get(self.url)
-        self.assertRedirects(
-            response,
-            # expected_url=reverse('login') + "?next=" + url
-            expected_url="/accounts/login/?next=%s" % self.url,
-        )
-
-    def test_403_without_perm_and_access_closed(self):
-        self.client.force_login(self.user)
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, 403)
-
-    def test_ok_with_perm_and_access_closed(self):
-        live_settings.open_access_to_contribution = "False"
-        self.test_ok_with_perm_and_access_closed_by_default()
-
-    def test_ok_with_perm_and_access_closed_by_default(self):
-        self.client.force_login(self.user_granted)
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, 302)
-
-    def test_ok_without_perm_and_access_open(self):
-        live_settings.open_access_to_contribution = "True"
-        self.client.force_login(self.user)
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, 302)
-
-    def test_ok_with_perm_and_access_open(self):
-        live_settings.open_access_to_contribution = "True"
-        self.client.force_login(self.user_granted)
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, 302)
-
-    def test_ok_with_superuser(self):
-        self.user.is_superuser = True
-        self.user.save()
-        self.client.force_login(self.user)
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, 302)
-
-    def test_ko_with_staff(self):
-        self.user.is_staff = True
-        self.user.save()
-        self.client.force_login(self.user)
-        response = self.client.get(self.url)
-        self.assertEqual(response.status_code, 403)
-
-
-class PDBFormTestCase(TestCase):
-    """
-    Test the PDB form data validation
-    """
-
-    def test_valid(self):
-        form = PDBForm({"pdb_id": "3wn7"})
-        self.assertTrue(form.is_valid(), "valid PDB code should be accepted")
-
-    def test_invalid_PDB_format(self):
-        form = PDBForm({"pdb_id": "33wn7"})
-        self.assertFalse(form.is_valid(), "5 char long PDB code should be rejected")
-        self.assertTrue(
-            form.has_error("pdb_id", code="max_length"),
-            "5 char long PDB code should be rejected with max_length error code",
-        )
-
-    def test_invalid_PDB_entry(self):
-        form = PDBForm({"pdb_id": "9aa9"})
-        self.assertFalse(
-            form.is_valid(), "inexistent PDB (9aa9) code should be rejected"
-        )
-        self.assertTrue(
-            form.has_error("pdb_id", code="not_found"),
-            "inexistent PDB (9aa9) code should be rejected with not_found error code",
-        )
-
-    def test_invalid_nouniprot(self):
-        form = PDBForm({"pdb_id": "3wn8"})
-        self.assertFalse(
-            form.is_valid(), "PDB code with no Uniprot mapping (3wn8) should be rejected"
-        )
-        self.assertTrue(
-            form.has_error("pdb_id", code="no_mapping"),
-            "PDB code with no Uniprot mapping (3wn8) should be rejected with no_mappping error code",
-        )
-
-
-class ContributionViewsTestCase(TestCase):
-    """
-    This class tests the Contribution Wizard by interacting with it
-    in the same way a user would do.
-    """
-
-    def setUp(self):
-        login = "contributor"
-        password = "12345"
-        User = get_user_model()
-        User.objects.create_user(username=login, password=password)
-        self.client.login(username=login, password=password)
-        grant_contribution_permission(None, None, User.objects.all())
-        symmetry = models.Symmetry()
-        symmetry.code = "AS"
-        symmetry.description = "asymmetric"
-        symmetry.save()
-        models.Taxonomy.objects.create(taxonomy_id=9606, name="Homo sapiens")
-
-    @staticmethod
-    def get_step_url(step_id):
-        step_url = reverse("admin-session-add")
-        if step_id is not None:
-            step_url += step_id + "/"
-        return step_url
-
-    def _process_contribution_wizard(self, entry_data):
-        def compute_ppi_name(entry_data):
-            bound_uniprots = [
-                item["uniprot_id"]
-                for item in entry_data["complex"]
-                if item["complex_type"] == "Bound"
-            ]
-            partner_uniprots = [
-                item["uniprot_id"]
-                for item in entry_data["complex"]
-                if item["complex_type"] == "Partner"
-            ]
-            bound_protein_names = [
-                get_uniprot_info(uniprot_id)["short_name"]
-                for uniprot_id in bound_uniprots
-            ]
-            partner_protein_names = [
-                get_uniprot_info(uniprot_id)["short_name"]
-                for uniprot_id in partner_uniprots
-            ]
-            bound_protein_names.sort()
-            partner_protein_names.sort()
-            bound_str = ",".join(bound_protein_names)
-            partner_str = ",".join(partner_protein_names)
-            name = bound_str
-            if partner_str != "":
-                name += " / " + partner_str
-            return name
-
-        future_expected_equals = [
-            (
-                models.Bibliography.objects.count,
-                models.Bibliography.objects.count() + 1,
-                "Bibliography count",
-            ),
-            (
-                models.Contribution.objects.count,
-                models.Contribution.objects.count() + 1,
-                "Contribution count",
-            ),
-            (
-                models.Compound.objects.count,
-                models.Compound.objects.count() + len(entry_data["compounds"]),
-                "Compounds count",
-            ),
-            (
-                models.Compound.objects.validated().count,
-                models.Compound.objects.validated().count(),
-                "Validated Compounds count should remains the same",
-            ),
-            (
-                models.CompoundAction.objects.count,
-                models.CompoundAction.objects.count() + len(entry_data["compounds"]),
-                "Compound Actions count",
-            ),
-            (
-                models.TestActivityDescription.objects.count,
-                models.TestActivityDescription.objects.count()
-                + len(entry_data.get("activity_tests", [])),
-                "Activity tests count",
-            ),
-            (
-                models.TestCytotoxDescription.objects.count,
-                models.TestCytotoxDescription.objects.count()
-                + len(entry_data.get("cytotox_tests", [])),
-                "Cytotox tests count",
-            ),
-            (
-                models.TestPKDescription.objects.count,
-                models.TestPKDescription.objects.count()
-                + len(entry_data.get("pharmacokinetic_tests", [])),
-                "Pharmacokinetic tests count",
-            ),
-            (
-                lambda: models.PpiFamily.objects.get().name,
-                entry_data.get("family_name"),
-                "Ppi family name",
-            ),
-            (
-                lambda: models.Ppi.objects.get().family.name,
-                entry_data.get("family_name"),
-                "Ppi family name from PPI",
-            ),
-            (
-                lambda: models.Ppi.objects.get().name,
-                compute_ppi_name(entry_data),
-                "Ppi name",
-            ),
-        ]
-        for activity_tests in entry_data["activity_tests"]:
-            for compound_activity_results in activity_tests[
-                "compound_activity_results"
-            ]:
-                if (
-                    "activity_mol" in compound_activity_results
-                    and compound_activity_results["activity_mol"] != ""
-                ):
-                    compound_activity_results["activity"] = -math.log10(
-                        Decimal(str(compound_activity_results["activity_mol"]))
-                        * Decimal(str(compound_activity_results["activity_unit"]))
-                    )
-
-        self._process_contribution_wizard_without_sanity_check(entry_data)
-
-        for fcn, results, msg in future_expected_equals:
-            self.assertEqual(fcn(), results, msg=msg)
-        post_validation_expected_equals = [
-            (
-                models.Compound.objects.validated().count,
-                models.Compound.objects.count(),
-                "Validated Compounds count should have increased",
-            )
-        ]
-        contribution_to_be_validated = models.Contribution.objects.get(validated=False)
-        contribution_to_be_validated.validated = True
-        contribution_to_be_validated.save()
-        for fcn, results, msg in post_validation_expected_equals:
-            self.assertEqual(fcn(), results, msg=msg)
-
-    def _process_contribution_wizard_without_sanity_check(
-        self, entry_data, error_expected_in_step=None
-    ):
-        """
-        The contribution add "wizard"
-        returns a 200
-        """
-        wizard_data = self._generate_wizard_data(entry_data)
-        for step in wizard_data:
-            # import json
-            # print(json.dumps(step, indent=4))
-            step_url = self.get_step_url(step.get("step-id"))
-            response = self.client.get(step_url)
-            # post form data
-            if step.get("form-data") is not None:
-                err_msg = (
-                    f"Response code not ok when getting form "
-                    f"for {step.get('step-id')} at {step_url}"
-                )
-                self.assertEqual(response.status_code, 200, err_msg)
-                form_data = {
-                    step["step-id"] + "-" + param_name: value
-                    for param_name, value in step.get("form-data").items()
-                }
-                form_data["ippi_wizard-current_step"] = step["step-id"]
-                response = self.client.post(step_url, form_data)
-            error_is_now = (
-                error_expected_in_step == step.get("step-id")
-                and response.status_code != 302
-            )
-            if (
-                response.status_code != 302
-                and step.get("step-id") != "done"
-                or error_is_now
-            ):
-                file_path = self.write_in_tmp_file(response)
-                err_msg = (
-                    f"Response code not ok when getting form for "
-                    f"{step.get('step-id')} at {step_url}. Server"
-                    f" response is stored in {file_path}"
-                )
-                if error_is_now:
-                    self.assertEqual(response.status_code, 200, err_msg)
-                    return
-                self.assertEqual(response.status_code, 302, err_msg)
-            # check redirect to next step (if there is one)
-            if step.get("next") is not None:
-                redirect_url = self.get_step_url(step.get("next"))
-                self.assertEqual(
-                    response.url,
-                    redirect_url,
-                    f"wrong redirection URL after step {step['step-id']}",
-                )
-        self.assertEqual(
-            response.url,
-            reverse("contribution-detail", kwargs={"contribution_pk": 1}),
-            f"wrong final URL, should be the contribution permanent URL",
-        )
-
-    def _generate_wizard_data(self, entry_data):
-        """
-        Generate the wizard form data dynamically based on iPPI-DB entry data
-        """
-
-        def get_id_form():
-            return {
-                "source": entry_data["source"],
-                "id_source": entry_data["id_source"],
-            }
-
-        def get_bibliography_form():
-            ret = {
-                "source": entry_data["source"],
-                "id_source": entry_data["id_source"],
-                "title": "Opportunistic+amoebae:+challenges"
-                "+in+prophylaxis+and+treatment.",
-                "journal_name": "Drug+resistance+updates+:+reviews+and+"
-                "commentaries+in+antimicrobial+and+anticancer"
-                "+chemotherapy",
-                "authors_list": "Schuster+FL,+Visvesvara+GS",
-                "biblio_year": "2004",
-            }
-            for k in [
-                "cytotox",
-                "xray",
-                "in_silico",
-                "in_vitro",
-                "in_cellulo",
-                "in_vivo",
-                "pharmacokinetic",
-            ]:
-                if entry_data.get(k, False):
-                    ret[k] = "on"
-            return ret
-
-        def get_pdb_form():
-            return {"pdb_id": entry_data["pdb_id"]}
-
-        def get_complex_type_form():
-            return {
-                "complexType": entry_data["complexType"],
-                "complexChoice": entry_data["complexChoice"],
-            }
-
-        def get_complex_form():
-            data = {
-                "TOTAL_FORMS": len(entry_data["complex"]),
-                "INITIAL_FORMS": len(entry_data["complex"]),
-                "MIN_NUM_FORMS": 0,
-                "MAX_NUM_FORMS": 1000,
-            }
-            for idx, entry_complex in enumerate(entry_data["complex"]):
-                data["{}-protein".format(idx)] = models.Protein.objects.get(
-                    uniprot_id=entry_complex["uniprot_id"]
-                ).id
-                data["{}-complex_type".format(idx)] = entry_complex["complex_type"]
-                if entry_complex["domain_pfam_acc"] is not None:
-                    try:
-                        data["{}-domain".format(idx)] = models.Domain.objects.get(
-                            pfam_acc=entry_complex["domain_pfam_acc"]
-                        ).id
-                    except models.Domain.DoesNotExist as dne:
-                        print(
-                            "No domain in DB for PFAM ACC {}".format(
-                                entry_complex["domain_pfam_acc"]
-                            )
-                        )
-                        raise dne
-                else:
-                    data["{}-domain".format(idx)] = ""
-                data["{}-ppc_copy_nb".format(idx)] = entry_complex["ppc_copy_nb"]
-                data["{}-cc_nb".format(idx)] = entry_complex["cc_nb"]
-                data["{}-ppp_copy_nb_per_p".format(idx)] = entry_complex[
-                    "ppp_copy_nb_per_p"
-                ]
-            return data
-
-        def get_ppi_form():
-            return {
-                "family": "",
-                "pdb_id": entry_data["pdb_id"],
-                "family_name": entry_data["family_name"],
-                "symmetry": 1,  # FIXME
-                "pockets_nb": 1,  # FIXME
-                "selected_diseases": "\n".join(entry_data["diseases"]),
-            }
-
-        def get_compound_form():
-            data = {
-                "TOTAL_FORMS": len(entry_data["compounds"]),
-                # FIXME there should be a way to list more than one compound
-                "INITIAL_FORMS": len(entry_data["compounds"]),
-                "MIN_NUM_FORMS": 1,
-                "MAX_NUM_FORMS": 1000,
-            }
-            for idx, compound_data_item in enumerate(entry_data["compounds"]):
-                data[f"{idx}-common_name"] = (compound_data_item["common_name"],)
-                data[f"{idx}-compound_name"] = (compound_data_item["compound_name"],)
-                if "molecule_smiles" in compound_data_item:
-                    data[f"{idx}-molecule_smiles"] = (
-                        compound_data_item["molecule_smiles"],
-                    )
-                elif "molecule_iupac" in compound_data_item:
-                    # if a IUPAC is provided for tests assume convert it to SMILES
-                    molecule_smiles = convert_iupac_to_smiles(
-                        compound_data_item["molecule_iupac"]
-                    )
-                    data[f"{idx}-molecule_smiles"] = molecule_smiles
-                data[f"{idx}-is_macrocycle"] = compound_data_item.get(
-                    "is_macrocycle", False
-                )
-            return data
-
-        def get_activity_description_form():
-            data = {
-                "TOTAL_FORMS": len(entry_data.get("activity_tests", [])),
-                "INITIAL_FORMS": 0,
-                "MIN_NUM_FORMS": 0,
-                "MAX_NUM_FORMS": 1000,
-            }
-            for idx, activity_test in enumerate(entry_data.get("activity_tests", [])):
-                data[f"{idx}-ppi"] = ""
-                data[f"{idx}-test_name"] = activity_test["test_name"]
-                data[f"{idx}-is_primary"] = activity_test["is_primary"]
-                data[f"{idx}-protein_bound_construct"] = activity_test[
-                    "protein_bound_construct"
-                ]
-                data[f"{idx}-test_type"] = activity_test["test_type"]
-                data[f"{idx}-test_modulation_type"] = activity_test[
-                    "test_modulation_type"
-                ]
-                data[f"{idx}-nb_active_compounds"] = activity_test[
-                    "nb_active_compounds"
-                ]
-                data[f"{idx}-cell_line_name"] = activity_test["cell_line_name"]
-                data[f"{idx}-protein_complex"] = activity_test["protein_complex"]
-                data[
-                    f"{idx}-compoundactivityresult_set-activity-results-TOTAL_FORMS"
-                ] = 1  # len(activity_test.get("compound_activity_results",[]))
-                data[
-                    f"{idx}-compoundactivityresult_set-activity-results-INITIAL_FORMS"
-                ] = 0
-                data[
-                    f"{idx}-compoundactivityresult_set-activity-results-MIN_NUM_FORMS"
-                ] = 0
-                data[
-                    f"{idx}-compoundactivityresult_set-activity-results-MAX_NUM_FORMS"
-                ] = 1000
-                for nidx, compound_activity_result in enumerate(
-                    activity_test["compound_activity_results"]
-                ):
-                    data[
-                        f"{idx}-compoundactivityresult_set-activity-results-{nidx}-compound_name"
-                    ] = compound_activity_result["compound_name"]
-                    data[
-                        f"{idx}-compoundactivityresult_set-activity-results-{nidx}-activity_type"
-                    ] = compound_activity_result["activity_type"]
-                    try:
-                        data[
-                            f"{idx}-compoundactivityresult_set-activity-results-{nidx}-activity_mol"
-                        ] = compound_activity_result["activity_mol"]
-                        data[
-                            f"{idx}-compoundactivityresult_set-activity-results-{nidx}-activity_unit"
-                        ] = compound_activity_result["activity_unit"]
-                    except KeyError:
-                        pass
-                    data[
-                        f"{idx}-compoundactivityresult_set-activity-results-{nidx}-modulation_type"
-                    ] = compound_activity_result["modulation_type"]
-                    # "id": "",
-                    # "test_activity_description": "",
-            return data
-
-        def get_cytotox_description_form():
-            data = {
-                "TOTAL_FORMS": len(entry_data.get("cytotox_tests", [])),
-                "INITIAL_FORMS": 0,
-                "MIN_NUM_FORMS": 1,
-                "MAX_NUM_FORMS": 1000,
-            }
-            for idx, cytotox_test in enumerate(entry_data.get("cytotox_tests", [])):
-                data[f"{idx}-test_name"] = cytotox_test["test_name"]
-                data[f"{idx}-compound_concentration"] = cytotox_test[
-                    "compound_concentration"
-                ]
-                data[f"{idx}-cell_line_name"] = cytotox_test["cell_line_name"]
-
-                data[
-                    f"{idx}-compoundcytotoxicityresult_set-cytotox-results-TOTAL_FORMS"
-                ] = len(cytotox_test.get("compound_cytotox_results", []))
-                data[
-                    f"{idx}-compoundcytotoxicityresult_set-cytotox-results-INITIAL_FORMS"
-                ] = 0
-                data[
-                    f"{idx}-compoundcytotoxicityresult_set-cytotox-results-MIN_NUM_FORMS"
-                ] = 0
-                data[
-                    f"{idx}-compoundcytotoxicityresult_set-cytotox-results-MAX_NUM_FORMS"
-                ] = 1000
-                for nidx, result in enumerate(cytotox_test["compound_cytotox_results"]):
-                    data[
-                        f"{idx}-compoundcytotoxicityresult_set-cytotox-results-{nidx}-compound_name"
-                    ] = result["compound_name"]
-                    data[
-                        f"{idx}-compoundcytotoxicityresult_set-cytotox-results-{nidx}-toxicity"
-                    ] = result["toxicity"]
-            return data
-
-        def get_pk_description_form():
-            data = {
-                "TOTAL_FORMS": len(entry_data.get("pharmacokinetic_tests", [])),
-                "INITIAL_FORMS": 0,
-                "MIN_NUM_FORMS": 1,
-                "MAX_NUM_FORMS": 1000,
-            }
-            for idx, pk_test in enumerate(entry_data.get("pharmacokinetic_tests", [])):
-                data[f"{idx}-test_name"] = pk_test["test_name"]
-                data[f"{idx}-organism"] = pk_test["organism"]
-                data[f"{idx}-administration_mode"] = pk_test["administration_mode"]
-                data[f"{idx}-concentration"] = pk_test["concentration"]
-                data[f"{idx}-dose"] = pk_test["dose"]
-                data[f"{idx}-dose_interval"] = pk_test["dose_interval"]
-                data[f"{idx}-cell_line_name"] = pk_test["cell_line_name"]
-
-                data[f"{idx}-compoundpkresult_set-pk-results-TOTAL_FORMS"] = len(
-                    pk_test.get("compound_pk_results", [])
-                )
-                data[f"{idx}-compoundpkresult_set-pk-results-INITIAL_FORMS"] = 0
-                data[f"{idx}-compoundpkresult_set-pk-results-MIN_NUM_FORMS"] = 0
-                data[f"{idx}-compoundpkresult_set-pk-results-MAX_NUM_FORMS"] = 1000
-                for nidx, result in enumerate(pk_test["compound_pk_results"]):
-                    data[
-                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-tolerated"
-                    ] = result["tolerated"]
-                    data[f"{idx}-compoundpkresult_set-pk-results-{nidx}-auc"] = result[
-                        "auc"
-                    ]
-                    data[
-                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-clearance"
-                    ] = result["clearance"]
-                    data[
-                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-c_max"
-                    ] = result["c_max"]
-                    data[
-                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-oral_bioavailability"
-                    ] = result["oral_bioavailability"]
-                    data[
-                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-t_demi"
-                    ] = result["t_demi"]
-                    data[
-                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-t_max"
-                    ] = result["t_max"]
-                    data[
-                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-voldistribution"
-                    ] = result["voldistribution"]
-                    data[
-                        f"{idx}-compoundpkresult_set-pk-results-{nidx}-compound_name"
-                    ] = result["compound_name"]
-            return data
-
-        form_callables = {
-            None: lambda: None,
-            "IdForm": get_id_form,
-            "BibliographyForm": get_bibliography_form,
-            "PDBForm": get_pdb_form,
-            "ProteinDomainComplexTypeForm": get_complex_type_form,
-            "ProteinDomainComplexForm": get_complex_form,
-            "PpiForm": get_ppi_form,
-            "CompoundForm": get_compound_form,
-            "ActivityDescriptionFormSet": get_activity_description_form,
-        }
-        if entry_data.get("cytotox", False):
-            form_callables[
-                "TestCytotoxDescriptionFormSet"
-            ] = get_cytotox_description_form
-        if entry_data.get("pharmacokinetic", False):
-            form_callables["TestPKDescriptionFormSet"] = get_pk_description_form
-
-        # Adding a step for "done" in order to also save the data in the db
-        form_callables["done"] = lambda: None
-
-        form_ids = iter(list(form_callables.keys())[1:])
-        for step in form_callables.keys():
-            item = {
-                "step-id": step,
-                "form-data": form_callables[step](),
-                "next": next(form_ids, None),
-            }
-            yield item
-
-    def get_basic_entry(self):
-        """
-        Basic entry test
-        """
-        return {
-            "source": "PM",
-            "id_source": "15072770",
-            "in_silico": True,
-            "pdb_id": "3u85",
-            "diseases": [";toto;1;", ";titi;sfddf;", ";tata;3232;"],
-            "complexChoice": "inhibited",
-            "complexType": "Inhib_Hetero2merAB",
-            "complex": [
-                {
-                    "uniprot_id": "O00255",
-                    "complex_type": "Partner",
-                    "domain_pfam_acc": "PF05053",
-                    "ppc_copy_nb": 1,
-                    "cc_nb": 1,
-                    "ppp_copy_nb_per_p": "",
-                },
-                {
-                    "uniprot_id": "Q03164",
-                    "complex_type": "Bound",
-                    "domain_pfam_acc": "PF05965",
-                    "ppc_copy_nb": 1,
-                    "cc_nb": 1,
-                    "ppp_copy_nb_per_p": 1,
-                },
-            ],
-            "family_name": "Menin (PF05053)",
-            "compounds": [
-                {
-                    "molecule_smiles": "CCC",
-                    "compound_name": "toto",
-                    "common_name": "super",
-                }
-            ],
-            "activity_tests": [
-                {
-                    "test_name": "test",
-                    "is_primary": True,
-                    "protein_bound_construct": "F",
-                    "test_type": "BIOCH",
-                    "test_modulation_type": "I",
-                    "nb_active_compounds": 2,
-                    "cell_line_name": "",
-                    # means first in the list above
-                    "protein_complex": "2",
-                    "compound_activity_results": [
-                        {
-                            "compound_name": "toto",
-                            "activity_type": "pIC50",
-                            "activity_mol": 6.85,
-                            "activity_unit": "1e-6",
-                            "modulation_type": "I",
-                        }
-                    ],
-                }
-            ],
-        }
-
-    def test_basic_entry(self):
-        self._process_contribution_wizard(self.get_basic_entry())
-
-    def get_entry_28(self):
-        """
-        Test for the "contribution #28"
-        """
-        return {
-            "source": "PM",
-            "id_source": "24512214 ",
-            "in_vitro": True,
-            "pdb_id": "3wn7",
-            "diseases": ["cancer (MONDO:0004992)"],
-            # FIXME continue here
-            "complexChoice": "inhibited",
-            "complexType": "Inhib_Hetero2merAB",
-            "complex": [
-                {
-                    "uniprot_id": "Q60795",
-                    "complex_type": "Partner",
-                    "domain_pfam_acc": None,
-                    "ppc_copy_nb": 1,
-                    "cc_nb": 1,
-                    "ppp_copy_nb_per_p": 1,
-                },
-                {
-                    "uniprot_id": "Q9Z2X8",
-                    "complex_type": "Bound",
-                    "domain_pfam_acc": "PF01344",
-                    "ppc_copy_nb": 1,
-                    "cc_nb": 1,
-                    "ppp_copy_nb_per_p": 1,
-                },
-            ],
-            "family_name": "KEAP1 / NRF2",
-            "compounds": [
-                {
-                    "molecule_smiles": "c1cc2c(cc1)c(ccc2N(S(=O)(=O)c1ccc(cc1)"
-                    "OC)CC(=O)O)N(S(=O)(=O)c1ccc(cc1)OC)CC(=O)O",
-                    "compound_name": "2",
-                    "common_name": "",
-                }
-            ],
-            "activity_tests": [
-                {
-                    "test_name": "biolayer interferometry assay",
-                    "is_primary": True,
-                    "protein_bound_construct": "F",
-                    "test_type": "BIOCH",
-                    "test_modulation_type": "B",
-                    "nb_active_compounds": 1,
-                    "cell_line_name": "",
-                    # means first in the list above
-                    "protein_complex": "1",
-                    "compound_activity_results": [
-                        {
-                            "compound_name": "2",
-                            "activity_type": "pKd",
-                            "activity_mol": 3.59,
-                            "activity_unit": "1e-9",
-                            "modulation_type": "I",
-                        }
-                    ],
-                }
-            ],
-        }
-
-    def test_entry_28(self):
-        self._process_contribution_wizard(self.get_entry_28())
-
-    def _test_activity_computation_and_storage(self, value, power):
-        basic_entry = self.get_basic_entry()
-        for activity_tests in basic_entry["activity_tests"]:
-            for compound_activity_results in activity_tests[
-                "compound_activity_results"
-            ]:
-                compound_activity_results["activity_unit"] = "1e-%i" % power
-                try:
-                    del compound_activity_results["activity"]
-                except KeyError:
-                    pass
-        self._process_contribution_wizard(basic_entry)
-        bibliography = models.Bibliography.objects.get(
-            id_source=basic_entry["id_source"]
-        )
-        for activity_tests in basic_entry["activity_tests"]:
-            for compound_activity_results in activity_tests[
-                "compound_activity_results"
-            ]:
-                compound = models.RefCompoundBiblio.objects.get(
-                    bibliography=bibliography,
-                    compound_name=compound_activity_results["compound_name"],
-                ).compound
-                results = models.CompoundActivityResult.objects.get(
-                    activity_type=compound_activity_results["activity_type"],
-                    modulation_type=compound_activity_results["modulation_type"],
-                    compound=compound,
-                )
-                self.assertEqual(
-                    Decimal(compound_activity_results["activity"]).quantize(
-                        Decimal(10)
-                        ** -models.CompoundActivityResult._meta.get_field(
-                            "activity"
-                        ).decimal_places
-                    ),
-                    results.activity,
-                )
-
-    def test_activity_computation_and_storage(self):
-        self._test_activity_computation_and_storage(0.685, 0)
-
-    def test_activity_computation_and_storage_3(self):
-        self._test_activity_computation_and_storage(639.39406, 3)
-
-    def test_activity_computation_and_storage_6(self):
-        self._test_activity_computation_and_storage(4.9846073, 6)
-
-    def test_activity_computation_and_storage_9(self):
-        self._test_activity_computation_and_storage(2380.002, 9)
-
-    def test_activity_computation_and_storage_12(self):
-        self._test_activity_computation_and_storage(1.018, 12)
-
-    def test_no_activity_fails(self):
-        basic_entry = self.get_basic_entry()
-        for activity_tests in basic_entry["activity_tests"]:
-            for compound_activity_results in activity_tests[
-                "compound_activity_results"
-            ]:
-                compound_activity_results["activity_unit"] = ""
-                compound_activity_results["activity_mol"] = ""
-                try:
-                    del compound_activity_results["activity"]
-                except KeyError:
-                    pass
-        self._process_contribution_wizard_without_sanity_check(
-            basic_entry, error_expected_in_step="ActivityDescriptionFormSet",
-        )
-
-    def test_with_all_tests(self):
-        """
-        Basic entry test
-        """
-        entry_data = {
-            "source": "PM",
-            "id_source": "15072770",
-            "in_silico": True,
-            "pharmacokinetic": True,
-            "cytotox": True,
-            "pdb_id": "3u85",
-            "diseases": ["", ";titi;sfddf;", ";tutu;5456456;", ";tutu;5456456;", ""],
-            "complexChoice": "inhibited",
-            "complexType": "Inhib_Hetero2merAB",
-            "complex": [
-                {
-                    "uniprot_id": "O00255",
-                    "complex_type": "Partner",
-                    "domain_pfam_acc": "PF05053",
-                    "ppc_copy_nb": 1,
-                    "cc_nb": 1,
-                    "ppp_copy_nb_per_p": "",
-                },
-                {
-                    "uniprot_id": "Q03164",
-                    "complex_type": "Bound",
-                    "domain_pfam_acc": "PF05965",
-                    "ppc_copy_nb": 1,
-                    "cc_nb": 1,
-                    "ppp_copy_nb_per_p": 1,
-                },
-            ],
-            "family_name": "Menin (PF05053)",
-            "compounds": [
-                {
-                    "molecule_smiles": "CCC",
-                    "compound_name": "toto",
-                    "common_name": "super",
-                }
-            ],
-            "activity_tests": [
-                {
-                    "test_name": "test",
-                    "is_primary": True,
-                    "protein_bound_construct": "F",
-                    "test_type": "BIOCH",
-                    "test_modulation_type": "I",
-                    "nb_active_compounds": 2,
-                    "cell_line_name": "",
-                    # means first in the list above
-                    "protein_complex": "2",
-                    "compound_activity_results": [
-                        {
-                            "compound_name": "toto",
-                            "activity_type": "pIC50",
-                            "activity_mol": 6.85,
-                            "activity_unit": "1e-3",
-                            "modulation_type": "I",
-                        }
-                    ],
-                }
-            ],
-            "pharmacokinetic_tests": [
-                {
-                    "test_name": "test",
-                    "organism": 1,
-                    "administration_mode": "IV",
-                    "concentration": "1.2",
-                    "dose": "3.5",
-                    "dose_interval": "2",
-                    "cell_line_name": "1043SK ",
-                    # means position in the list above
-                    "compound_pk_results": [
-                        {
-                            "tolerated": "True",
-                            "auc": "1",
-                            "clearance": "1.2",
-                            "c_max": "5.2",
-                            "oral_bioavailability": "22",
-                            "t_demi": "5",
-                            "t_max": "5",
-                            "voldistribution": "5.5",
-                            "compound_name": "toto",
-                        }
-                    ],
-                }
-            ],
-            "cytotox_tests": [
-                {
-                    "test_name": "test",
-                    "compound_concentration": "2",
-                    "cell_line_name": "1043SK ",
-                    "compound_cytotox_results": [
-                        {"compound_name": "toto", "toxicity": "True"}
-                    ],
-                }
-            ],
-        }
-        self._process_contribution_wizard(entry_data)
-        input_diseases = set(entry_data["diseases"])
-        input_diseases.remove("")
-        self.assertSetEqual(
-            input_diseases,
-            set(
-                models.Ppi.objects.get(pdb_id=entry_data["pdb_id"])
-                .diseases.annotate(
-                    raw=Concat(Value(";"), "name", Value(";"), "identifier", Value(";"))
-                )
-                .values_list("raw", flat=True)
-            ),
-        )
-
-    def test_complex_no_pfam(self):
-        """
-        Test that it is possible to not select any domain
-        for one of the partners in the complex
-        (see #77)
-        """
-        entry_data = {
-            "source": "PM",
-            "id_source": "26958703",
-            "in_silico": True,
-            "pdb_id": "3emh",
-            "diseases": [],
-            "complexChoice": "inhibited",
-            "complexType": "Inhib_Hetero2merAB",
-            "complex": [
-                {
-                    "uniprot_id": "P61964",
-                    "complex_type": "Bound",
-                    "domain_pfam_acc": "PF00400",
-                    "ppc_copy_nb": 1,
-                    "cc_nb": 1,
-                    "ppp_copy_nb_per_p": 1,
-                },
-                {
-                    "uniprot_id": "Q03164",
-                    "complex_type": "Partner",
-                    "domain_pfam_acc": None,
-                    "ppc_copy_nb": 1,
-                    "cc_nb": 1,
-                    "ppp_copy_nb_per_p": "",
-                },
-            ],
-            "family_name": "WD40 (PF00400)",
-            "compounds": [
-                {
-                    "molecule_smiles": "C",
-                    "compound_name": "toto",
-                    "common_name": "super",
-                }
-            ],
-            "activity_tests": [
-                {
-                    "test_name": "test",
-                    "is_primary": True,
-                    "protein_bound_construct": "F",
-                    "test_type": "BIOCH",
-                    "test_modulation_type": "I",
-                    "nb_active_compounds": 2,
-                    "cell_line_name": "",
-                    # means position in the list above
-                    "protein_complex": "1",
-                    "compound_activity_results": [
-                        {
-                            "compound_name": "toto",
-                            "activity_type": "pIC50",
-                            "activity_mol": 6.85,
-                            "activity_unit": "1e-3",
-                            "modulation_type": "I",
-                        }
-                    ],
-                }
-            ],
-        }
-        self._process_contribution_wizard(entry_data)
-
-    def test_simple_heterodimer(self):
-        """
-        Test provided for simple heterodimer
-        """
-        entry_data = {
-            "source": "PM",
-            "id_source": "26958703",
-            "in_silico": True,
-            "in_vitro": True,
-            "xray": True,
-            "pdb_id": "3emh",
-            "diseases": [],
-            "complexChoice": "inhibited",
-            "complexType": "Inhib_Hetero2merAB",
-            "complex": [
-                {
-                    "uniprot_id": "P61964",
-                    "complex_type": "Bound",
-                    "domain_pfam_acc": "PF00400",
-                    "ppc_copy_nb": 1,
-                    "cc_nb": 1,
-                    "ppp_copy_nb_per_p": 1,
-                },
-                {
-                    "uniprot_id": "Q03164",
-                    "complex_type": "Partner",
-                    "domain_pfam_acc": None,
-                    "ppc_copy_nb": 1,
-                    "cc_nb": 1,
-                    "ppp_copy_nb_per_p": "",
-                },
-            ],
-            "family_name": "WD40 (PF00400)",
-            "compounds": [
-                {
-                    "molecule_smiles": r"CN1CCN(CC1)C1=C(C=C(C=C1)C1=CC(=CC=C1)"
-                    r"CN1CCOCC1)NC(=O)C1=CNC(C=C1C(F)(F)F)=O",
-                    "compound_name": "16d",
-                    "common_name": "officially 16d",
-                    "ligand_id": "ABC",
-                },
-                {
-                    "molecule_iupac": "N-(2-(4-Methylpiperazin-1-yl)-5-nitrophenyl)"
-                    "-6-oxo-4-(trifluoromethyl)-1,6-dihydropyridine-3-carboxamide",
-                    "compound_name": "14a",
-                    "common_name": "officially 14a",
-                    "is_macrocycle": False,
-                },
-                {
-                    "molecule_smiles": "C1CCC1",
-                    "compound_name": "lili",
-                    "common_name": "stuck",
-                    "is_macrocycle": False,
-                },
-            ],
-            "activity_tests": [
-                {
-                    "test_name": "test",
-                    "is_primary": True,
-                    "protein_bound_construct": "F",
-                    "test_type": "BIOCH",
-                    "test_modulation_type": "I",
-                    "nb_active_compounds": 2,
-                    "cell_line_name": "",
-                    # means position in the list above
-                    "protein_complex": "1",
-                    "compound_activity_results": [
-                        {
-                            "compound_name": "16d",
-                            "activity_type": "pIC50",
-                            "activity_mol": 6.85,
-                            "activity_unit": "1e-3",
-                            "modulation_type": "I",
-                        }
-                    ],
-                }
-            ],
-        }
-
-        self._process_contribution_wizard(entry_data)
-
-    def test_simple_stabilized_heterodimer(self):
-        """
-        Test provided for stabilized heterodimer
-        """
-        entry_data = {
-            "source": "PM",
-            "id_source": "23676274",
-            "in_cellulo": True,
-            "in_vitro": True,
-            "xray": True,
-            "pdb_id": "4jc3",
-            "complexChoice": "stabilized",
-            "complexType": "Stab_Hetero2merAB",
-            "complex": [
-                {
-                    "uniprot_id": "P31947",
-                    "complex_type": "Bound",
-                    "domain_pfam_acc": "PF00244",
-                    "ppc_copy_nb": 1,
-                    "cc_nb": 1,
-                    "ppp_copy_nb_per_p": 1,
-                },
-                {
-                    "uniprot_id": "P03372",
-                    "complex_type": "Bound",
-                    "domain_pfam_acc": None,
-                    "ppc_copy_nb": 1,
-                    "cc_nb": 1,
-                    "ppp_copy_nb_per_p": 1,
-                },
-            ],
-            "family_name": "14-3-3 / ER",
-            "diseases": ["breast cancer, MONDO:0007254"],
-            "compounds": [
-                {
-                    "molecule_smiles": r"COC[C@H]1CC[C@H]2[C@@H](C)[C@@H](O)"
-                    r"[C@H](O[C@H]3O[C@H](COC(C)(C)C=C)[C@@H](O)[C@H](OC(C)=O)"
-                    r"[C@H]3O)C3=C(C[C@H](O)[C@]3(C)\C=C1/2)[C@H](C)COC(C)=O",
-                    "compound_name": "FC",
-                    "common_name": "fusicoccin",
-                    "ligand_id": "FSC",
-                }
-            ],
-            "activity_tests": [
-                {
-                    "test_name": "pull down",
-                    "is_primary": True,
-                    "protein_bound_construct": "F",
-                    "test_type": "BIOCH",
-                    "test_modulation_type": "I",
-                    "nb_active_compounds": 2,
-                    "cell_line_name": "",
-                    # means position in the list above
-                    "protein_complex": "1",
-                    "compound_activity_results": [
-                        {
-                            "compound_name": "FC",
-                            "activity_type": "pIC50",
-                            "activity_mol": 0.25,
-                            "activity_unit": "1e-6",
-                            "modulation_type": "S",
-                        }
-                    ],
-                }
-            ],
-        }
-
-        self._process_contribution_wizard(entry_data)
-
-    def write_in_tmp_file(self, response):
-        """
-        Write an HTTP response contents into a temporary file for debug
-
-        :param response: response that will be saved
-        :type respons: django.http.HttpResponse
-        :return: path to the temporary file created
-        :rtype: str
-        """
-        with NamedTemporaryFile(delete=False, suffix=".html") as f:
-            f.write(response.content)
-            return f.name
diff --git a/ippisite/ippidb/urls.py b/ippisite/ippidb/urls.py
index ce56492d0ea9c17baaaf44322c6278b2ea0099b9..7946abc0b36c64aa1ae44a16b3b06f18a23ecf25 100644
--- a/ippisite/ippidb/urls.py
+++ b/ippisite/ippidb/urls.py
@@ -64,9 +64,7 @@ urlpatterns = [
     url(r"^utils/smi2mol$", views.convert_smi2mol, name="smi2mol"),
     url(r"^utils/smi2iupac$", views.convert_smi2iupac, name="smi2iupac"),
     url(r"^utils/iupac2smi$", views.convert_iupac2smi, name="iupac2smi"),
-    url(r"^credits", views.credits, name="credits"),
-    url(r"^citation", views.citation, name="citation"),
-    url(r"^news", views.news, name="news"),
+    url(r"^utils/getoutputjob$", views.get_output_job, name="getoutputjob"),
 ]
 
 if settings.DEBUG:
diff --git a/ippisite/ippidb/views/__init__.py b/ippisite/ippidb/views/__init__.py
index dd6b454cb1e886bf07b3fb292b39409d15261f7f..af0cb27ee9b02c64288941267454a1f27054cc35 100644
--- a/ippisite/ippidb/views/__init__.py
+++ b/ippisite/ippidb/views/__init__.py
@@ -21,6 +21,7 @@ from .compound_query import (
     convert_smi2iupac,
     convert_iupac2smi,
 )
+from .tasks import get_output_job
 from .about import (
     about_general,
     about_le_lle,
@@ -43,18 +44,6 @@ def tutorials(request):
     return render(request, "tutorials.html")
 
 
-def credits(request):
-    return render(request, "credits.html")
-
-
-def citation(request):
-    return render(request, "citation.html")
-
-
-def news(request):
-    return render(request, "news.html")
-
-
 @login_required
 def adminSession(request):
     return render(request, "admin-session.html")
@@ -68,6 +57,14 @@ def marvinjs(request):
     return {"marvinjs_apikey": settings.MARVINJS_APIKEY}
 
 
+def google_analytics(request):
+    if hasattr(settings, "GA_CODE"):
+        ga_code = settings.GA_CODE
+    else:
+        ga_code = None
+    return {"gacode": ga_code}
+
+
 __all__ = [
     ippidb_wizard_view,
     admin_session_view,
@@ -89,4 +86,5 @@ __all__ = [
     about_pca,
     about_pharmacology,
     about_physicochemistry,
+    get_output_job,
 ]
diff --git a/ippisite/ippidb/views/about.py b/ippisite/ippidb/views/about.py
index 6833ee73bdf10931d474022acaa39ef33917e01c..726f1e0a1f068bae1e817dea77e49ba4cdf2d1ad 100644
--- a/ippisite/ippidb/views/about.py
+++ b/ippisite/ippidb/views/about.py
@@ -149,7 +149,10 @@ def about_pharmacology(request):
 
 
 def about_le_lle(request):
-    context = {"le_lle_biplot_data": LeLleBiplotData.objects.get().le_lle_biplot_data}
+    try:
+        context = {"le_lle_biplot_data": LeLleBiplotData.objects.get().le_lle_biplot_data}
+    except LeLleBiplotData.DoesNotExist:
+        context = {}
     return render(request, "about-le-lle.html", context=context)
 
 
@@ -249,7 +252,10 @@ def about_physicochemistry(request):
 
 
 def about_pca(request):
-    context = {"pca_biplot_data": PcaBiplotData.objects.get().pca_biplot_data}
-    pca_biplot = json.loads(PcaBiplotData.objects.get().pca_biplot_data)
-    context["pca_biplot_cc"] = pca_biplot["correlation_circle"]
+    try:
+        context = {"pca_biplot_data": PcaBiplotData.objects.get().pca_biplot_data}
+        pca_biplot = json.loads(PcaBiplotData.objects.get().pca_biplot_data)
+        context["pca_biplot_cc"] = pca_biplot["correlation_circle"]
+    except PcaBiplotData.DoesNotExist:
+        context = {}
     return render(request, "about-pca.html", context=context)
diff --git a/ippisite/ippidb/views/compound_query.py b/ippisite/ippidb/views/compound_query.py
index d5deb70bfb7819c285e64260b909517f2026aff3..1ea45e79385133f29f6f91ba51a59c955de18e19 100644
--- a/ippisite/ippidb/views/compound_query.py
+++ b/ippisite/ippidb/views/compound_query.py
@@ -463,6 +463,7 @@ class CompoundListView(ListView):
         "lle",
         "best_activity",
         "tests_av",
+        "families",
     ]
 
     def get(self, request, *args, **kwargs):
@@ -502,6 +503,8 @@ class CompoundListView(ListView):
         for sort_by_option_id in self.sort_by_option_ids:
             if sort_by_option_id == "pubs":
                 name = "Number of publications"
+            elif sort_by_option_id == "families":
+                name = "PPI families"
             elif sort_by_option_id == "le":
                 name = "Ligand Efficiency"
             elif sort_by_option_id == "lle":
diff --git a/ippisite/ippidb/views/tasks.py b/ippisite/ippidb/views/tasks.py
new file mode 100644
index 0000000000000000000000000000000000000000..676851c8d37e8250ca64d912e20a473c47be9305
--- /dev/null
+++ b/ippisite/ippidb/views/tasks.py
@@ -0,0 +1,27 @@
+from django.http import JsonResponse, HttpResponseBadRequest
+from ippidb.models import Job
+
+
+def get_output_job(request):
+    """
+    get a task information
+    """
+
+    task_id = request.GET.get("task_id")
+    if task_id in [None, ""]:
+        return HttpResponseBadRequest("task_id parameter is mandatory")
+    try:
+        job = Job.objects.get(task_result__task_id=task_id)
+        resp = {
+            "task_name": job.task_result.task_name,
+            "task_id": job.task_result.task_id,
+            "status": job.task_result.status,
+            "create_time": job.task_result.date_created,
+            "complete_time": job.task_result.date_done,
+            "std_out": job.std_out,
+            "std_err": job.std_err,
+            "traceback": job.task_result.traceback,
+        }
+        return JsonResponse(resp)
+    except Job.DoesNotExist:
+        return HttpResponseBadRequest("task with id {} doesn't exist".format(task_id))
diff --git a/ippisite/ippidb/ws.py b/ippisite/ippidb/ws.py
index 10332f73938de1dc73d07aefa7eadedef2fcdf6d..190c4ef13c30151ffb7e1f11fc3a67570ede063c 100644
--- a/ippisite/ippidb/ws.py
+++ b/ippisite/ippidb/ws.py
@@ -77,85 +77,6 @@ def get_pubmed_info(pmid):
     }
 
 
-def get_epo_info(patent_number):
-    """
-    Retrieve information about a patent using the EPO website
-
-    WARNING: this is not to be used anymore, the 3.1 version of the EPO service is now
-    offline
-
-    :param patent_number: patent number
-    :type patent_number: str
-    :return: patent metadata (title, journal name, publication year, authors list).
-    :rtype: dict
-    """
-    resp = requests.get(
-        f"http://ops.epo.org/3.1/rest-services/published-data/publication/docdb/"
-        f"{patent_number}/biblio.json"
-    )
-    data = resp.json()
-    exchange_doc = data["ops:world-patent-data"]["exchange-documents"][
-        "exchange-document"
-    ]
-    if isinstance(exchange_doc, list):
-        exchange_doc = exchange_doc[0]
-    title = [
-        el["$"]
-        for el in exchange_doc["bibliographic-data"]["invention-title"]
-        if el["@lang"] == "en"
-    ][0]
-    authors = [
-        i["inventor-name"]["name"]["$"]
-        for i in exchange_doc["bibliographic-data"]["parties"]["inventors"]["inventor"]
-        if i["@data-format"] == "original"
-    ][0]
-    biblio_year = [
-        el["date"]["$"][:4]
-        for el in exchange_doc["bibliographic-data"]["publication-reference"][
-            "document-id"
-        ]
-        if el["@document-id-type"] == "epodoc"
-    ][0]
-    return {
-        "title": title,
-        "journal_name": None,
-        "biblio_year": biblio_year,
-        "authors_list": authors,
-    }
-
-
-def get_google_patent_info_ris(patent_number):
-    """
-    Retrieve information about a patent using the Google RIS web service
-
-    WARNING: this is not to be used anymore, this Google Web service is now offline
-
-    :param patent_number: patent number
-    :type patent_number: str
-    :return: patent metadata (title, journal name, publication year, authors list).
-    :rtype: dict
-    """
-    url = "https://encrypted.google.com/patents/{}.ris".format(patent_number)
-    resp = requests.get(url)
-    title = None
-    authors = []
-    biblio_year = None
-    for line_str in resp.text.split("\n"):
-        line = line_str.strip().split("  - ")
-        if line[0] == "A1":
-            authors.append(line[1])
-        elif line[0] == "T1":
-            title = line[1]
-        elif line[0] == "Y1":
-            biblio_year = line[1].split("/")[0]
-    return {
-        "title": title,
-        "journal_name": None,
-        "biblio_year": biblio_year,
-        "authors_list": authors,
-    }
-
-
 def get_google_patent_info(patent_number):
     """
     Retrieve information about a patent parsing Dublin Core info in the Google HTML
diff --git a/ippisite/ippisite/admin.py b/ippisite/ippisite/admin.py
index 0cc216a8a5d375264d1a95b49c0f9da338ab7948..da708f9c9a83053515d11934051eed00e98f83ec 100644
--- a/ippisite/ippisite/admin.py
+++ b/ippisite/ippisite/admin.py
@@ -2,11 +2,11 @@ from django.contrib import admin
 from django.urls import path
 from django.shortcuts import redirect
 from django.contrib import messages
-
 from ippidb.tasks import (
-    launch_compound_properties_caching,
-    launch_drugbank_similarity_computing,
+    launch_update_compound_cached_properties,
+    run_compute_drugbank_similarity,
     launch_plots_computing,
+    launch_test_command_caching,
 )
 
 
@@ -35,15 +35,25 @@ class IppidbAdmin(admin.AdminSite):
                 "launch_plots_computing/",
                 self.admin_view(self.launch_plots_computing_view),
             ),
+            path("launch_test_command/", self.admin_view(self.launch_test_command),),
         ]
         return my_urls + urls
 
+    def launch_test_command(self, request):
+        """
+        This view launches the task to test jobs
+        """
+        task = launch_test_command_caching.delay()
+        print(task.state)
+        messages.add_message(request, messages.INFO, "Test job launched")
+        return redirect("/admin/")
+
     def launch_compound_properties_caching_view(self, request):
         """
         This view launches the task to perform, for all already validated compounds,
         the caching of the properties.
         """
-        launch_compound_properties_caching.delay()
+        launch_update_compound_cached_properties()
         messages.add_message(
             request, messages.INFO, "Compound properties caching launched"
         )
@@ -54,7 +64,7 @@ class IppidbAdmin(admin.AdminSite):
         This view launches the task to perform, for all already validated compounds,
         the computing of drugbank similarity.
         """
-        launch_drugbank_similarity_computing.delay()
+        run_compute_drugbank_similarity.delay()
         messages.add_message(
             request, messages.INFO, "DrugBank similarity computing launched"
         )
@@ -64,6 +74,6 @@ class IppidbAdmin(admin.AdminSite):
         """
         This view launches the task to perform the computing of LE-LLE and PCA plots.
         """
-        launch_plots_computing.delay()
+        launch_plots_computing()
         messages.add_message(request, messages.INFO, "Plots computing launched")
         return redirect("/admin/")
diff --git a/ippisite/ippisite/decorator.py b/ippisite/ippisite/decorator.py
new file mode 100644
index 0000000000000000000000000000000000000000..a66e1d0017e27fc177d0766a792dea92491b9f79
--- /dev/null
+++ b/ippisite/ippisite/decorator.py
@@ -0,0 +1,59 @@
+import traceback
+
+from celery import Task, states
+from django_celery_results.models import TaskResult
+
+from ippidb.models import Job
+
+
+class AlreadyExistError(Exception):
+    pass
+
+
+class MonitorTask(Task):
+    """
+    Ippidb custom task
+    """
+
+    def __call__(self, *args, **kwargs):
+        """In celery task this function call the run method, here you can
+        set some environment variable before the run of the task"""
+
+        tasks = TaskResult.objects.filter(
+            task_name=self.request.task,
+            status__in=[states.STARTED, states.PENDING, states.RETRY, states.RECEIVED],
+        )
+        count_tasks = tasks.count()
+        if not count_tasks:
+            self.update_state(state=states.PENDING)
+            return self.run(*args, **kwargs)
+        else:
+            message_exc = "Job {} in state {} already exist".format(
+                tasks[0].task_name, tasks[0].status
+            )
+            raise AlreadyExistError(message_exc)
+
+    def write(self, std_out=None, std_err=None):
+        job = Job.objects.get(task_result__task_id=self.task_id)
+        if std_out:
+            job.update_output(std_out, output="std_out")
+        elif std_err:
+            job.update_output(std_err, output="std_err")
+
+    def on_success(self, retval, task_id, args, kwargs):
+        self.write(std_out="SUCCESS")
+        super(MonitorTask, self).on_success(retval, task_id, args, kwargs)
+
+    def on_failure(self, exc, task_id, args, kwargs, einfo):
+        self.write(std_err="".join(traceback.format_tb(einfo.tb)))
+        super(MonitorTask, self).on_failure(exc, task_id, args, kwargs, einfo)
+
+    def update_state(self, task_id=None, state=None, meta=None, **kwargs):
+        self.state = state
+        if task_id is None:
+            self.task_id = self.request.id
+        else:
+            self.task_id = task_id
+        super(MonitorTask, self).update_state(
+            task_id=task_id, state=state, meta=meta, **kwargs
+        )
diff --git a/ippisite/ippisite/settings.py b/ippisite/ippisite/settings.py
index e6d355db3a13129f94d7edf4fcf63256031ac491..4756dbcbf554c7a04bce04ed64763e5ea5671574 100644
--- a/ippisite/ippisite/settings.py
+++ b/ippisite/ippisite/settings.py
@@ -43,6 +43,7 @@ INSTALLED_APPS = [
     "django.contrib.messages",
     "django.contrib.staticfiles",
     "django_extensions",
+    "django_celery_results",
     "crispy_forms",
     "live_settings",
     "ippidb",
@@ -84,6 +85,7 @@ TEMPLATES = [
                 "django.contrib.auth.context_processors.auth",
                 "django.contrib.messages.context_processors.messages",
                 "ippidb.views.marvinjs",
+                "ippidb.views.google_analytics",
                 "live_settings.context_processors.processors",
             ]
         },
@@ -182,3 +184,8 @@ MARVINJS_APIKEY = None
 GALAXY_BASE_URL = None
 GALAXY_APIKEY = None
 GALAXY_COMPOUNDPROPERTIES_WORKFLOWID = None
+
+# celery setting.
+CELERY_RESULT_BACKEND = "django-db"
+
+GA_CODE = None
diff --git a/ippisite/ippisite/settings.template.py b/ippisite/ippisite/settings.template.py
index b96e3bde7238b8f9593c22f25dc7904dea7f7b5d..f971d198766ae090eaa5165b86ed3cbde0808f02 100644
--- a/ippisite/ippisite/settings.template.py
+++ b/ippisite/ippisite/settings.template.py
@@ -45,6 +45,7 @@ INSTALLED_APPS = [
     "django.contrib.messages",
     "django.contrib.staticfiles",
     "django_extensions",
+    "django_celery_results",
     "crispy_forms",
     "live_settings",
     "ippidb",
@@ -161,3 +162,6 @@ LOGOUT_REDIRECT_URL = "/"
 # django-crispy-forms
 ################################################################################
 CRISPY_TEMPLATE_PACK = "bootstrap4"
+
+# celery setting.
+CELERY_RESULT_BACKEND = 'django-db'
diff --git a/ippisite/requirements-core.txt b/ippisite/requirements-core.txt
index d383d7ab590fd4fdf11a62ce8cd9a6cca9432eba..c071c907d2a8828c52aff66ae856f6b7f7958f13 100644
--- a/ippisite/requirements-core.txt
+++ b/ippisite/requirements-core.txt
@@ -6,6 +6,7 @@ django-extensions
 django-formtools
 django-debug-toolbar
 django-allauth
+django-celery-results==1.2.1
 # import scripts
 pandas==0.25.0
 openpyxl
diff --git a/ippisite/requirements-dev.txt b/ippisite/requirements-dev.txt
index c3cf6a52096ef6795aefab61a80765e7ed4e25b1..c8aa1e20adb53df4bf0f96e67253ded5ce33ce92 100644
--- a/ippisite/requirements-dev.txt
+++ b/ippisite/requirements-dev.txt
@@ -10,6 +10,7 @@ sphinx_rtd_theme
 coverage
 # tests
 requests-cache
+parameterized
 # dependencies to generate graph models using django-extensions
 pygraphviz
 pydot
diff --git a/ippisite/templates/admin/index.html b/ippisite/templates/admin/index.html
index 212e9632b58eaaa9a3c089fa4615f9d2924ba30e..d147b65995fe69eebececa685c1c9d0e2edd2adc 100644
--- a/ippisite/templates/admin/index.html
+++ b/ippisite/templates/admin/index.html
@@ -59,6 +59,12 @@
             <input type="submit" value="Plots generation" name="_save"/>
         </form>
         <hr/>
+        <form method="POST" action="/admin/launch_test_command/"
+              style="display:block">{% csrf_token %}
+            <input type="submit" value="test command" name="_save"/>
+        </form>
+        <hr/>
+        
     </div>