diff --git a/.gitignore b/.gitignore index 6d1dee062..a2e2b37c7 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ nosetests.xml coverage.xml *.cover .hypothesis/ +.pytest_cache/ # Translations *.mo diff --git a/weblab/conftest.py b/weblab/conftest.py index 77197e12e..0cff5a104 100644 --- a/weblab/conftest.py +++ b/weblab/conftest.py @@ -42,6 +42,13 @@ def add_version(entity, populate_entity_cache(entity) return commit + @staticmethod + def cached_version(entity, **kwargs): + """Add a single commit/version to an entity and return the relevant repocache entry""" + assert kwargs.get('cache', True), "Cache must be true for cached version" + version = Helpers.add_version(entity, **kwargs) + return entity.repocache.get_version(version.sha) + @staticmethod def add_fake_version(entity, visibility='private', date=None, message='cache-only commit'): """Add a new commit/version only in the cache, not in git.""" @@ -140,6 +147,13 @@ def protocol_with_version(): return protocol +@pytest.fixture +def fittingspec_with_version(): + fittingspec = recipes.fittingspec.make() + Helpers.add_version(fittingspec, visibility='private') + return fittingspec + + @pytest.fixture def public_model(helpers): model = recipes.model.make() @@ -154,6 +168,19 @@ def public_protocol(helpers): return protocol +@pytest.fixture +def public_fittingspec(helpers): + fittingspec = recipes.fittingspec.make() + helpers.add_version(fittingspec, visibility='public') + return fittingspec + + +@pytest.fixture +def public_dataset(helpers): + dataset = recipes.dataset.make(visibility='public') + return dataset + + @pytest.fixture def moderated_model(helpers): model = recipes.model.make() @@ -331,3 +358,32 @@ def my_dataset_with_file(logged_in_user, helpers, public_protocol, client): ) yield dataset dataset.delete() + + +@pytest.fixture +def fittingresult_version(public_model, public_protocol, public_fittingspec, public_dataset): + return recipes.fittingresult_version.make( + status='SUCCESS', + fittingresult__model=public_model, + fittingresult__model_version=public_model.repocache.latest_version, + fittingresult__protocol=public_protocol, + fittingresult__protocol_version=public_protocol.repocache.latest_version, + fittingresult__fittingspec=public_fittingspec, + fittingresult__fittingspec_version=public_fittingspec.repocache.latest_version, + fittingresult__dataset=public_dataset, + ) + + +@pytest.fixture +def fittingresult_with_result(model_with_version, protocol_with_version): + version = recipes.fittingresult_version.make( + status='SUCCESS', + fittingresult__model=model_with_version, + fittingresult__model_version=model_with_version.repocache.latest_version, + fittingresult__protocol=protocol_with_version, + fittingresult__protocol_version=protocol_with_version.repocache.latest_version, + ) + version.mkdir() + with (version.abs_path / 'result.txt').open('w') as f: + f.write('fitting results') + return version diff --git a/weblab/core/recipes.py b/weblab/core/recipes.py index 65d75ca91..1b289b485 100644 --- a/weblab/core/recipes.py +++ b/weblab/core/recipes.py @@ -30,6 +30,10 @@ cached_protocol_version = Recipe('CachedProtocolVersion') cached_protocol_tag = Recipe('CachedProtocolTag') +cached_fittingspec = Recipe('CachedFittingSpec') +cached_fittingspec_version = Recipe('CachedFittingSpecVersion') +cached_fittingspec_tag = Recipe('CachedFittingSpecTag') + experiment = Recipe( 'Experiment', model=foreign_key(model), @@ -51,3 +55,16 @@ dataset_file = Recipe('DatasetFile', dataset=foreign_key(dataset)) + +fittingresult = Recipe( + 'FittingResult', + model=foreign_key(model), + model_version=foreign_key(cached_model_version), + protocol=foreign_key(protocol), + protocol_version=foreign_key(cached_protocol_version), + fittingspec=foreign_key(fittingspec), + fittingspec_version=foreign_key(cached_fittingspec_version), + dataset=foreign_key(dataset), +) + +fittingresult_version = Recipe('FittingResultVersion', fittingresult=foreign_key(fittingresult)) diff --git a/weblab/experiments/models.py b/weblab/experiments/models.py index fcd12f954..8c5735893 100644 --- a/weblab/experiments/models.py +++ b/weblab/experiments/models.py @@ -10,7 +10,75 @@ from repocache.models import CachedModelVersion, CachedProtocolVersion -class Experiment(UserCreatedModelMixin, models.Model): +class ExperimentMixin(models.Model): + """ + Model mixin for different types of experiment + + Models must have model, model_version, protocol and protocol_version fields + and be the parent of a Runnable-derived model. + """ + def __str__(self): + return self.name + + @property + def latest_version(self): + return self.versions.latest('created_at') + + @property + def nice_model_version(self): + """Use tags to give a nicer representation of the commit id""" + return self.model_version.nice_version() + + @property + def nice_protocol_version(self): + """Use tags to give a nicer representation of the commit id""" + return self.protocol_version.nice_version() + + @property + def latest_result(self): + try: + return self.latest_version.status + except Runnable.DoesNotExist: + return '' + + @property + def entities(self): + """Entity objects related to this experiment""" + return (self.model, self.protocol) + + def is_visible_to_user(self, user): + """ + Can the user view the experiment? + + :param user: user to test against + + :returns: True if the user is allowed to view the experiment, False otherwise + """ + return visibility_check(self.visibility, self.viewers, user) + + @property + def viewers(self): + """ + Get users which have special permissions to view this experiment. + + We take the intersection of users with special permissions to view each object + (model, fitting spec, etc) involved, if that object is private. If it's public, + we can ignore it because everyone can see it. + + :return: `set` of `User` objects + """ + viewers = [ + obj.viewers + for obj in self.entities + if obj.visibility == Visibility.PRIVATE + ] + return set.intersection(*viewers) if viewers else {} + + class Meta: + abstract = True + + +class Experiment(ExperimentMixin, UserCreatedModelMixin, models.Model): """A specific version of a protocol run on a specific version of a model This class essentially just stores the model & protocol links. The results are @@ -34,9 +102,6 @@ class Meta: ('create_experiment', 'Can create experiments'), ) - def __str__(self): - return self.name - @property def name(self): return self.get_name() @@ -60,55 +125,6 @@ def get_name(self, model_version=False, proto_version=False): def visibility(self): return get_joint_visibility(self.model_version.visibility, self.protocol_version.visibility) - @property - def viewers(self): - """ - Get users which have special permissions to view this experiment - - We do not handle the case where both model and protocol are public, - since this would make the experiment also public and therefore - visible to every user - so calling this method makes very little sense. - - :return: `set` of `User` objects - """ - if self.protocol.visibility != Visibility.PRIVATE: - return self.model.viewers - elif self.model.visibility != Visibility.PRIVATE: - return self.protocol.viewers - else: - return self.model.viewers & self.protocol.viewers - - def is_visible_to_user(self, user): - """ - Can the user view the experiment? - - :param user: user to test against - - :returns: True if the user is allowed to view the experiment, False otherwise - """ - return visibility_check(self.visibility, self.viewers, user) - - @property - def latest_version(self): - return self.versions.latest('created_at') - - @property - def nice_model_version(self): - """Use tags to give a nicer representation of the commit id""" - return self.model_version.nice_version() - - @property - def nice_protocol_version(self): - """Use tags to give a nicer representation of the commit id""" - return self.protocol_version.nice_version() - - @property - def latest_result(self): - try: - return self.latest_version.status - except Runnable.DoesNotExist: - return '' - class Runnable(UserCreatedModelMixin, FileCollectionMixin, models.Model): """ Runnable base class @@ -140,33 +156,38 @@ class Runnable(UserCreatedModelMixin, FileCollectionMixin, models.Model): ) return_text = models.TextField(blank=True) - def __str__(self): - return '%s at %s: (%s)' % (self.experiment, self.created_at, self.status) - class Meta: indexes = [ models.Index(fields=['created_at']) ] + def __str__(self): + return '%s at %s: (%s)' % (self.parent, self.created_at, self.status) + + @property + def parent(self): + """E.g. the Experiment this is a version of. Must be defined by subclasses.""" + raise NotImplementedError + @property def name(self): return '{:%Y-%m-%d %H:%M:%S}'.format(self.created_at) @property def run_number(self): - return self.experiment.versions.filter(created_at__lte=self.created_at).count() + return self.parent.versions.filter(created_at__lte=self.created_at).count() @property def is_latest(self): - return not self.experiment.versions.filter(created_at__gt=self.created_at).exists() + return not self.parent.versions.filter(created_at__gt=self.created_at).exists() @property def visibility(self): - return self.experiment.visibility + return self.parent.visibility @property def viewers(self): - return self.experiment.viewers + return self.parent.viewers @property def abs_path(self): diff --git a/weblab/experiments/processing.py b/weblab/experiments/processing.py index 40c8c62f9..3bf362941 100644 --- a/weblab/experiments/processing.py +++ b/weblab/experiments/processing.py @@ -44,6 +44,62 @@ class ProcessingException(Exception): pass +def submit_runnable(runnable, body, user): + """Submit a Celery task to the Chaste backend + + @param runnable Runnable object to submit + @param body dict of extra parameters to post to the request + @param user user making the request + """ + run = RunningExperiment.objects.create(runnable=runnable) + signature = runnable.signature + + body.update({ + 'signature': runnable.signature, + 'callBack': urljoin(settings.CALLBACK_BASE_URL, reverse('experiments:callback')), + 'user': user.full_name, + 'password': settings.CHASTE_PASSWORD, + 'isAdmin': user.is_staff, + }) + + try: + response = requests.post(settings.CHASTE_URL, body) + except requests.exceptions.ConnectionError: + run.delete() + runnable.status = Runnable.STATUS_FAILED + runnable.return_text = 'Unable to connect to experiment runner service' + runnable.save() + logger.exception(runnable.return_text) + return runnable, True + + res = response.content.decode().strip() + logger.debug('Response from chaste backend: %s' % res) + + if not res.startswith(signature): + run.delete() + runnable.status = Runnable.STATUS_FAILED + runnable.return_text = 'Chaste backend answered with something unexpected: %s' % res + runnable.save() + logger.error(runnable.return_text) + raise ProcessingException(res) + + status = res[len(signature):].strip() + + if status.startswith('succ'): + run.task_id = status[4:].strip() + run.save() + elif status == 'inapplicable': + run.delete() + runnable.status = Runnable.STATUS_INAPPLICABLE + else: + run.delete() + logger.error('Chaste backend answered with error: %s' % status) + runnable.status = Runnable.STATUS_FAILED + runnable.return_text = status + + runnable.save() + + def submit_experiment(model, model_version, protocol, protocol_version, user, rerun_ok): """Submit a Celery task to run an experiment. @@ -80,9 +136,6 @@ def submit_experiment(model, model_version, protocol, protocol_version, user, re author=user, ) - run = RunningExperiment.objects.create(runnable=version) - signature = version.signature - model_url = reverse( 'entities:entity_archive', args=['model', model.pk, model_version] @@ -94,52 +147,11 @@ def submit_experiment(model, model_version, protocol, protocol_version, user, re body = { 'model': urljoin(settings.CALLBACK_BASE_URL, model_url), 'protocol': urljoin(settings.CALLBACK_BASE_URL, protocol_url), - 'signature': signature, - 'callBack': urljoin(settings.CALLBACK_BASE_URL, reverse('experiments:callback')), - 'user': user.full_name, - 'password': settings.CHASTE_PASSWORD, - 'isAdmin': user.is_staff, } if protocol.is_fitting_spec: body['dataset'] = body['fittingSpec'] = body['protocol'] - try: - response = requests.post(settings.CHASTE_URL, body) - except requests.exceptions.ConnectionError: - run.delete() - version.status = Runnable.STATUS_FAILED - version.return_text = 'Unable to connect to experiment runner service' - version.save() - logger.exception(version.return_text) - return version, True - - res = response.content.decode().strip() - logger.debug('Response from chaste backend: %s' % res) - - if not res.startswith(signature): - run.delete() - version.status = Runnable.STATUS_FAILED - version.return_text = 'Chaste backend answered with something unexpected: %s' % res - version.save() - logger.error(version.return_text) - raise ProcessingException(res) - - status = res[len(signature):].strip() - - if status.startswith('succ'): - run.task_id = status[4:].strip() - run.save() - elif status == 'inapplicable': - run.delete() - version.status = Runnable.STATUS_INAPPLICABLE - else: - run.delete() - logger.error('Chaste backend answered with error: %s' % status) - version.status = Runnable.STATUS_FAILED - version.return_text = status - - version.save() - + submit_runnable(version, body, user) return version, True diff --git a/weblab/experiments/templatetags/experiments.py b/weblab/experiments/templatetags/experiments.py index a1ac968e3..95a9985ac 100644 --- a/weblab/experiments/templatetags/experiments.py +++ b/weblab/experiments/templatetags/experiments.py @@ -8,8 +8,8 @@ register = template.Library() -@register.filter -def url_experiment_comparison_json(experiment_versions): +@register.simple_tag(takes_context=True) +def url_experiment_comparison_json(context, experiment_versions): """ Build URL for experiment comparison json """ @@ -17,7 +17,8 @@ def url_experiment_comparison_json(experiment_versions): version_ids = '/' + '/'.join(str(ver.id) for ver in experiment_versions) else: version_ids = '' - return reverse('experiments:compare_json', args=[version_ids]) + ns = context['current_namespace'] + return reverse(ns + ':compare_json', args=[version_ids]) @register.simple_tag diff --git a/weblab/experiments/tests/test_templatetags.py b/weblab/experiments/tests/test_templatetags.py index 4412cd83f..5a420d08d 100644 --- a/weblab/experiments/tests/test_templatetags.py +++ b/weblab/experiments/tests/test_templatetags.py @@ -10,9 +10,10 @@ def test_url_experiment_comparison_json(): versions = recipes.experiment_version.make(_quantity=3) compare_url = '/experiments/compare/%d/%d/%d/info' % tuple(ver.id for ver in versions) - assert exp_tags.url_experiment_comparison_json(versions) == compare_url + context = {'current_namespace': 'experiments'} + assert exp_tags.url_experiment_comparison_json(context, versions) == compare_url - assert exp_tags.url_experiment_comparison_json([]) == '/experiments/compare/info' + assert exp_tags.url_experiment_comparison_json(context, []) == '/experiments/compare/info' @pytest.mark.django_db diff --git a/weblab/fitting/migrations/0002_auto_20200821_1339.py b/weblab/fitting/migrations/0002_auto_20200821_1339.py new file mode 100644 index 000000000..bc850310e --- /dev/null +++ b/weblab/fitting/migrations/0002_auto_20200821_1339.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2020-08-21 13:39 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('repocache', '0021_auto_20200116_0913'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('entities', '0015_auto_20191128_1601'), + ('datasets', '0005_auto_20190628_1253'), + ('experiments', '0032_auto_20200317_0927'), + ('fitting', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='FittingResult', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('dataset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fitting_results', to='datasets.Dataset')), + ('fittingspec', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fitting_results', to='fitting.FittingSpec')), + ('fittingspec_version', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='fit_ver_fitres', to='repocache.CachedFittingSpecVersion')), + ('model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='model_fitting_results', to='entities.ModelEntity')), + ('model_version', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='model_ver_fitres', to='repocache.CachedModelVersion')), + ('protocol', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='protocol_fitting_results', to='entities.ProtocolEntity')), + ('protocol_version', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='pro_ver_fitres', to='repocache.CachedProtocolVersion')), + ], + options={ + 'permissions': (('run_fits', 'Can run parameter fitting experiments'),), + }, + ), + migrations.CreateModel( + name='FittingResultVersion', + fields=[ + ('runnable_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='experiments.Runnable')), + ('fittingresult', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='fitting.FittingResult')), + ], + options={ + 'abstract': False, + }, + bases=('experiments.runnable',), + ), + migrations.AlterUniqueTogether( + name='fittingresult', + unique_together=set([('fittingspec', 'dataset', 'model', 'protocol', 'fittingspec_version', 'model_version', 'protocol_version')]), + ), + ] diff --git a/weblab/fitting/models.py b/weblab/fitting/models.py index 71d57ae29..cd8a497f8 100644 --- a/weblab/fitting/models.py +++ b/weblab/fitting/models.py @@ -1,6 +1,16 @@ from django.db import models -from entities.models import Entity, EntityManager, ProtocolEntity +from core.models import UserCreatedModelMixin +from core.visibility import get_joint_visibility +from datasets.models import Dataset +from entities.models import ( + Entity, + EntityManager, + ModelEntity, + ProtocolEntity, +) +from experiments.models import ExperimentMixin, Runnable +from repocache.models import CachedFittingSpecVersion, CachedModelVersion, CachedProtocolVersion class FittingSpec(Entity): @@ -47,3 +57,63 @@ def remove_collaborator(self, user): @property def collaborators(self): return self.entity_ptr.collaborators + + +class FittingResult(ExperimentMixin, UserCreatedModelMixin, models.Model): + """Represents the result of running a parameter fitting experiment. + + This class essentially just stores the links to (particular versions of) a fitting spec, + model, protocol, and dataset. The actual results are contained within FittingResultVersion + instances, available as .versions, that represent specific runs of the fitting experiment. + + There will only ever be one FittingResult for a given combination of model, protocol, + dataset and fitting spec versions. + + """ + fittingspec = models.ForeignKey(FittingSpec, related_name='fitting_results') + dataset = models.ForeignKey(Dataset, related_name='fitting_results') + model = models.ForeignKey(ModelEntity, related_name='model_fitting_results') + protocol = models.ForeignKey(ProtocolEntity, related_name='protocol_fitting_results') + + model_version = models.ForeignKey(CachedModelVersion, default=None, null=False, related_name='model_ver_fitres') + protocol_version = models.ForeignKey(CachedProtocolVersion, default=None, null=False, related_name='pro_ver_fitres') + fittingspec_version = models.ForeignKey( + CachedFittingSpecVersion, + default=None, null=False, related_name='fit_ver_fitres', + ) + + class Meta: + unique_together = ('fittingspec', 'dataset', 'model', 'protocol', + 'fittingspec_version', 'model_version', 'protocol_version') + + permissions = ( + ('run_fits', 'Can run parameter fitting experiments'), + ) + + @property + def name(self): + """There isn't an obvious easy naming for fitting results...""" + return 'Fit {} to {} using {}'.format(self.model.name, self.dataset.name, self.fittingspec.name) + + @property + def visibility(self): + return get_joint_visibility( + self.fittingspec_version.visibility, + self.dataset.visibility, + self.model_version.visibility, + self.protocol_version.visibility, + ) + + @property + def entities(self): + return (self.fittingspec, self.dataset, self.model, self.protocol) + + +class FittingResultVersion(Runnable): + """The results of a single parameter fitting run.""" + fittingresult = models.ForeignKey(FittingResult, related_name='versions') + + @property + def parent(self): + """The FittingResult this is a version of.""" + return self.fittingresult diff --git a/weblab/fitting/processing.py b/weblab/fitting/processing.py new file mode 100644 index 000000000..d504ddb7c --- /dev/null +++ b/weblab/fitting/processing.py @@ -0,0 +1,84 @@ +import logging +from urllib.parse import urljoin + +from django.conf import settings +from django.core.exceptions import MultipleObjectsReturned +from django.core.urlresolvers import reverse + +from experiments.processing import submit_runnable + +from .models import FittingResult, FittingResultVersion + + +logger = logging.getLogger(__name__) + + +def submit_fitting( + model, model_version, + protocol, protocol_version, + fittingspec, fittingspec_version, + dataset, user, rerun_ok, +): + """Submit a Celery task to run a fitting experiment + + @param rerun_ok if False and a FittingResultVersion already exists, will just return that. + Otherwise will create a new version of the fitting result. + @return the FittingResultVersion for the run + """ + fittingresult, _ = FittingResult.objects.get_or_create( + model=model, + protocol=protocol, + fittingspec=fittingspec, + dataset=dataset, + model_version=model.repocache.get_version(model_version), + protocol_version=protocol.repocache.get_version(protocol_version), + fittingspec_version=fittingspec.repocache.get_version(fittingspec_version), + defaults={ + 'author': user, + } + ) + + # Check there isn't an existing version if we're not allowed to re-run + if not rerun_ok: + try: + version, created = FittingResultVersion.objects.get_or_create( + fittingresult=fittingresult, + defaults={ + 'author': user, + } + ) + except MultipleObjectsReturned: + return FittingResultVersion.objects.filter(fittingresult=fittingresult).latest('created_at'), False + if not created: + return version, False + else: + version = FittingResultVersion.objects.create( + fittingresult=fittingresult, + author=user, + ) + + model_url = reverse( + 'entities:entity_archive', + args=['model', model.pk, model_version] + ) + protocol_url = reverse( + 'entities:entity_archive', + args=['protocol', protocol.pk, protocol_version] + ) + fittingspec_url = reverse( + 'fitting:entity_archive', + args=['spec', fittingspec.pk, fittingspec_version] + ) + dataset_url = reverse( + 'datasets:archive', + args=[dataset.pk] + ) + body = { + 'model': urljoin(settings.CALLBACK_BASE_URL, model_url), + 'protocol': urljoin(settings.CALLBACK_BASE_URL, protocol_url), + 'fittingSpec': urljoin(settings.CALLBACK_BASE_URL, fittingspec_url), + 'dataset': urljoin(settings.CALLBACK_BASE_URL, dataset_url), + } + + submit_runnable(version, body, user) + return version, True diff --git a/weblab/fitting/tests/test.omex b/weblab/fitting/tests/test.omex new file mode 100644 index 000000000..53699e151 Binary files /dev/null and b/weblab/fitting/tests/test.omex differ diff --git a/weblab/fitting/tests/test_models.py b/weblab/fitting/tests/test_models.py index c43fa884f..3835b3914 100644 --- a/weblab/fitting/tests/test_models.py +++ b/weblab/fitting/tests/test_models.py @@ -1,10 +1,14 @@ +from datetime import date + import pytest from django.db.utils import IntegrityError from django.shortcuts import get_object_or_404 from guardian.shortcuts import assign_perm from core import recipes +from datasets.models import Dataset from repocache.models import CachedFittingSpec +from repocache.populate import populate_entity_cache @pytest.mark.django_db @@ -86,3 +90,178 @@ def test_get_repocache(self): assert CachedFittingSpec.objects.count() == 1 spec.delete() assert CachedFittingSpec.objects.count() == 0 + + +@pytest.mark.django_db +class TestFittingResult: + def test_name(self, helpers): + model = recipes.model.make(name='my model') + protocol = recipes.protocol.make(name='my protocol') + dataset = recipes.dataset.make(name='my dataset') + fittingspec = recipes.fittingspec.make(name='my fitting spec') + + model_version = helpers.add_version(model, tag_name='v1') + protocol_version = helpers.add_version(protocol, tag_name='v2') + fittingspec_version = helpers.add_version(fittingspec, tag_name='v3') + + fitres = recipes.fittingresult.make( + model=model, + model_version=model.repocache.get_version(model_version.sha), + protocol=protocol, + protocol_version=protocol.repocache.get_version(protocol_version.sha), + fittingspec=fittingspec, + fittingspec_version=fittingspec.repocache.get_version(fittingspec_version.sha), + dataset=dataset, + ) + + assert str(fitres) == fitres.name == 'Fit my model to my dataset using my fitting spec' + + def test_latest_version(self): + v1 = recipes.fittingresult_version.make(created_at=date(2017, 1, 2)) + v2 = recipes.fittingresult_version.make(fittingresult=v1.fittingresult, created_at=date(2017, 1, 3)) + + assert v1.fittingresult.latest_version == v2 + assert not v1.is_latest + assert v2.is_latest + + def test_latest_result(self): + ver = recipes.fittingresult_version.make(created_at=date(2017, 1, 2), status='FAILED') + + assert ver.fittingresult.latest_result == 'FAILED' + + def test_latest_result_empty_if_no_versions(self): + fitres = recipes.fittingresult.make() + + assert fitres.latest_result == '' + + def test_nice_versions(self, fittingresult_version): + fitres = fittingresult_version.fittingresult + + assert fitres.nice_model_version == fitres.model.repocache.latest_version.sha[:8] + '...' + assert fitres.nice_protocol_version == fitres.protocol.repocache.latest_version.sha[:8] + '...' + + fitres.model.repo.tag('v1') + populate_entity_cache(fitres.model) + assert fitres.nice_model_version == 'v1' + + fitres.protocol.repo.tag('v2') + populate_entity_cache(fitres.protocol) + + assert fitres.nice_protocol_version == 'v2' + + def test_visibility(self, helpers): + model = recipes.model.make() + protocol = recipes.protocol.make() + ds1 = recipes.dataset.make(visibility='private') + ds2 = recipes.dataset.make(visibility='public') + fittingspec = recipes.fittingspec.make() + + mv1 = helpers.cached_version(model, visibility='private') + mv2 = helpers.cached_version(model, visibility='public') + pv1 = helpers.cached_version(protocol, visibility='private') + pv2 = helpers.cached_version(protocol, visibility='public') + fv1 = helpers.cached_version(fittingspec, visibility='private') + fv2 = helpers.cached_version(fittingspec, visibility='public') + + # all public + assert recipes.fittingresult.make( + model=model, model_version=mv2, + protocol=protocol, protocol_version=pv2, + fittingspec=fittingspec, fittingspec_version=fv2, + dataset=ds2, + ).visibility == 'public' + + # all private + assert recipes.fittingresult.make( + model=model, model_version=mv1, + protocol=protocol, protocol_version=pv1, + fittingspec=fittingspec, fittingspec_version=fv1, + dataset=ds1, + ).visibility == 'private' + + # model private version => private + assert recipes.fittingresult.make( + model=model, model_version=mv1, + protocol=protocol, protocol_version=pv2, + fittingspec=fittingspec, fittingspec_version=fv2, + dataset=ds2, + ).visibility == 'private' + + # protocol private version => private + assert recipes.fittingresult.make( + model=model, model_version=mv2, + protocol=protocol, protocol_version=pv1, + fittingspec=fittingspec, fittingspec_version=fv2, + dataset=ds2, + ).visibility == 'private' + + # fitting spec private version => private + assert recipes.fittingresult.make( + model=model, model_version=mv2, + protocol=protocol, protocol_version=pv2, + fittingspec=fittingspec, fittingspec_version=fv1, + dataset=ds2, + ).visibility == 'private' + + # dataset private version => private + assert recipes.fittingresult.make( + model=model, model_version=mv2, + protocol=protocol, protocol_version=pv2, + fittingspec=fittingspec, fittingspec_version=fv2, + dataset=ds1, + ).visibility == 'private' + + def test_viewers(self, helpers, user): + helpers.add_permission(user, 'create_model') + helpers.add_permission(user, 'create_protocol') + helpers.add_permission(user, 'create_fittingspec') + helpers.add_permission(user, 'create_dataset', model=Dataset) + + model = recipes.model.make() + protocol = recipes.protocol.make() + fittingspec = recipes.fittingspec.make() + # Datasets do not currently support collaborators + # (https://github.com/ModellingWebLab/WebLab/issues/247) + # so test with a public dataset for now + dataset = recipes.dataset.make(visibility='public') + mv = helpers.cached_version(model, visibility='private') + pv = helpers.cached_version(protocol, visibility='private') + fv = helpers.cached_version(fittingspec, visibility='private') + + fr = recipes.fittingresult.make( + model=model, model_version=mv, + protocol=protocol, protocol_version=pv, + fittingspec=fittingspec, fittingspec_version=fv, + dataset=dataset, + ) + assert user not in fr.viewers + assert not fr.is_visible_to_user(user) + + fr.model.add_collaborator(user) + assert user not in fr.viewers + assert not fr.is_visible_to_user(user) + + fr.protocol.add_collaborator(user) + assert user not in fr.viewers + assert not fr.is_visible_to_user(user) + + fr.fittingspec.add_collaborator(user) + assert user in fr.viewers + assert fr.is_visible_to_user(user) + + def test_viewers_of_public_fittingresult(self, helpers, user): + model = recipes.model.make() + protocol = recipes.protocol.make() + fittingspec = recipes.fittingspec.make() + dataset = recipes.dataset.make(visibility='public') + mv = helpers.cached_version(model, visibility='public') + pv = helpers.cached_version(protocol, visibility='public') + fv = helpers.cached_version(fittingspec, visibility='public') + + fr = recipes.fittingresult.make( + model=model, model_version=mv, + protocol=protocol, protocol_version=pv, + fittingspec=fittingspec, fittingspec_version=fv, + dataset=dataset, + ) + assert fr.viewers == {} diff --git a/weblab/fitting/tests/test_processing.py b/weblab/fitting/tests/test_processing.py new file mode 100644 index 000000000..b4b6f7928 --- /dev/null +++ b/weblab/fitting/tests/test_processing.py @@ -0,0 +1,242 @@ +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile + +from core import recipes +from experiments.models import RunningExperiment +from experiments.processing import ProcessingException +from fitting.models import FittingResult, FittingResultVersion +from fitting.processing import submit_fitting + + +def generate_response(template='%s succ celery-task-id', field='signature'): + def mock_submit(url, body): + return Mock(content=(template % body[field]).encode()) + return mock_submit + + +@pytest.fixture +def archive_file_path(): + return str(Path(__file__).absolute().parent.joinpath('./test.omex')) + + +@pytest.fixture +def archive_upload(archive_file_path): + with open(archive_file_path, 'rb') as fp: + return SimpleUploadedFile('test.omex', fp.read()) + + +@patch('requests.post', side_effect=generate_response()) +@pytest.mark.django_db +class TestSubmitFitting: + def test_creates_new_fittingresult_and_side_effects( + self, mock_post, + user, model_with_version, protocol_with_version, + fittingspec_with_version, public_dataset + ): + model = model_with_version + protocol = protocol_with_version + fittingspec = fittingspec_with_version + dataset = public_dataset + model_version = model.repo.latest_commit.sha + protocol_version = protocol.repo.latest_commit.sha + fittingspec_version = fittingspec.repo.latest_commit.sha + + assert FittingResult.objects.count() == 0 + assert RunningExperiment.objects.count() == 0 + + version, is_new = submit_fitting( + model, + model_version, + protocol, + protocol_version, + fittingspec, + fittingspec_version, + dataset, + user, + False + ) + assert is_new + + # Check properties of the new fitting result & version + assert FittingResult.objects.count() == 1 + assert version.fittingresult.model == model + assert version.fittingresult.protocol == protocol + assert version.fittingresult.fittingspec == fittingspec + assert version.fittingresult.dataset == dataset + assert version.author == user + assert version.fittingresult.model_version.sha == model_version + assert version.fittingresult.protocol_version.sha == protocol_version + assert version.fittingresult.fittingspec_version.sha == fittingspec_version + assert version.fittingresult.author == user + assert version.status == FittingResultVersion.STATUS_QUEUED + + # Check it did submit to the webservice + model_url = '/entities/models/%d/versions/%s/archive' % (model.pk, model_version) + protocol_url = ( + '/entities/protocols/%d/versions/%s/archive' % + (protocol.pk, protocol_version)) + fittingspec_url = ( + '/fitting/specs/%d/versions/%s/archive' % + (fittingspec.pk, fittingspec_version)) + dataset_url = '/datasets/%d/archive' % (dataset.pk) + + assert mock_post.call_count == 1 + assert mock_post.call_args[0][0] == settings.CHASTE_URL + assert mock_post.call_args[0][1] == { + 'model': settings.CALLBACK_BASE_URL + model_url, + 'protocol': settings.CALLBACK_BASE_URL + protocol_url, + 'fittingSpec': settings.CALLBACK_BASE_URL + fittingspec_url, + 'dataset': settings.CALLBACK_BASE_URL + dataset_url, + 'signature': str(version.running.first().id), + 'callBack': settings.CALLBACK_BASE_URL + '/experiments/callback', + 'user': 'Test User', + 'isAdmin': False, + 'password': settings.CHASTE_PASSWORD, + } + + # Check running fitting record + assert RunningExperiment.objects.count() == 1 + assert version.running.count() == 1 + assert version.running.first().task_id == 'celery-task-id' + + # Check the run is cancelled when we delete the fitting result version + # We check indirect deletion - this should cascade to everything + mock_post.side_effect = generate_response(field='cancelTask') + model.delete() + assert FittingResult.objects.count() == 0 + assert FittingResultVersion.objects.count() == 0 + assert RunningExperiment.objects.count() == 0 + assert mock_post.call_count == 2 + assert mock_post.call_args[0][0] == settings.CHASTE_URL + assert mock_post.call_args[0][1] == { + 'cancelTask': 'celery-task-id', + 'password': settings.CHASTE_PASSWORD, + } + + def test_uses_existing_fittingresult( + self, mock_post, user, model_with_version, + protocol_with_version, fittingspec_with_version, public_dataset, + ): + model = model_with_version + protocol = protocol_with_version + fittingspec = fittingspec_with_version + dataset = public_dataset + model_version = model.repocache.latest_version + protocol_version = protocol.repocache.latest_version + fittingspec_version = fittingspec.repocache.latest_version + + fittingresult = recipes.fittingresult.make( + model=model, model_version=model_version, + protocol=protocol, protocol_version=protocol_version, + fittingspec=fittingspec, fittingspec_version=fittingspec_version, + dataset=dataset, + ) + + version, is_new = submit_fitting( + fittingresult.model, + fittingresult.model_version.sha, + fittingresult.protocol, + fittingresult.protocol_version.sha, + fittingresult.fittingspec, + fittingresult.fittingspec_version.sha, + fittingresult.dataset, + user, + False, + ) + + assert is_new + assert version.fittingresult == fittingresult + + def test_raises_exception_on_webservice_error( + self, mock_post, user, model_with_version, protocol_with_version, + fittingspec_with_version, public_dataset + ): + model = model_with_version + protocol = protocol_with_version + fittingspec = fittingspec_with_version + dataset = public_dataset + model_version = model.repocache.latest_version + protocol_version = protocol.repocache.latest_version + fittingspec_version = fittingspec.repocache.latest_version + + mock_post.side_effect = generate_response('something %s') + with pytest.raises(ProcessingException): + submit_fitting( + model, model_version.sha, + protocol, protocol_version.sha, + fittingspec, fittingspec_version.sha, + dataset, + user, + False + ) + + # There should be no running fitting + assert RunningExperiment.objects.count() == 0 + + # It should still record a failed fittingresult version however + assert FittingResultVersion.objects.count() == 1 + version = FittingResultVersion.objects.first() + assert version.running.count() == 0 + assert version.fittingresult.model == model + assert version.fittingresult.protocol == protocol + assert version.status == FittingResultVersion.STATUS_FAILED + assert version.return_text.startswith('Chaste backend answered with something unexpected:') + + def test_records_submission_error( + self, mock_post, user, model_with_version, protocol_with_version, + fittingspec_with_version, public_dataset + ): + model = model_with_version + protocol = protocol_with_version + fittingspec = fittingspec_with_version + dataset = public_dataset + model_version = model.repocache.latest_version + protocol_version = protocol.repocache.latest_version + fittingspec_version = fittingspec.repocache.latest_version + + mock_post.side_effect = generate_response('%s an error occurred') + + version, is_new = submit_fitting( + model, model_version.sha, + protocol, protocol_version.sha, + fittingspec, fittingspec_version.sha, + dataset, + user, + False + ) + + assert is_new + assert version.status == FittingResultVersion.STATUS_FAILED + assert version.return_text == 'an error occurred' + assert RunningExperiment.objects.count() == 0 + + def test_records_inapplicable_result( + self, mock_post, user, model_with_version, protocol_with_version, + fittingspec_with_version, public_dataset + ): + model = model_with_version + protocol = protocol_with_version + fittingspec = fittingspec_with_version + dataset = public_dataset + model_version = model.repocache.latest_version + protocol_version = protocol.repocache.latest_version + fittingspec_version = fittingspec.repocache.latest_version + + mock_post.side_effect = generate_response('%s inapplicable') + + version, is_new = submit_fitting( + model, model_version.sha, + protocol, protocol_version.sha, + fittingspec, fittingspec_version.sha, + dataset, + user, + False + ) + + assert is_new + assert version.status == FittingResultVersion.STATUS_INAPPLICABLE + assert RunningExperiment.objects.count() == 0 diff --git a/weblab/fitting/tests/test_views.py b/weblab/fitting/tests/test_views.py index d1d21a4fc..f7b633b42 100644 --- a/weblab/fitting/tests/test_views.py +++ b/weblab/fitting/tests/test_views.py @@ -1,7 +1,429 @@ +import json +import shutil +import zipfile +from io import BytesIO +from pathlib import Path + import pytest +from django.core.urlresolvers import reverse +from django.utils.dateparse import parse_datetime +from pytest_django.asserts import assertContains, assertTemplateUsed from core import recipes -from fitting.models import FittingSpec +from fitting.models import FittingResult, FittingResultVersion, FittingSpec +from repocache.populate import populate_entity_cache + + +@pytest.fixture +def archive_file_path(): + return str(Path(__file__).absolute().parent.joinpath('./test.omex')) + + +@pytest.mark.django_db +class TestFittingResultVersionsView: + def test_view_fittingresult_versions(self, client, fittingresult_version): + response = client.get( + ('/fitting/results/%d/versions/' % fittingresult_version.fittingresult.pk) + ) + + assert response.status_code == 200 + assert response.context['fittingresult'] == fittingresult_version.fittingresult + + +@pytest.mark.django_db +class TestFittingResultVersionView: + def test_view_fittingresult_version(self, client, fittingresult_version): + response = client.get( + ('/fitting/results/%d/versions/%d' % + (fittingresult_version.fittingresult.pk, fittingresult_version.pk)) + ) + + assert response.context['version'] == fittingresult_version + assertTemplateUsed(response, 'fitting/fittingresultversion_detail.html') + assertContains(response, 'Download archive of all files') + + +@pytest.mark.django_db +class TestFittingResultArchiveView: + def test_download_archive(self, client, fittingresult_version, archive_file_path): + fittingresult_version.mkdir() + fittingresult_version.fittingresult.model.name = 'my_model' + fittingresult_version.fittingresult.model.save() + fittingresult_version.fittingresult.fittingspec.name = 'my_spec' + fittingresult_version.fittingresult.fittingspec.save() + fittingresult_version.fittingresult.dataset.name = 'my_dataset' + fittingresult_version.fittingresult.dataset.save() + shutil.copyfile(archive_file_path, str(fittingresult_version.archive_path)) + + response = client.get( + '/fitting/results/%d/versions/%d/archive' % + (fittingresult_version.fittingresult.pk, fittingresult_version.pk) + ) + assert response.status_code == 200 + archive = zipfile.ZipFile(BytesIO(response.content)) + assert set(archive.namelist()) == { + 'stdout.txt', 'errors.txt', 'manifest.xml', 'oxmeta:membrane%3Avoltage - space.csv'} + assert response['Content-Disposition'] == ( + 'attachment; filename=Fit_my_model_to_my_dataset_using_my_spec.zip' + ) + + def test_returns_404_if_no_archive_exists(self, client, fittingresult_version): + response = client.get( + '/fitting/results/%d/versions/%d/archive' % + (fittingresult_version.fittingresult.pk, fittingresult_version.pk) + ) + assert response.status_code == 404 + + +@pytest.mark.django_db +class TestFittingResultFileDownloadView: + def test_download_file(self, client, archive_file_path, fittingresult_version): + fittingresult_version.mkdir() + shutil.copyfile(archive_file_path, str(fittingresult_version.archive_path)) + + response = client.get( + '/fitting/results/%d/versions/%d/download/stdout.txt' % + (fittingresult_version.fittingresult.pk, fittingresult_version.pk) + ) + assert response.status_code == 200 + assert response.content == b'line of output\nmore output\n' + assert response['Content-Disposition'] == ( + 'attachment; filename=stdout.txt' + ) + assert response['Content-Type'] == 'text/plain' + + def test_handles_odd_characters(self, client, archive_file_path, fittingresult_version): + fittingresult_version.mkdir() + shutil.copyfile(archive_file_path, str(fittingresult_version.archive_path)) + filename = 'oxmeta:membrane%3Avoltage - space.csv' + + response = client.get( + reverse('fitting:result:file_download', + args=[fittingresult_version.fittingresult.pk, fittingresult_version.pk, filename]) + ) + + assert response.status_code == 200 + assert response.content == b'1,1\n' + assert response['Content-Disposition'] == ( + 'attachment; filename=' + filename + ) + assert response['Content-Type'] == 'text/csv' + + def test_disallows_non_local_files(self, client, archive_file_path, fittingresult_version): + fittingresult_version.mkdir() + shutil.copyfile(archive_file_path, str(fittingresult_version.archive_path)) + + for filename in ['/etc/passwd', '../../../pytest.ini']: + response = client.get( + '/fitting/results/%d/versions/%d/download/%s' % ( + fittingresult_version.fittingresult.pk, fittingresult_version.pk, filename) + ) + assert response.status_code == 404 + + +@pytest.mark.django_db +class TestFittingResultVersionJsonView: + def test_fittingresult_json(self, client, logged_in_user, fittingresult_version): + version = fittingresult_version + + version.author.full_name = 'test user' + version.author.save() + version.status = 'SUCCESS' + + response = client.get( + ('/fitting/results/%d/versions/%d/files.json' % (version.fittingresult.pk, version.pk)) + ) + + assert response.status_code == 200 + data = json.loads(response.content.decode()) + ver = data['version'] + assert ver['id'] == version.pk + assert ver['author'] == 'test user' + assert ver['status'] == 'SUCCESS' + assert ver['visibility'] == 'public' + assert ( + parse_datetime(ver['created']).replace(microsecond=0) == + version.created_at.replace(microsecond=0) + ) + assert ver['name'] == '{:%Y-%m-%d %H:%M:%S}'.format(version.created_at) + assert ver['fittingResultId'] == version.fittingresult.id + assert ver['version'] == version.id + assert ver['files'] == [] + assert ver['numFiles'] == 0 + assert ver['download_url'] == ( + '/fitting/results/%d/versions/%d/archive' % (version.fittingresult.pk, version.pk) + ) + + def test_file_json(self, client, archive_file_path, fittingresult_version): + version = fittingresult_version + version.author.full_name = 'test user' + version.author.save() + version.mkdir() + shutil.copyfile(archive_file_path, str(version.archive_path)) + + response = client.get( + ('/fitting/results/%d/versions/%d/files.json' % (version.fittingresult.pk, version.pk)) + ) + + assert response.status_code == 200 + data = json.loads(response.content.decode()) + file1 = data['version']['files'][0] + assert file1['author'] == 'test user' + assert file1['name'] == 'stdout.txt' + assert file1['filetype'] == 'http://purl.org/NET/mediatypes/text/plain' + assert not file1['masterFile'] + assert file1['size'] == 27 + assert file1['url'] == ( + '/fitting/results/%d/versions/%d/download/stdout.txt' % (version.fittingresult.pk, version.pk) + ) + + +@pytest.mark.django_db +class TestFittingResultDeletion: + def test_owner_can_delete_fittingresult( + self, logged_in_user, client, fittingresult_with_result + ): + fittingresult = fittingresult_with_result.fittingresult + fittingresult.author = logged_in_user + fittingresult.save() + exp_ver_path = fittingresult_with_result.abs_path + assert FittingResult.objects.filter(pk=fittingresult.pk).exists() + + response = client.post('/fitting/results/%d/delete' % fittingresult.pk) + + assert response.status_code == 302 + assert response.url == '/experiments/?show_fits=true' + + assert not FittingResult.objects.filter(pk=fittingresult.pk).exists() + assert not exp_ver_path.exists() + + @pytest.mark.usefixtures('logged_in_user') + def test_non_owner_cannot_delete_fittingresult( + self, other_user, client, fittingresult_with_result + ): + fittingresult = fittingresult_with_result.fittingresult + fittingresult.author = other_user + fittingresult.save() + exp_ver_path = fittingresult_with_result.abs_path + + response = client.post('/fitting/results/%d/delete' % fittingresult.pk) + + assert response.status_code == 403 + assert FittingResult.objects.filter(pk=fittingresult.pk).exists() + assert exp_ver_path.exists() + + def test_owner_can_delete_fittingresult_version( + self, logged_in_user, client, fittingresult_with_result + ): + fittingresult = fittingresult_with_result.fittingresult + fittingresult_with_result.author = logged_in_user + fittingresult_with_result.save() + exp_ver_path = fittingresult_with_result.abs_path + + response = client.post( + '/fitting/results/%d/versions/%d/delete' % + (fittingresult.pk, fittingresult_with_result.pk)) + + assert response.status_code == 302 + assert response.url == '/fitting/results/%d/versions/' % fittingresult.pk + + assert not FittingResultVersion.objects.filter(pk=fittingresult_with_result.pk).exists() + assert not exp_ver_path.exists() + assert FittingResult.objects.filter(pk=fittingresult.pk).exists() + + @pytest.mark.usefixtures('logged_in_user') + def test_non_owner_cannot_delete_fittingresult_version( + self, other_user, client, fittingresult_with_result + ): + fittingresult = fittingresult_with_result.fittingresult + fittingresult_with_result.author = other_user + fittingresult_with_result.save() + exp_ver_path = fittingresult_with_result.abs_path + + response = client.post( + '/fitting/results/%d/versions/%d/delete' % + (fittingresult.pk, fittingresult_with_result.pk)) + + assert response.status_code == 403 + assert FittingResultVersion.objects.filter(pk=fittingresult_with_result.pk).exists() + assert FittingResult.objects.filter(pk=fittingresult.pk).exists() + assert exp_ver_path.exists() + + +@pytest.mark.django_db +class TestFittingResultComparisonView: + def test_compare_fittingresults(self, client, fittingresult_version, helpers): + fit = fittingresult_version.fittingresult + protocol = recipes.protocol.make() + protocol_commit = helpers.add_version(protocol, visibility='public') + + version2 = recipes.fittingresult_version.make( + status='SUCCESS', + fittingresult__model=fit.model, + fittingresult__model_version=fit.model_version, + fittingresult__protocol=protocol, + fittingresult__protocol_version=protocol.repocache.get_version(protocol_commit.sha), + fittingresult__fittingspec=fit.fittingspec, + fittingresult__fittingspec_version=fit.fittingspec_version, + fittingresult__dataset=fit.dataset, + ) + + response = client.get( + ('/fitting/results/compare/%d/%d' % (fittingresult_version.id, version2.id)) + ) + + assert response.status_code == 200 + assert set(response.context['fittingresult_versions']) == { + fittingresult_version, version2 + } + + def test_only_compare_visible_fittingresults(self, client, fittingresult_version, helpers): + ver1 = fittingresult_version + fit = ver1.fittingresult + + proto = recipes.protocol.make() + proto_commit = helpers.add_version(proto, visibility='private') + ver2 = recipes.fittingresult_version.make( + status='SUCCESS', + fittingresult__model=fit.model, + fittingresult__model_version=fit.model_version, + fittingresult__protocol=proto, + fittingresult__protocol_version=proto.repocache.get_version(proto_commit.sha), + fittingresult__fittingspec=fit.fittingspec, + fittingresult__fittingspec_version=fit.fittingspec_version, + fittingresult__dataset=fit.dataset, + ) + + response = client.get( + ('/fitting/results/compare/%d/%d' % (ver1.id, ver2.id)) + ) + + assert response.status_code == 200 + assert set(response.context['fittingresult_versions']) == {ver1} + + assert len(response.context['ERROR_MESSAGES']) == 1 + + def test_no_visible_fittingresults(self, client, fittingresult_version): + proto = fittingresult_version.fittingresult.protocol + proto.set_version_visibility('latest', 'private') + fittingresult_version.fittingresult.protocol_version.refresh_from_db() + assert fittingresult_version.visibility == 'private' + + response = client.get('/fitting/results/compare/%d' % (fittingresult_version.id)) + + assert response.status_code == 200 + assert len(response.context['fittingresult_versions']) == 0 + + +@pytest.mark.django_db +class TestFittingResultComparisonJsonView: + def test_compare_fittingresults(self, client, fittingresult_version, helpers): + fitres = fittingresult_version.fittingresult + protocol = recipes.protocol.make() + protocol_commit = helpers.add_version(protocol, visibility='public') + fitres.protocol.repo.tag('v1') + populate_entity_cache(fitres.protocol) + + version2 = recipes.fittingresult_version.make( + status='SUCCESS', + fittingresult__model=fitres.model, + fittingresult__model_version=fitres.model_version, + fittingresult__protocol=protocol, + fittingresult__protocol_version=protocol.repocache.get_version(protocol_commit.sha), + fittingresult__fittingspec=fitres.fittingspec, + fittingresult__fittingspec_version=fitres.fittingspec_version, + fittingresult__dataset=fitres.dataset, + ) + + response = client.get( + ('/fitting/results/compare/%d/%d/info' % (fittingresult_version.id, version2.id)) + ) + + assert response.status_code == 200 + data = json.loads(response.content.decode()) + versions = data['getEntityInfos']['entities'] + assert versions[0]['versionId'] == fittingresult_version.id + assert versions[1]['versionId'] == version2.id + assert versions[0]['modelName'] == fitres.model.name + assert versions[0]['modelVersion'] == fitres.model_version.sha + assert versions[0]['protoName'] == fitres.protocol.name + assert versions[0]['protoVersion'] == 'v1' + assert versions[0]['fittingSpecName'] == fitres.fittingspec.name + assert versions[0]['fittingSpecVersion'] == fitres.fittingspec_version.sha + assert versions[0]['datasetName'] == fitres.dataset.name + assert versions[0]['name'] == fitres.name + assert versions[0]['runNumber'] == 1 + + def test_only_compare_visible_fittingresults(self, client, fittingresult_version, helpers): + ver1 = fittingresult_version + fitres = ver1.fittingresult + + proto = recipes.protocol.make() + proto_commit = helpers.add_version(proto, visibility='private') + ver2 = recipes.fittingresult_version.make( + status='SUCCESS', + fittingresult__model=fitres.model, + fittingresult__model_version=fitres.model_version, + fittingresult__protocol=proto, + fittingresult__protocol_version=proto.repocache.get_version(proto_commit.sha), + fittingresult__fittingspec=fitres.fittingspec, + fittingresult__fittingspec_version=fitres.fittingspec_version, + fittingresult__dataset=fitres.dataset, + ) + + response = client.get( + ('/fitting/results/compare/%d/%d/info' % (ver1.id, ver2.id)) + ) + + assert response.status_code == 200 + data = json.loads(response.content.decode()) + versions = data['getEntityInfos']['entities'] + assert len(versions) == 1 + assert versions[0]['versionId'] == ver1.id + + def test_file_json(self, client, archive_file_path, helpers, fittingresult_version): + fittingresult_version.author.full_name = 'test user' + fittingresult_version.author.save() + fittingresult_version.mkdir() + shutil.copyfile(archive_file_path, str(fittingresult_version.archive_path)) + fitres = fittingresult_version.fittingresult + fitres.model.set_version_visibility('latest', 'public') + fitres.protocol.set_version_visibility('latest', 'public') + + protocol = recipes.protocol.make() + protocol_commit = helpers.add_version(protocol, visibility='public') + version2 = recipes.fittingresult_version.make( + status='SUCCESS', + fittingresult__model=fitres.model, + fittingresult__model_version=fitres.model.repocache.get_version(fitres.model_version.sha), + fittingresult__protocol=protocol, + fittingresult__protocol_version=protocol.repocache.get_version(protocol_commit.sha), + ) + version2.mkdir() + shutil.copyfile(archive_file_path, str(version2.archive_path)) + + response = client.get( + ('/fitting/results/compare/%d/%d/info' % (fittingresult_version.pk, version2.pk)) + ) + + assert response.status_code == 200 + data = json.loads(response.content.decode()) + file1 = data['getEntityInfos']['entities'][0]['files'][0] + assert file1['author'] == 'test user' + assert file1['name'] == 'stdout.txt' + assert file1['filetype'] == 'http://purl.org/NET/mediatypes/text/plain' + assert not file1['masterFile'] + assert file1['size'] == 27 + assert file1['url'] == ( + '/fitting/results/%d/versions/%d/download/stdout.txt' % (fitres.pk, fittingresult_version.pk) + ) + + def test_empty_fittingresult_list(self, client, fittingresult_version): + response = client.get('/fitting/results/compare/info') + + assert response.status_code == 200 + data = json.loads(response.content.decode()) + assert len(data['getEntityInfos']['entities']) == 0 @pytest.mark.django_db diff --git a/weblab/fitting/urls.py b/weblab/fitting/urls.py index 5076625b0..9c9a17118 100644 --- a/weblab/fitting/urls.py +++ b/weblab/fitting/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.conf.urls import include, url from entities import views as entity_views @@ -11,6 +11,63 @@ _FILEVIEW = r'%s/(?P\w+)' % _FILENAME _ENTITY_TYPE = '(?P%s)s' % FittingSpec.url_type + +result_patterns = [ + url( + r'^(?P\d+)/versions/$', + views.FittingResultVersionListView.as_view(), + name='versions', + ), + + url( + r'^(?P\d+)/versions/(?P\d+)(?:/%s)?$' % _FILEVIEW, + views.FittingResultVersionView.as_view(), + name='version', + ), + + url( + r'^(?P\d+)/versions/(?P\d+)/archive$', + views.FittingResultVersionArchiveView.as_view(), + name='archive', + ), + + url( + r'^(?P\d+)/versions/(?P\d+)/download/%s$' % _FILENAME, + views.FittingResultFileDownloadView.as_view(), + name='file_download', + ), + + url( + r'^(?P\d+)/versions/(?P\d+)/files.json$', + views.FittingResultVersionJsonView.as_view(), + name='version_json', + ), + + url( + r'^(?P\d+)/delete$', + views.FittingResultDeleteView.as_view(), + name='delete', + ), + + url( + r'^(?P\d+)/versions/(?P\d+)/delete$', + views.FittingResultVersionDeleteView.as_view(), + name='delete_version', + ), + + url( + r'^compare(?P(/\d+){1,})(?:/show/%s)?$' % _FILEVIEW, + views.FittingResultComparisonView.as_view(), + name='compare', + ), + + url( + r'^compare(?P(/\d+)*)/info$', + views.FittingResultComparisonJsonView.as_view(), + name='compare_json', + ), +] + urlpatterns = [ url( r'^%s/$' % _ENTITY_TYPE, @@ -138,4 +195,8 @@ views.FittingSpecRenameView.as_view(), name='rename', ), + + url( + r'^results/', include(result_patterns, namespace='result') + ), ] diff --git a/weblab/fitting/views.py b/weblab/fitting/views.py index dba6dfb10..78f156427 100644 --- a/weblab/fitting/views.py +++ b/weblab/fitting/views.py @@ -11,13 +11,22 @@ """ from braces.views import UserFormKwargsMixin +from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.http import JsonResponse from django.urls import reverse +from django.utils.text import get_valid_filename +from django.views import View +from django.views.generic import TemplateView +from django.views.generic.detail import DetailView, SingleObjectMixin from django.views.generic.edit import CreateView +from core.visibility import VisibilityMixin +from datasets import views as dataset_views from entities.views import EntityNewVersionView, EntityTypeMixin, RenameView from .forms import FittingSpecForm, FittingSpecRenameForm, FittingSpecVersionForm +from .models import FittingResult, FittingResultVersion class FittingSpecCreateView( @@ -43,5 +52,153 @@ class FittingSpecNewVersionView(EntityNewVersionView): class FittingSpecRenameView(RenameView): - + """Rename a fitting specification.""" form_class = FittingSpecRenameForm + + +class FittingResultVersionListView(VisibilityMixin, DetailView): + """Show all versions of a fitting result""" + model = FittingResult + context_object_name = 'fittingresult' + template_name = 'fitting/fittingresult_versions.html' + + +class FittingResultVersionView(VisibilityMixin, DetailView): + model = FittingResultVersion + context_object_name = 'version' + + +class FittingResultVersionArchiveView(dataset_views.DatasetArchiveView): + """ + Download a combine archive of a fitting result version + """ + model = FittingResultVersion + + def get_archive_name(self, version): + return get_valid_filename('%s.zip' % version.fittingresult.name) + + +class FittingResultFileDownloadView(dataset_views.DatasetFileDownloadView): + """ + Download an individual file from a fitting result + """ + model = FittingResultVersion + + +class FittingResultVersionJsonView(VisibilityMixin, SingleObjectMixin, View): + """ + Serve up json view of a fitting result verson + """ + model = FittingResultVersion + + def get(self, request, *args, **kwargs): + version = self.get_object() + ns = self.request.resolver_match.namespace + url_args = [version.fittingresult.id, version.id] + details = version.get_json(ns, url_args) + details.update({ + 'status': version.status, + 'version': version.id, + 'fittingResultId': version.fittingresult.id, + }) + return JsonResponse({ + 'version': details, + }) + + +class FittingResultDeleteView(dataset_views.DatasetDeleteView): + """ + Delete all versions of a fitting result + """ + model = FittingResult + + def get_success_url(self, *args, **kwargs): + return reverse('experiments:list') + '?show_fits=true' + + +class FittingResultVersionDeleteView(dataset_views.DatasetDeleteView): + """ + Delete a single version of a fitting result + """ + model = FittingResultVersion + + def get_success_url(self, *args, **kwargs): + return reverse('fitting:result:versions', args=[self.get_object().fittingresult.id]) + + +class FittingResultComparisonView(TemplateView): + """ + Compare multiple fitting result versions + """ + template_name = 'fitting/fittingresultversion_compare.html' + + def get_context_data(self, **kwargs): + pks = set(map(int, self.kwargs['version_pks'].strip('/').split('/'))) + versions = FittingResultVersion.objects.filter(pk__in=pks).order_by('created_at') + versions = [v for v in versions if v.fittingresult.is_visible_to_user(self.request.user)] + + if len(versions) < len(pks): + messages.error( + self.request, + 'Some requested fitting results could not be found ' + '(or you don\'t have permission to see them)' + ) + + kwargs.update({ + 'fittingresult_versions': versions, + }) + return super().get_context_data(**kwargs) + + +class FittingResultComparisonJsonView(View): + """ + Serve up JSON view of multiple fitting result versions for comparison + """ + def _version_json(self, version, model_version_in_name, protocol_version_in_name): + """ + JSON for a single fitting result version + + :param version: FittingResultVersion object + :param model_version_in_name: Whether to include model version specifier in name field + :param protocol_version_in_name: Whether to include protocol version specifier in name field + """ + exp = version.fittingresult + ns = self.request.resolver_match.namespace + url_args = [exp.id, version.id] + details = version.get_json(ns, url_args) + details.update({ + 'name': exp.name, + 'url': reverse(ns + ':version', args=url_args), + 'versionId': version.id, + 'modelName': exp.model.name, + 'protoName': exp.protocol.name, + 'fittingSpecName': exp.fittingspec.name, + 'datasetName': exp.dataset.name, + 'modelVersion': exp.model_version.get_name(), + 'protoVersion': exp.protocol_version.get_name(), + 'fittingSpecVersion': exp.fittingspec_version.get_name(), + 'runNumber': version.run_number, + }) + return details + + def get(self, request, *args, **kwargs): + pks = {int(pk) for pk in self.kwargs['version_pks'][1:].split('/') if pk} + versions = FittingResultVersion.objects.filter(pk__in=pks).order_by('created_at') + versions = [v for v in versions if v.fittingresult.is_visible_to_user(self.request.user)] + + models = set((v.fittingresult.model, v.fittingresult.model_version) for v in versions) + protocols = set((v.fittingresult.protocol, v.fittingresult.protocol_version) for v in versions) + compare_model_versions = len(models) > len(dict(models)) + compare_protocol_versions = len(protocols) > len(dict(protocols)) + + response = {} + response = { + 'getEntityInfos': { + 'entities': [ + self._version_json(version, compare_model_versions, compare_protocol_versions) + for version in versions + ] + } + } + + return JsonResponse(response) diff --git a/weblab/repocache/models.py b/weblab/repocache/models.py index 6549b0a75..6d2ba638b 100644 --- a/weblab/repocache/models.py +++ b/weblab/repocache/models.py @@ -3,8 +3,6 @@ from core.models import VisibilityModelMixin from core.visibility import Visibility -from entities.models import ModelEntity, ProtocolEntity -from fitting.models import FittingSpec from .exceptions import RepoCacheMiss @@ -211,7 +209,7 @@ def _set_class_links(entity_cache_type, version_cache_type, tag_cache_type): class CachedModel(CachedEntity): """Cache for a CellML model's repository.""" - entity = models.OneToOneField(ModelEntity, on_delete=models.CASCADE, related_name='cachedmodel') + entity = models.OneToOneField('entities.ModelEntity', on_delete=models.CASCADE, related_name='cachedmodel') class CachedModelVersion(CachedEntityVersion): @@ -230,7 +228,7 @@ class CachedModelTag(CachedEntityTag): class CachedProtocol(CachedEntity): """Cache for a protocol's repository.""" - entity = models.OneToOneField(ProtocolEntity, on_delete=models.CASCADE, related_name='cachedprotocol') + entity = models.OneToOneField('entities.ProtocolEntity', on_delete=models.CASCADE, related_name='cachedprotocol') class CachedProtocolVersion(CachedEntityVersion): @@ -249,7 +247,7 @@ class CachedProtocolTag(CachedEntityTag): class CachedFittingSpec(CachedEntity): """Cache for a fitting specifications's repository.""" - entity = models.OneToOneField(FittingSpec, on_delete=models.CASCADE, related_name='cachedfittingspec') + entity = models.OneToOneField('fitting.FittingSpec', on_delete=models.CASCADE, related_name='cachedfittingspec') class CachedFittingSpecVersion(CachedEntityVersion): diff --git a/weblab/static/js/compare.js b/weblab/static/js/compare.js index ec69ab403..7eaee470c 100644 --- a/weblab/static/js/compare.js +++ b/weblab/static/js/compare.js @@ -27,10 +27,20 @@ var entities = {}, // Contains information about each experiment being compared metadataToParse = 0, metadataParsed = 0, defaultViz = null, defaultVizCount = 0, // State for figuring out whether we're comparing multiple protocols on a single model, or multiple models on a single protocol firstModelName = "", firstModelVersion = "", firstProtoName = "", firstProtoVersion = "", - singleModel = true, singleProto = true, - modelsWithMultipleVersions = [], protocolsWithMultipleVersions = []; - compareModelVersions = false, compareProtocolVersions = false; - + firstFittingSpecName = "", firstFittingSpecVersion = "", firstDatasetName = "", + modelsWithMultipleVersions = [], protocolsWithMultipleVersions = [], fittingSpecsWithMultipleVersions = [], + singleEntities = { + model: true, + protocol: true, + fittingspec: true, + dataset: true, + }, + versionComparisons = { + model: false, + protocol: false, + fittingspec: false, + dataset: false, + }; function nextPage (url, replace) @@ -111,8 +121,8 @@ function highlightPlots (entity, showDefault) var viz = document.getElementById("filerow-" + data_file_code + "-viz-displayPlotFlot"); if (viz) { - var thisCount = singleProto ? plotDescription.length : f.entities.length; - if ((!singleProto || i == 1) && thisCount > defaultVizCount) + var thisCount = singleEntities.protocol ? plotDescription.length : f.entities.length; + if ((!singleEntities.protocol || i == 1) && thisCount > defaultVizCount) { // console.log("Set default viz to " + plotDescription[i][0]); defaultViz = viz; @@ -239,10 +249,15 @@ function parseEntities (entityObj) firstModelVersion = entityObj[0].modelVersion; firstProtoName = entityObj[0].protoName; firstProtoVersion = entityObj[0].protoVersion; + firstFittingSpecName = entityObj[0].fittingSpecName; + firstFittingSpecVersion = entityObj[0].fittingSpecVersion; + firstDatasetName = entityObj[0].datasetName; var versionsOfModels = {}; var versionsOfProtocols = {}; + var versionsOfFittingSpecs ={}; modelsWithMultipleVersions = []; protocolsWithMultipleVersions = []; + fittingSpecsWithMultipleVersions = []; // Sort entityObj list by .name entityObj.sort(function(a,b) {return (a.name.toLocaleLowerCase() > b.name.toLocaleLowerCase()) ? 1 : ((b.name.toLocaleLowerCase() > a.name.toLocaleLowerCase()) ? -1 : 0);}); @@ -251,26 +266,41 @@ function parseEntities (entityObj) { var entity = entityObj[i]; - if (singleModel && (entity.modelName != firstModelName)) { - singleModel = false; + if (singleEntities.model && (entity.modelName != firstModelName)) { + singleEntities.model = false; } if (versionsOfModels[entity.modelName] === undefined) { versionsOfModels[entity.modelName] = entity.modelVersion; } else if (versionsOfModels[entity.modelName] != entity.modelVersion) { modelsWithMultipleVersions.push(entity.modelName); - compareModelVersions = true; + versionComparisons.model = true; } - if (singleProto && (entity.protoName != firstProtoName)) { - singleProto = false; + if (singleEntities.protocol && (entity.protoName != firstProtoName)) { + singleEntities.protocol = false; } if (versionsOfProtocols[entity.protoName] === undefined) { versionsOfProtocols[entity.protoName] = entity.protoVersion; } else if (versionsOfProtocols[entity.protoName] != entity.protoVersion) { protocolsWithMultipleVersions.push(entity.protoName); - compareProtocolVersions = true; + versionComparisons.protocol = true; + } + + if (singleEntities.fittingspec && (entity.fittingSpecName != firstFittingSpecName)) { + singleEntities.fittingspec = false; + } + + if (versionsOfFittingSpecs[entity.fittingSpecName] === undefined) { + versionsOfFittingSpecs[entity.fittingSpecName] = entity.fittingSpecVersion; + } else if (versionsOfFittingSpecs[entity.fittingSpecName] != entity.fittingSpecVersion) { + fittingSpecsWithMultipleVersions.push(entity.fittingSpecName); + versionComparisons.fittingspec = true; + } + + if (singleEntities.dataset && (entity.datasetName != firstDatasetName)) { + singleEntities.dataset = false; } // Fill in the entities and files entries for this entity @@ -303,106 +333,150 @@ function parseEntities (entityObj) } } - console.log(singleModel ? 'single model' : 'multiple models', - compareModelVersions ? ('- compare versions of ' + modelsWithMultipleVersions.join(',')) : ''); - console.log(singleProto ? 'single protocol' : 'multiple protocols', - compareProtocolVersions ? ('- compare versions of ' + protocolsWithMultipleVersions.join(',')) : ''); - + console.log(singleEntities.model ? 'single model' : 'multiple models', + versionComparisons.model ? ('- compare versions of ' + modelsWithMultipleVersions.join(',')) : ''); + console.log(singleEntities.protocol ? 'single protocol' : 'multiple protocols', + versionComparisons.protocol ? ('- compare versions of ' + protocolsWithMultipleVersions.join(',')) : ''); + console.log(singleEntities.fittingspec ? 'single fitting spec' : 'multiple fitting specs', + versionComparisons.fittingspec ? ('- compare versions of ' + fittingSpecsWithMultipleVersions.join(',')) : ''); + console.log(singleEntities.dataset ? 'single dataset' : 'multiple datasets') + + /* + // TESTING / DEBUG of different combinations + singleEntities = { + model: false, + protocol: false, + fittingspec: false, + dataset: false, + }; + versionComparisons = { + model: false, + protocol: true, + fittingspec: false, + dataset: false + }; + // END TESTING / DEBUG + */ + + var entityTypes = ['model', 'protocol']; + if (entityType == 'result') entityTypes.push('fittingspec', 'dataset'); + + var entityTypeDisplayStrings = { + model: 'model', + protocol: 'protocol', + fittingspec: 'fitting spec', + dataset: 'dataset', + }; + + // List of entity types which have multiple objects + var entityTypesToCompare = entityTypes.filter(entitytype => !singleEntities[entitytype]); + + // List of entity types which have multiple versions but not multiple objects + var entityVersionsToCompare = entityTypes.filter(entitytype => versionComparisons[entitytype] && singleEntities[entitytype]); // Add version info to plot labels where needed for (var i = 0; i < entityObj.length; i++) { var entity = entityObj[i]; + var modelDescription = entity.modelName + (modelsWithMultipleVersions.includes(entity.modelName) ? ('@' + entity.modelVersion) : ''); var protoDescription = entity.protoName + (protocolsWithMultipleVersions.includes(entity.protoName) ? ('@' + entity.protoVersion) : ''); - if (singleModel && singleProto) { - if (compareModelVersions && compareProtocolVersions) { - // 5. Single model with multiple versions, single protocol with multiple versions - entity.plotName = '@' + entity.modelVersion + ' & @' + entity.protoVersion; - } else if (compareProtocolVersions) { - // 2. Single model version, single protocol with multiple versions - entity.plotName = '@' + entity.protoVersion; - } else if (compareModelVersions) { - // 4. Single model with multiple versions, single protocol version - entity.plotName = '@' + entity.modelVersion; - } else { - // 1. Single model version, single protocol version + var fitspecDescription = entity.fittingSpecName + (fittingSpecsWithMultipleVersions.includes(entity.fittingSpecName) ? ('@' + entity.fittingSpecVersion) : ''); + var datasetDescription = entity.datasetName; + + // Descriptions of things that have entity comparisons + var descriptions = []; + if (entityTypesToCompare.includes('model')) descriptions.push(modelDescription); + if (entityTypesToCompare.includes('protocol')) descriptions.push(protoDescription); + if (entityTypesToCompare.includes('fittingspec')) descriptions.push(fitspecDescription); + if (entityTypesToCompare.includes('dataset')) descriptions.push(datasetDescription); + var entityDescription = descriptions.join(' & '); + + // Version labels of things that have version comparisons + var versionLabels = []; + if (entityVersionsToCompare.includes('model')) versionLabels.push('@' + entity.modelVersion); + if (entityVersionsToCompare.includes('protocol')) versionLabels.push('@' + entity.protoVersion); + if (entityVersionsToCompare.includes('fittingspec')) versionLabels.push('@' + entity.fittingSpecVersion); + var versionString = versionLabels.join(' & '); + + if (entityTypesToCompare.length == 0) { + // All same entities, possibly different versions of them + if (entityVersionsToCompare.length== 0) { + // Single versions of everything - show the run number entity.plotName = 'Run ' + entity.runNumber; - } - } else if (singleModel) { - if (compareModelVersions) { - // 6. Single model with multiple versions, multiple protocols (maybe multiple versions of individual protocols) - entity.plotName = '@' + entity.modelVersion + ' & ' + protoDescription; } else { - // 3. Single model version, multiple protocols (maybe multiple versions of individual protocols) - entity.plotName = protoDescription; + // Just version comparisons + entity.plotName = versionString; } - } else if (singleProto) { - if (compareProtocolVersions) { - // 8. Single protocol with multiple versions, multiple models (maybe multiple versions of individual models) - entity.plotName = modelDescription + ' & @' + entity.protoVersion; + } else { + // List versions and entities that vary between experiments + if (versionString.length > 0) { + entity.plotName = versionString + ' & ' + entityDescription; } else { - // 7. Single protocol version, multiple models (maybe multiple versions of individual models) - entity.plotName = modelDescription; + entity.plotName = entityDescription; } - } else { - // 9. Multiple models / protocols (maybe multiple versions of each) - entity.plotName = modelDescription + ' & ' + protoDescription; } } - - - // Alter heading to reflect type of comparison + + // Alter heading to reflect type of comparison var pageTitle = "Comparison of " + entityType.charAt(0).toUpperCase() + entityType.slice(1) + "s"; - - if (entityType == "experiment") - { - if (singleModel && singleProto) { + if (entityType == "experiment" || entityType == "result") + { + if (entityTypesToCompare.length == 0) { + // All same entities, just possibly different versions of them pageTitle = firstModelName + " & " + firstProtoName; - if (compareModelVersions && compareProtocolVersions) { - // 5. Single model with multiple versions, single protocol with multiple versions + + if (entityVersionsToCompare.length == 1) { + // Just one type of version comparison + pageTitle += " experiments: comparison of " + entityTypeDisplayStrings[entityVersionsToCompare[0]] + " versions"; + // label: '@' + } else if (entityVersionsToCompare.length > 1) { + // Multiple types of version comparison pageTitle += " experiments: comparison of versions"; - // label = '@ & @' - } else if (compareProtocolVersions) { - // 2. Single model version, single protocol with multiple versions - pageTitle += " experiments: comparison of protocol versions"; - // label = '@' - } else if (compareModelVersions) { - // 4. Single model with multiple versions, single protocol version - pageTitle += " experiments: comparison of model versions"; - // label = '@' + // label: '@ & @' etc. } else { - // 1. Single model version, single protocol version + // Single versions of everything pageTitle += ": comparison of repeat experiments"; - // label = Run + // label: 'Run ' } - } else if (singleModel) { - pageTitle = firstModelName + " experiments : "; - if (compareModelVersions) { - // 6. Single model with multiple versions, multiple protocols (maybe multiple versions of individual protocols) - pageTitle += "comparison of model versions and protocols"; - // label = '@ & @' (protocol version omitted if not needed) - } else { - // 3. Single model version, multiple protocols (maybe multiple versions of individual protocols) - pageTitle += "comparison of protocols"; - // label = '@' (protocol version omitted if not needed) + } else if (entityTypesToCompare.length == 1) { + // Just one type of entity comparison, possibly also with version comparisons + pageTitle = singleEntities.model ? firstModelName : firstProtoName; + pageTitle += " experiments : comparison of " + entityTypeDisplayStrings[entityTypesToCompare[0]] + "s"; + // If just one type of version comparison, be specific. + // Otherwise, just say "versions" + if (entityVersionsToCompare.length == 1) { + pageTitle += " and " + entityTypeDisplayStrings[entityVersionsToCompare[0]] + " versions"; + // label: '@' + // label: ' & @' + } else if (entityVersionsToCompare.length > 1){ + pageTitle += " and versions"; + // label: '@ & @' } - } else if (singleProto) { - pageTitle = firstProtoName + " experiments : "; - if (compareProtocolVersions) { - // 8. Single protocol with multiple versions, multiple models (maybe multiple versions of individual models) - pageTitle += "comparison of models and protocol versions"; - // label = '@ & @' (model version omitted if not needed) - } else { - // 7. Single protocol version, multiple models (maybe multiple versions of individual models) - pageTitle += "comparison of models"; - // label = '@' (model version omitted if not needed) + // } else { label: '' } + + } else if (entityTypesToCompare.length == 2){ + // Two types of entity comparisons + // just list those and take the title from one of the other entity types + var entityName; + if (singleEntities.model) { + entityName = firstModelName; + } else if (singleEntities.protocol) { + entityName = firstProtoName; + } else if (firstFittingSpecName && singleEntities.fittingspec) { + entityName = firstFittingSpecName; + } + if (entityName !== undefined) { + pageTitle = entityName + " experiments: comparison of " + entityTypeDisplayStrings[entityTypesToCompare[0]] + "s and " + entityTypeDisplayStrings[entityTypesToCompare[1]] + "s"; } + // label: " & " + // } else { - // 9. Multiple models / protocols (maybe multiple versions of each) - page title is default + // More than two entity comparisons (possibly multiple versions of each) - page title is default } + doc.heading.innerHTML = pageTitle; // This was used in an earlier version and is still expected to exist by plugins @@ -429,7 +503,7 @@ function parseEntities (entityObj) { var option = document.createElement("option"); option.value = entities[entity].url; - option.innerHTML = entities[entity].name + (entityType == "experiment" ? "" : " — " + entities[entity].version); + option.innerHTML = entities[entity].name + ((entityType == "experiment" || entityType == "result") ? "" : " — " + entities[entity].version); select_box.appendChild(option); } form.innerHTML = entityType.charAt(0).toUpperCase() + entityType.slice(1) + "s selected for comparison: "; @@ -603,7 +677,12 @@ function parseUrl (event) entityType = 'experiment'; entityIds = parts.slice(i+2); break; - } else if (parts[i+1] == 'compare') { + } else if (parts[i] == 'results') { + basicurl = parts.slice(0, i+2).join('/') + '/'; + entityType = 'result'; + entityIds = parts.slice(i+2); + } + else if (parts[i+1] == 'compare') { basicurl = parts.slice(0, i+2).join('/') + '/'; entityType = parts[i].slice(0, parts[i].length-1); entityIds = parts.slice(i+2); diff --git a/weblab/templates/experiments/experimentversion_compare.html b/weblab/templates/experiments/experimentversion_compare.html index 82df48c98..25202e0e7 100644 --- a/weblab/templates/experiments/experimentversion_compare.html +++ b/weblab/templates/experiments/experimentversion_compare.html @@ -5,7 +5,7 @@ {% block content %}

Comparison

-
+
loading...
diff --git a/weblab/templates/fitting/fittingresult_confirm_delete.html b/weblab/templates/fitting/fittingresult_confirm_delete.html new file mode 100644 index 000000000..81f22ad45 --- /dev/null +++ b/weblab/templates/fitting/fittingresult_confirm_delete.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Delete fitting experiment - {% endblock title %} + +{% block content %} + +
{% csrf_token %} +

Are you sure you want to delete all versions of fitting experiment "{{ object }}"?

+

This operation cannot be undone.

+ +
+ +{% endblock %} diff --git a/weblab/templates/fitting/fittingresult_versions.html b/weblab/templates/fitting/fittingresult_versions.html new file mode 100644 index 000000000..83830fd99 --- /dev/null +++ b/weblab/templates/fitting/fittingresult_versions.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% load staticfiles %} + +{% block title %}Fitting result versions - {% endblock title %} + +{% block content %} + {% include "./includes/fittingresult_header.html" %} + +

Versions

+ +
+
    + {% for version in fittingresult.versions.all|dictsortreversed:"created_at" %} +
  • +

    + + + {{ version.name }} + + + by {{ version.author.full_name }} + +
    + + created + {% with version.files|length as numfiles %} + containing {{ numfiles }} file{{ numfiles|pluralize }} + {% endwith %} +
    + + {{ version.return_text|safe|linebreaksbr }} + +

    +
  • + {% endfor %} +
+ +

+ Status Legend: + queued + running + inapplicable + failed + partial failure + success +

+
+{% endblock %} diff --git a/weblab/templates/fitting/fittingresultversion_compare.html b/weblab/templates/fitting/fittingresultversion_compare.html new file mode 100644 index 000000000..e06010fe0 --- /dev/null +++ b/weblab/templates/fitting/fittingresultversion_compare.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% load experiments %} + +{% block title %}Fitting result comparison - {% endblock title %} + +{% block content %} +

Comparison

+
+ loading... +
+
+ +

+
+
+ +
+

+ +
+
+ +{% endblock %} diff --git a/weblab/templates/fitting/fittingresultversion_confirm_delete.html b/weblab/templates/fitting/fittingresultversion_confirm_delete.html new file mode 100644 index 000000000..929d29e1a --- /dev/null +++ b/weblab/templates/fitting/fittingresultversion_confirm_delete.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Delete fitting experiment version - {% endblock title %} + +{% block content %} + +
{% csrf_token %} +

Are you sure you want to delete version {{ object.name }} of fitting experiment "{{ object.fittingresult }}"?

+

This operation cannot be undone.

+ +
+ +{% endblock %} diff --git a/weblab/templates/fitting/fittingresultversion_detail.html b/weblab/templates/fitting/fittingresultversion_detail.html new file mode 100644 index 000000000..4f1bf2cc9 --- /dev/null +++ b/weblab/templates/fitting/fittingresultversion_detail.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} +{% load staticfiles %} +{% load experiments %} + +{% block title %}Fitting result details - {% endblock title %} + +{% block body_id %}experiment-version{% endblock %} + +{% block content %} + {% with version.fittingresult as fittingresult %} + {% include "./includes/fittingresult_header.html" %} + +
+ +
+ +

+ Version: {{ version.name }} +

+
+ + Created + by {{ version.author.full_name }}. + {% if version.finished_at %}Took {{ version.created_at|timesince:version.finished_at }}.{% endif %} + Visibility: {{ version.visibility }} + help. + + + {% if perms.run_fits %} + rerun experiment + {% endif %} + + {% can_delete_entity version as can_delete %} + {% if can_delete %} + Delete fitting result version: + + delete this version of this fitting result + {% endif %} + +
Corresponding model: + {{ fittingresult.model.name }} @ {{ fittingresult.nice_model_version }} + & protocol: + {{ fittingresult.protocol.name }} @ {{ fittingresult.nice_protocol_version }} + +
+
+ + {% if not version.is_latest %} +

+ Note: there is a newer run of this fitting experiment. +

+ {% endif %} + + + + + + {% if fittingresult.protocol.protocol_experimental_datasets.exists %} + + {% endif %} + +
+
+ +

+
Created by + .
+
+
+ +
+

Files attached to this version

+

+ +
+ + Download + Download archive of all files + +
+
+
+
+ +{% endwith %} +{% endblock %} diff --git a/weblab/templates/fitting/includes/fittingresult_header.html b/weblab/templates/fitting/includes/fittingresult_header.html new file mode 100644 index 000000000..0a3aff632 --- /dev/null +++ b/weblab/templates/fitting/includes/fittingresult_header.html @@ -0,0 +1,19 @@ + +{% load staticfiles %} +{% load experiments %} + +

+ Fitting result: {{ fittingresult.name }} +

+ +
+ Created by {{fittingresult.author.full_name}}. + {% can_delete_entity fittingresult as can_delete %} + {% if can_delete %} + Delete fitting result: + + delete all versions of this fitting result + {% endif %} + +