diff --git a/weblab/conftest.py b/weblab/conftest.py index 0cff5a104..0cc618f05 100644 --- a/weblab/conftest.py +++ b/weblab/conftest.py @@ -13,6 +13,7 @@ from core import recipes from datasets.models import Dataset from entities.models import Entity +from fitting.models import FittingResult from repocache.populate import populate_entity_cache @@ -43,8 +44,11 @@ def add_version(entity, return commit @staticmethod - def cached_version(entity, **kwargs): - """Add a single commit/version to an entity and return the relevant repocache entry""" + def add_cached_version(entity, **kwargs): + """ + Add a single commit/version to an entity along with a repocache entry. + @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) @@ -99,6 +103,17 @@ def add_permission(user, perm, model=Entity): def login(client, user): client.login(username=user.email, password='password') + @staticmethod + def link_to_protocol(protocol, *objects): + """ + Link given objects to protocol (fitting specs or datasets) + @param protocol - protocol to link to + @param objects - list of objects to link to the protocol + """ + for obj in objects: + obj.protocol = protocol + obj.save() + @pytest.fixture def helpers(): @@ -156,28 +171,28 @@ def fittingspec_with_version(): @pytest.fixture def public_model(helpers): - model = recipes.model.make() + model = recipes.model.make(name='public model') helpers.add_version(model, visibility='public') return model @pytest.fixture def public_protocol(helpers): - protocol = recipes.protocol.make() + protocol = recipes.protocol.make(name='public protocol') helpers.add_version(protocol, visibility='public') return protocol @pytest.fixture def public_fittingspec(helpers): - fittingspec = recipes.fittingspec.make() + fittingspec = recipes.fittingspec.make(name='public fitting spec') helpers.add_version(fittingspec, visibility='public') return fittingspec @pytest.fixture -def public_dataset(helpers): - dataset = recipes.dataset.make(visibility='public') +def public_dataset(): + dataset = recipes.dataset.make(visibility='public', name='public dataset') return dataset @@ -195,6 +210,32 @@ def moderated_protocol(helpers): return protocol +@pytest.fixture +def private_model(helpers): + model = recipes.model.make(name='private model') + helpers.add_version(model, visibility='private') + return model + + +@pytest.fixture +def private_protocol(helpers): + protocol = recipes.protocol.make(name='private protocol') + helpers.add_version(protocol, visibility='private') + return protocol + + +@pytest.fixture +def private_fittingspec(helpers): + fittingspec = recipes.fittingspec.make(name='private fittingspec') + helpers.add_version(fittingspec, visibility='private') + return fittingspec + + +@pytest.fixture +def private_dataset(): + return recipes.dataset.make(name='private dataset', visibility='private') + + @pytest.fixture def queued_experiment(model_with_version, protocol_with_version): version = recipes.experiment_version.make( @@ -208,6 +249,22 @@ def queued_experiment(model_with_version, protocol_with_version): return version +@pytest.fixture +def queued_fittingresult(public_model, public_protocol, public_fittingspec, public_dataset): + version = recipes.fittingresult_version.make( + status='QUEUED', + 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, + ) + recipes.running_experiment.make(runnable=version) + return version + + @pytest.fixture def experiment_with_result(model_with_version, protocol_with_version): version = recipes.experiment_version.make( @@ -323,6 +380,18 @@ def moderator(user, helpers): return user +@pytest.fixture +def fits_user(logged_in_user): + """User with permission to run fittings""" + content_type = ContentType.objects.get_for_model(FittingResult) + permission = Permission.objects.get( + codename='run_fits', + content_type=content_type, + ) + logged_in_user.user_permissions.add(permission) + return logged_in_user + + @pytest.fixture def dataset_creator(user, helpers): helpers.add_permission(user, 'create_dataset', Dataset) diff --git a/weblab/core/recipes.py b/weblab/core/recipes.py index 1b289b485..88f4d0d8a 100644 --- a/weblab/core/recipes.py +++ b/weblab/core/recipes.py @@ -22,17 +22,17 @@ analysis_task = Recipe('AnalysisTask', entity=foreign_key(protocol)) -cached_model = Recipe('CachedModel') -cached_model_version = Recipe('CachedModelVersion') -cached_model_tag = Recipe('CachedModelTag') +cached_model = Recipe('CachedModel', entity=foreign_key(model)) +cached_model_version = Recipe('CachedModelVersion', entity=foreign_key(cached_model)) +cached_model_tag = Recipe('CachedModelTag', entity=foreign_key(cached_model)) -cached_protocol = Recipe('CachedProtocol') -cached_protocol_version = Recipe('CachedProtocolVersion') -cached_protocol_tag = Recipe('CachedProtocolTag') +cached_protocol = Recipe('CachedProtocol', entity=foreign_key(protocol)) +cached_protocol_version = Recipe('CachedProtocolVersion', entity=foreign_key(cached_protocol)) +cached_protocol_tag = Recipe('CachedProtocolTag', entity=foreign_key(cached_protocol)) -cached_fittingspec = Recipe('CachedFittingSpec') -cached_fittingspec_version = Recipe('CachedFittingSpecVersion') -cached_fittingspec_tag = Recipe('CachedFittingSpecTag') +cached_fittingspec = Recipe('CachedFittingSpec', entity=foreign_key(fittingspec)) +cached_fittingspec_version = Recipe('CachedFittingSpecVersion', entity=foreign_key(cached_fittingspec)) +cached_fittingspec_tag = Recipe('CachedFittingSpecTag', entity=foreign_key(cached_fittingspec)) experiment = Recipe( 'Experiment', @@ -48,7 +48,6 @@ running_experiment = Recipe('RunningExperiment', runnable=foreign_key(runnable)) - dataset = Recipe('Dataset', name=seq('my dataset'), protocol=foreign_key(protocol)) diff --git a/weblab/datasets/tests/test_views.py b/weblab/datasets/tests/test_views.py index 6ef75cf97..56e8fa7a9 100644 --- a/weblab/datasets/tests/test_views.py +++ b/weblab/datasets/tests/test_views.py @@ -400,6 +400,21 @@ def test_returns_404_for_nonexistent_file(self, my_dataset_with_file, client): @pytest.mark.django_db class TestDatasetArchiveView: + def test_anonymous_dataset_download_for_running_fittingresult( + self, client, queued_fittingresult, my_dataset_with_file, + ): + queued_fittingresult.fittingresult.dataset = my_dataset_with_file + queued_fittingresult.fittingresult.save() + + response = client.get( + '/datasets/%d/archive' % my_dataset_with_file.pk, + HTTP_AUTHORIZATION='Token {}'.format(queued_fittingresult.signature) + ) + + assert response.status_code == 200 + archive = zipfile.ZipFile(BytesIO(response.content)) + assert archive.filelist[0].filename == 'mydataset.csv' + def test_download_archive(self, my_dataset_with_file, client): response = client.get('/datasets/%d/archive' % my_dataset_with_file.pk) assert response.status_code == 200 diff --git a/weblab/datasets/views.py b/weblab/datasets/views.py index cfa03e9fc..3c506c0da 100644 --- a/weblab/datasets/views.py +++ b/weblab/datasets/views.py @@ -200,6 +200,17 @@ class DatasetArchiveView(VisibilityMixin, SingleObjectMixin, View): """ model = Dataset + def check_access_token(self, token): + """ + Override to allow token based access to dataset archive downloads - + must match a (fitting) `RunningExperiment` set up against the dataset. + """ + from experiments.models import RunningExperiment + return RunningExperiment.objects.filter( + id=token, + runnable__fittingresultversion__fittingresult__dataset=self.get_object().id, + ).exists() + def get_archive_name(self, dataset): return dataset.archive_name diff --git a/weblab/entities/tests/test_views.py b/weblab/entities/tests/test_views.py index 7457aef68..62864e407 100644 --- a/weblab/entities/tests/test_views.py +++ b/weblab/entities/tests/test_views.py @@ -1786,6 +1786,27 @@ def test_anonymous_protocol_download_for_running_experiment(self, client, queued archive = zipfile.ZipFile(BytesIO(response.content)) assert archive.filelist[0].filename == 'file1.txt' + @pytest.mark.parametrize("entity_type,url_fragment", [ + ('model', '/entities/models'), + ('protocol', '/entities/protocols'), + ('fittingspec', '/fitting/specs'), + ]) + def test_anonymous_entity_download_for_running_fittingresult( + self, client, queued_fittingresult, entity_type, url_fragment + ): + entity = getattr(queued_fittingresult.fittingresult, entity_type) + sha = entity.repo.latest_commit.sha + entity.set_version_visibility(sha, 'private') + + response = client.get( + '%s/%d/versions/latest/archive' % (url_fragment, entity.pk), + HTTP_AUTHORIZATION='Token {}'.format(queued_fittingresult.signature) + ) + + assert response.status_code == 200 + archive = zipfile.ZipFile(BytesIO(response.content)) + assert archive.filelist[0].filename == 'file1.txt' + def test_anonymous_protocol_download_for_analysis_task(self, client, analysis_task): protocol = analysis_task.entity protocol.set_version_visibility('latest', 'private') diff --git a/weblab/entities/views.py b/weblab/entities/views.py index 43375f667..32578879b 100644 --- a/weblab/entities/views.py +++ b/weblab/entities/views.py @@ -661,16 +661,30 @@ class EntityArchiveView(SingleObjectMixin, EntityVersionMixin, View): def check_access_token(self, token): """ Override to allow token based access to entity archive downloads - - must match a `RunningExperiment` or `AnalysisTask` object set up against the entity + must match a `RunningExperiment` or `AnalysisTask` object set up against the entity. + + We support both simulation and fitting experiments. """ from entities.models import AnalysisTask from experiments.models import RunningExperiment - entity_field = 'runnable__experimentversion__experiment__%s' % self.kwargs['entity_type'] self_id = self._get_object().id - return (RunningExperiment.objects.filter( - id=token, - **{entity_field: self_id} - ).exists() or AnalysisTask.objects.filter(id=token, entity=self_id).exists()) + if AnalysisTask.objects.filter(id=token, entity=self_id).exists(): + return True + + query_tpl = 'runnable__{subclass}version__{subclass}__{entity_type}' + entity_type = self.kwargs['entity_type'] + + # Look for all experiments linked to the given entity + # Fitting specs are not valid for simulation experiments, and go by + # a different field name for fitting experiments. + q_expt = RunningExperiment.objects.none() + if entity_type == 'spec': + entity_type = 'fittingspec' + else: + q_expt |= Q(**{query_tpl.format(subclass='experiment', entity_type=entity_type): self_id}) + + q_expt |= Q(**{query_tpl.format(subclass='fittingresult', entity_type=entity_type): self_id}) + return RunningExperiment.objects.filter(Q(id=token) & q_expt).exists() def get(self, request, *args, **kwargs): entity = self._get_object() diff --git a/weblab/experiments/processing.py b/weblab/experiments/processing.py index 3bf362941..8f560edd1 100644 --- a/weblab/experiments/processing.py +++ b/weblab/experiments/processing.py @@ -100,7 +100,7 @@ def submit_runnable(runnable, body, user): runnable.save() -def submit_experiment(model, model_version, protocol, protocol_version, user, rerun_ok): +def submit_experiment(model_version, protocol_version, user, rerun_ok): """Submit a Celery task to run an experiment. @param rerun_ok if False and an ExperimentVersion already exists, will just return that. @@ -108,10 +108,10 @@ def submit_experiment(model, model_version, protocol, protocol_version, user, re @return the ExperimentVersion for the run """ experiment, _ = Experiment.objects.get_or_create( - model=model, - protocol=protocol, - model_version=model.repocache.get_version(model_version), - protocol_version=protocol.repocache.get_version(protocol_version), + model=model_version.model, + protocol=protocol_version.protocol, + model_version=model_version, + protocol_version=protocol_version, defaults={ 'author': user, } @@ -138,17 +138,17 @@ def submit_experiment(model, model_version, protocol, protocol_version, user, re model_url = reverse( 'entities:entity_archive', - args=['model', model.pk, model_version] + args=['model', model_version.model.pk, model_version.sha] ) protocol_url = reverse( 'entities:entity_archive', - args=['protocol', protocol.pk, protocol_version] + args=['protocol', protocol_version.protocol.pk, protocol_version.sha] ) body = { 'model': urljoin(settings.CALLBACK_BASE_URL, model_url), 'protocol': urljoin(settings.CALLBACK_BASE_URL, protocol_url), } - if protocol.is_fitting_spec: + if protocol_version.protocol.is_fitting_spec: body['dataset'] = body['fittingSpec'] = body['protocol'] submit_runnable(version, body, user) diff --git a/weblab/experiments/tests/test_processing.py b/weblab/experiments/tests/test_processing.py index 2820a9df6..193088b58 100644 --- a/weblab/experiments/tests/test_processing.py +++ b/weblab/experiments/tests/test_processing.py @@ -39,13 +39,13 @@ def test_creates_new_experiment_and_side_effects( user, model_with_version, protocol_with_version): model = model_with_version protocol = protocol_with_version - model_version = model.repo.latest_commit.sha - protocol_version = protocol.repo.latest_commit.sha + model_version = model.repocache.latest_version + protocol_version = protocol.repocache.latest_version assert Experiment.objects.count() == 0 assert RunningExperiment.objects.count() == 0 - version, is_new = submit_experiment(model, model_version, protocol, protocol_version, user, False) + version, is_new = submit_experiment(model_version, protocol_version, user, False) assert is_new # Check properties of the new experiment & version @@ -53,16 +53,16 @@ def test_creates_new_experiment_and_side_effects( assert version.experiment.model == model assert version.experiment.protocol == protocol assert version.author == user - assert version.experiment.model_version.sha == model_version - assert version.experiment.protocol_version.sha == protocol_version + assert version.experiment.model_version == model_version + assert version.experiment.protocol_version == protocol_version assert version.experiment.author == user assert version.status == ExperimentVersion.STATUS_QUEUED # Check it did submit to the webservice - model_url = '/entities/models/%d/versions/%s/archive' % (model.pk, model_version) + model_url = '/entities/models/%d/versions/%s/archive' % (model.pk, model_version.sha) protocol_url = ( '/entities/protocols/%d/versions/%s/archive' % - (protocol.pk, protocol_version)) + (protocol.pk, protocol_version.sha)) assert mock_post.call_count == 1 assert mock_post.call_args[0][0] == settings.CHASTE_URL @@ -106,8 +106,7 @@ def test_uses_existing_experiment(self, mock_post, protocol=protocol, protocol_version=protocol_version) - version, is_new = submit_experiment(model, model_version.sha, - protocol, protocol_version.sha, user, False) + version, is_new = submit_experiment(model_version, protocol_version, user, False) assert is_new assert version.experiment == experiment @@ -121,7 +120,7 @@ def test_raises_exception_on_webservice_error(self, mock_post, mock_post.side_effect = generate_response('something %s') with pytest.raises(ProcessingException): - submit_experiment(model, model_version.sha, protocol, protocol_version.sha, user, False) + submit_experiment(model_version, protocol_version, user, False) # There should be no running experiment assert RunningExperiment.objects.count() == 0 @@ -144,7 +143,7 @@ def test_records_submission_error(self, mock_post, mock_post.side_effect = generate_response('%s an error occurred') - version, is_new = submit_experiment(model, model_version.sha, protocol, protocol_version.sha, user, False) + version, is_new = submit_experiment(model_version, protocol_version, user, False) assert is_new assert version.status == ExperimentVersion.STATUS_FAILED @@ -160,7 +159,7 @@ def test_records_inapplicable_result(self, mock_post, mock_post.side_effect = generate_response('%s inapplicable') - version, is_new = submit_experiment(model, model_version.sha, protocol, protocol_version.sha, user, False) + version, is_new = submit_experiment(model_version, protocol_version, user, False) assert is_new assert version.status == ExperimentVersion.STATUS_INAPPLICABLE diff --git a/weblab/experiments/views.py b/weblab/experiments/views.py index 0514d8784..bd4fa0dae 100644 --- a/weblab/experiments/views.py +++ b/weblab/experiments/views.py @@ -314,22 +314,24 @@ def post(self, request, *args, **kwargs): exp = exp_ver.experiment model = exp.model protocol = exp.protocol - model_version = exp.model_version.sha - protocol_version = exp.protocol_version.sha + model_sha = exp.model_version.sha + protocol_sha = exp.protocol_version.sha else: model = get_object_or_404(ModelEntity, pk=request.POST['model']) protocol = get_object_or_404(ProtocolEntity, pk=request.POST['protocol']) - model_version = request.POST['model_version'] - protocol_version = request.POST['protocol_version'] + model_sha = request.POST['model_version'] + protocol_sha = request.POST['protocol_version'] - version, is_new = submit_experiment(model, model_version, protocol, protocol_version, + model_version = model.repocache.get_version(model_sha) + protocol_version = protocol.repocache.get_version(protocol_sha) + version, is_new = submit_experiment(model_version, protocol_version, request.user, 'rerun' in request.POST or 'planned' in request.POST) queued = version.status == ExperimentVersion.STATUS_QUEUED if is_new and version.status != ExperimentVersion.STATUS_FAILED: # Remove from planned experiments PlannedExperiment.objects.filter( - model=model, model_version=model_version, - protocol=protocol, protocol_version=protocol_version + model=model, model_version=model_sha, + protocol=protocol, protocol_version=protocol_sha ).delete() version_url = reverse('experiments:version', diff --git a/weblab/fitting/forms.py b/weblab/fitting/forms.py index 4e239c67f..6c7073eee 100644 --- a/weblab/fitting/forms.py +++ b/weblab/fitting/forms.py @@ -1,8 +1,13 @@ +from braces.forms import UserKwargModelFormMixin +from django import forms +from django.core.exceptions import ValidationError +from datasets.models import Dataset from entities.forms import EntityForm, EntityRenameForm, EntityVersionForm -from entities.models import ProtocolEntity +from entities.models import ModelEntity, ProtocolEntity +from repocache.models import CachedFittingSpecVersion, CachedModelVersion, CachedProtocolVersion -from .models import FittingSpec +from .models import FittingResult, FittingSpec class FittingSpecForm(EntityForm): @@ -32,3 +37,68 @@ class FittingSpecRenameForm(EntityRenameForm): class Meta: model = FittingSpec fields = ['name'] + + +class VersionChoiceField(forms.ModelChoiceField): + def label_from_instance(self, obj): + return obj.nice_version() + + +class FittingResultCreateForm(UserKwargModelFormMixin, forms.ModelForm): + """Used for creating and running a new fitting result""" + model_version = VersionChoiceField(queryset=CachedModelVersion.objects.all()) + protocol_version = VersionChoiceField(queryset=CachedProtocolVersion.objects.all()) + fittingspec_version = VersionChoiceField(queryset=CachedFittingSpecVersion.objects.all(), + label='Fitting specification version') + + class Meta: + model = FittingResult + fields = ('model', 'model_version', 'protocol', 'protocol_version', + 'fittingspec', 'fittingspec_version', 'dataset') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['fittingspec'].label = 'Fitting specification' + + # Disable fields with preselected values + # But only when there is no data bound - otherwise we will lose the + # posted values of these fields from the submitted form. + if not self.is_bound: + self.fields['model'].disabled = bool(self.initial.get('model')) + self.fields['protocol'].disabled = bool(self.initial.get('protocol')) + self.fields['fittingspec'].disabled = bool(self.initial.get('fittingspec')) + self.fields['dataset'].disabled = bool(self.initial.get('dataset')) + + # Ensure only visible entities and versions are available to user + self.fields['model'].queryset = ModelEntity.objects.visible_to_user(self.user) + self.fields['protocol'].queryset = ProtocolEntity.objects.visible_to_user(self.user) + self.fields['fittingspec'].queryset = FittingSpec.objects.visible_to_user(self.user) + self.fields['dataset'].queryset = Dataset.objects.visible_to_user(self.user) + + self.fields['model_version'].queryset = CachedModelVersion.objects.visible_to_user(self.user) + self.fields['protocol_version'].queryset = CachedProtocolVersion.objects.visible_to_user(self.user) + self.fields['fittingspec_version'].queryset = CachedFittingSpecVersion.objects.visible_to_user(self.user) + + def clean(self): + # Check ownership of versions by entities + model_version = self.cleaned_data.get('model_version') + if model_version and model_version.model != self.cleaned_data.get('model'): + raise ValidationError({'model_version': 'Model version must belong to model'}) + + protocol_version = self.cleaned_data.get('protocol_version') + if protocol_version and protocol_version.protocol != self.cleaned_data['protocol']: + raise ValidationError({'protocol_version': 'Protocol version must belong to protocol'}) + + fittingspec_version = self.cleaned_data.get('fittingspec_version') + if fittingspec_version and fittingspec_version.fittingspec != self.cleaned_data['fittingspec']: + raise ValidationError({'fittingspec_version': 'Fitting spec version must belong to fitting spec'}) + + # Check linkage between protocols, datasets and fitting specs + protocol = self.cleaned_data.get('protocol') + dataset = self.cleaned_data.get('dataset') + if dataset and dataset.protocol != protocol: + raise ValidationError({'protocol': 'Protocol and dataset must match'}) + + fittingspec = self.cleaned_data.get('fittingspec') + if fittingspec and fittingspec.protocol != protocol: + raise ValidationError({'protocol': 'Protocol and fitting spec must match'}) diff --git a/weblab/fitting/models.py b/weblab/fitting/models.py index cd8a497f8..9c73eb976 100644 --- a/weblab/fitting/models.py +++ b/weblab/fitting/models.py @@ -82,6 +82,11 @@ class FittingResult(ExperimentMixin, UserCreatedModelMixin, models.Model): default=None, null=False, related_name='fit_ver_fitres', ) + @property + def nice_fittingspec_version(self): + """Use tags to give a nicer representation of the commit id""" + return self.fittingspec_version.nice_version() + class Meta: unique_together = ('fittingspec', 'dataset', 'model', 'protocol', 'fittingspec_version', 'model_version', 'protocol_version') diff --git a/weblab/fitting/processing.py b/weblab/fitting/processing.py index d504ddb7c..1256ae012 100644 --- a/weblab/fitting/processing.py +++ b/weblab/fitting/processing.py @@ -14,9 +14,9 @@ def submit_fitting( - model, model_version, - protocol, protocol_version, - fittingspec, fittingspec_version, + model_version, + protocol_version, + fittingspec_version, dataset, user, rerun_ok, ): """Submit a Celery task to run a fitting experiment @@ -26,13 +26,13 @@ def submit_fitting( @return the FittingResultVersion for the run """ fittingresult, _ = FittingResult.objects.get_or_create( - model=model, - protocol=protocol, - fittingspec=fittingspec, + model=model_version.model, + protocol=protocol_version.protocol, + fittingspec=fittingspec_version.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), + model_version=model_version, + protocol_version=protocol_version, + fittingspec_version=fittingspec_version, defaults={ 'author': user, } @@ -59,15 +59,15 @@ def submit_fitting( model_url = reverse( 'entities:entity_archive', - args=['model', model.pk, model_version] + args=['model', model_version.model.pk, model_version.sha] ) protocol_url = reverse( 'entities:entity_archive', - args=['protocol', protocol.pk, protocol_version] + args=['protocol', protocol_version.protocol.pk, protocol_version.sha] ) fittingspec_url = reverse( 'fitting:entity_archive', - args=['spec', fittingspec.pk, fittingspec_version] + args=['spec', fittingspec_version.fittingspec.pk, fittingspec_version.sha] ) dataset_url = reverse( 'datasets:archive', diff --git a/weblab/fitting/tests/test_forms.py b/weblab/fitting/tests/test_forms.py new file mode 100644 index 000000000..839ae466d --- /dev/null +++ b/weblab/fitting/tests/test_forms.py @@ -0,0 +1,247 @@ +import pytest + +from core import recipes +from fitting.forms import FittingResultCreateForm + + +@pytest.mark.django_db +class TestFittingResultCreateForm: + def test_fields_exist(self, fits_user): + form = FittingResultCreateForm(user=fits_user) + assert 'model' in form.fields + assert 'model_version' in form.fields + assert 'protocol' in form.fields + assert 'protocol_version' in form.fields + assert 'fittingspec' in form.fields + assert 'fittingspec_version' in form.fields + assert 'dataset' in form.fields + + def test_fields_enabled_by_default(self, fits_user): + form = FittingResultCreateForm(user=fits_user) + assert not form.fields['model'].disabled + assert not form.fields['protocol'].disabled + assert not form.fields['fittingspec'].disabled + assert not form.fields['dataset'].disabled + + def test_disables_preselected_model(self, public_model, fits_user): + form = FittingResultCreateForm(initial={'model': public_model.pk}, user=fits_user) + assert form.fields['model'].disabled + + def test_disables_preselected_protocol(self, public_protocol, fits_user): + form = FittingResultCreateForm(initial={'protocol': public_protocol.pk}, user=fits_user) + assert form.fields['protocol'].disabled + + def test_disables_preselected_fittingspec(self, public_fittingspec, fits_user): + form = FittingResultCreateForm(initial={'fittingspec': public_fittingspec.pk}, user=fits_user) + assert form.fields['fittingspec'].disabled + + def test_disables_preselected_dataset(self, public_dataset, fits_user): + form = FittingResultCreateForm(initial={'dataset': public_dataset.pk}, user=fits_user) + assert form.fields['dataset'].disabled + + def test_valid_form( + self, public_model, public_protocol, + public_fittingspec, public_dataset, fits_user, helpers + ): + helpers.link_to_protocol(public_protocol, public_dataset, public_fittingspec) + + form = FittingResultCreateForm({ + 'model': public_model.pk, + 'model_version': public_model.repocache.latest_version.pk, + 'protocol': public_protocol.pk, + 'protocol_version': public_protocol.repocache.latest_version.pk, + 'fittingspec': public_fittingspec.pk, + 'fittingspec_version': public_fittingspec.repocache.latest_version.pk, + 'dataset': public_dataset.pk, + }, user=fits_user) + assert form.is_valid() + + def test_model_version_must_be_visible( + self, private_model, public_protocol, + public_fittingspec, public_dataset, fits_user, helpers + ): + helpers.link_to_protocol(public_protocol, public_dataset, public_fittingspec) + + form = FittingResultCreateForm({ + 'model': private_model.pk, + 'model_version': private_model.repocache.latest_version.pk, + 'protocol': public_protocol.pk, + 'protocol_version': public_protocol.repocache.latest_version.pk, + 'fittingspec': public_fittingspec.pk, + 'fittingspec_version': public_fittingspec.repocache.latest_version.pk, + 'dataset': public_dataset.pk, + }, user=fits_user) + assert not form.is_valid() + assert 'model_version' in form.errors + assert form.errors['model_version'][0].startswith("Select a valid choice.") + + def test_protocol_version_must_be_visible( + self, public_model, private_protocol, + public_fittingspec, public_dataset, fits_user, helpers + ): + helpers.link_to_protocol(private_protocol, public_dataset, public_fittingspec) + + form = FittingResultCreateForm({ + 'model': public_model.pk, + 'model_version': public_model.repocache.latest_version.pk, + 'protocol': private_protocol.pk, + 'protocol_version': private_protocol.repocache.latest_version.pk, + 'fittingspec': public_fittingspec.pk, + 'fittingspec_version': public_fittingspec.repocache.latest_version.pk, + 'dataset': public_dataset.pk, + }, user=fits_user) + assert not form.is_valid() + assert 'protocol_version' in form.errors + assert form.errors['protocol_version'][0].startswith("Select a valid choice.") + + def test_fittingspec_version_must_be_visible( + self, public_model, public_protocol, + private_fittingspec, public_dataset, fits_user, helpers + ): + helpers.link_to_protocol(public_protocol, public_dataset, private_fittingspec) + + form = FittingResultCreateForm({ + 'model': public_model.pk, + 'model_version': public_model.repocache.latest_version.pk, + 'protocol': public_protocol.pk, + 'protocol_version': public_protocol.repocache.latest_version.pk, + 'fittingspec': private_fittingspec.pk, + 'fittingspec_version': private_fittingspec.repocache.latest_version.pk, + 'dataset': public_dataset.pk, + }, user=fits_user) + assert not form.is_valid() + assert 'fittingspec_version' in form.errors + assert form.errors['fittingspec_version'][0].startswith("Select a valid choice.") + + def test_dataset_must_be_visible( + self, public_model, public_protocol, public_fittingspec, private_dataset, fits_user, helpers + ): + helpers.link_to_protocol(public_protocol, private_dataset, public_fittingspec) + + form = FittingResultCreateForm({ + 'model': public_model.pk, + 'model_version': public_model.repocache.latest_version.pk, + 'protocol': public_protocol.pk, + 'protocol_version': public_protocol.repocache.latest_version.pk, + 'fittingspec': public_fittingspec.pk, + 'fittingspec_version': public_fittingspec.repocache.latest_version.pk, + 'dataset': private_dataset.pk, + }, user=fits_user) + assert not form.is_valid() + assert 'dataset' in form.errors + assert form.errors['dataset'][0].startswith("Select a valid choice.") + + def test_only_shows_visible_models(self, private_model, public_model, fits_user): + form = FittingResultCreateForm(user=fits_user) + assert form.fields['model'].valid_value(public_model.pk) + assert not form.fields['model'].valid_value(private_model.pk) + + def test_only_shows_visible_protocols(self, private_protocol, public_protocol, fits_user): + form = FittingResultCreateForm(user=fits_user) + assert form.fields['protocol'].valid_value(public_protocol.pk) + assert not form.fields['protocol'].valid_value(private_protocol.pk) + + def test_only_shows_visible_fittingspecs(self, private_fittingspec, public_fittingspec, fits_user): + form = FittingResultCreateForm(user=fits_user) + assert form.fields['fittingspec'].valid_value(public_fittingspec.pk) + assert not form.fields['fittingspec'].valid_value(private_fittingspec.pk) + + def test_only_shows_visible_datasets(self, private_dataset, public_dataset, fits_user): + form = FittingResultCreateForm(user=fits_user) + assert form.fields['dataset'].valid_value(public_dataset.pk) + assert not form.fields['dataset'].valid_value(private_dataset.pk) + + def test_model_version_must_belong_to_model( + self, public_model, public_protocol, + public_fittingspec, public_dataset, fits_user, helpers + ): + helpers.link_to_protocol(public_protocol, public_dataset, public_fittingspec) + + invalid_version = recipes.cached_model_version.make() + form = FittingResultCreateForm({ + 'model': public_model.pk, + 'model_version': invalid_version.pk, + 'protocol': public_protocol.pk, + 'protocol_version': public_protocol.repocache.latest_version.pk, + 'fittingspec': public_fittingspec.pk, + 'fittingspec_version': public_fittingspec.repocache.latest_version.pk, + 'dataset': public_dataset.pk, + }, user=fits_user) + assert not form.is_valid() + assert 'model_version' in form.errors + + def test_protocol_version_must_belong_to_protocol( + self, public_model, public_protocol, + public_fittingspec, public_dataset, fits_user, helpers + ): + helpers.link_to_protocol(public_protocol, public_dataset, public_fittingspec) + + invalid_version = recipes.cached_protocol_version.make() + form = FittingResultCreateForm({ + 'model': public_model.pk, + 'model_version': public_model.repocache.latest_version.pk, + 'protocol': public_protocol.pk, + 'protocol_version': invalid_version.pk, + 'fittingspec': public_fittingspec.pk, + 'fittingspec_version': public_fittingspec.repocache.latest_version.pk, + 'dataset': public_dataset.pk, + }, user=fits_user) + assert not form.is_valid() + assert 'protocol_version' in form.errors + + def test_fittingspec_version_must_belong_to_fittingspec( + self, public_model, public_protocol, + public_fittingspec, public_dataset, fits_user, helpers + ): + + helpers.link_to_protocol(public_protocol, public_dataset, public_fittingspec) + + invalid_version = recipes.cached_fittingspec_version.make() + form = FittingResultCreateForm({ + 'model': public_model.pk, + 'model_version': public_model.repocache.latest_version.pk, + 'protocol': public_protocol.pk, + 'protocol_version': public_protocol.repocache.latest_version.pk, + 'fittingspec': public_fittingspec.pk, + 'fittingspec_version': invalid_version.pk, + 'dataset': public_dataset.pk, + }, user=fits_user) + assert not form.is_valid() + assert 'fittingspec_version' in form.errors + + def test_protocol_and_fittingspec_must_be_linked( + self, public_model, public_protocol, + public_fittingspec, public_dataset, fits_user, helpers + ): + helpers.link_to_protocol(public_protocol, public_dataset) + + form = FittingResultCreateForm({ + 'model': public_model.pk, + 'model_version': public_model.repocache.latest_version.pk, + 'protocol': public_protocol.pk, + 'protocol_version': public_protocol.repocache.latest_version.pk, + 'fittingspec': public_fittingspec.pk, + 'fittingspec_version': public_fittingspec.repocache.latest_version.pk, + 'dataset': public_dataset.pk, + }, user=fits_user) + assert not form.is_valid() + assert 'protocol' in form.errors + + def test_protocol_and_dataset_must_be_linked( + self, public_model, public_protocol, + public_fittingspec, public_dataset, fits_user, helpers + ): + + helpers.link_to_protocol(public_protocol, public_fittingspec) + + form = FittingResultCreateForm({ + 'model': public_model.pk, + 'model_version': public_model.repocache.latest_version.pk, + 'protocol': public_protocol.pk, + 'protocol_version': public_protocol.repocache.latest_version.pk, + 'fittingspec': public_fittingspec.pk, + 'fittingspec_version': public_fittingspec.repocache.latest_version.pk, + 'dataset': public_dataset.pk, + }, user=fits_user) + assert not form.is_valid() + assert 'protocol' in form.errors diff --git a/weblab/fitting/tests/test_models.py b/weblab/fitting/tests/test_models.py index 3835b3914..3aa77633a 100644 --- a/weblab/fitting/tests/test_models.py +++ b/weblab/fitting/tests/test_models.py @@ -139,6 +139,7 @@ def test_nice_versions(self, fittingresult_version): assert fitres.nice_model_version == fitres.model.repocache.latest_version.sha[:8] + '...' assert fitres.nice_protocol_version == fitres.protocol.repocache.latest_version.sha[:8] + '...' + assert fitres.nice_fittingspec_version == fitres.fittingspec.repocache.latest_version.sha[:8] + '...' fitres.model.repo.tag('v1') populate_entity_cache(fitres.model) @@ -146,9 +147,12 @@ def test_nice_versions(self, fittingresult_version): fitres.protocol.repo.tag('v2') populate_entity_cache(fitres.protocol) - assert fitres.nice_protocol_version == 'v2' + fitres.fittingspec.repo.tag('v3') + populate_entity_cache(fitres.fittingspec) + assert fitres.nice_fittingspec_version == 'v3' + def test_visibility(self, helpers): model = recipes.model.make() protocol = recipes.protocol.make() @@ -156,12 +160,12 @@ def test_visibility(self, helpers): 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') + mv1 = helpers.add_cached_version(model, visibility='private') + mv2 = helpers.add_cached_version(model, visibility='public') + pv1 = helpers.add_cached_version(protocol, visibility='private') + pv2 = helpers.add_cached_version(protocol, visibility='public') + fv1 = helpers.add_cached_version(fittingspec, visibility='private') + fv2 = helpers.add_cached_version(fittingspec, visibility='public') # all public assert recipes.fittingresult.make( @@ -224,9 +228,9 @@ def test_viewers(self, helpers, user): # (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') + mv = helpers.add_cached_version(model, visibility='private') + pv = helpers.add_cached_version(protocol, visibility='private') + fv = helpers.add_cached_version(fittingspec, visibility='private') fr = recipes.fittingresult.make( model=model, model_version=mv, @@ -254,9 +258,9 @@ def test_viewers_of_public_fittingresult(self, helpers, user): 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') + mv = helpers.add_cached_version(model, visibility='public') + pv = helpers.add_cached_version(protocol, visibility='public') + fv = helpers.add_cached_version(fittingspec, visibility='public') fr = recipes.fittingresult.make( model=model, model_version=mv, diff --git a/weblab/fitting/tests/test_processing.py b/weblab/fitting/tests/test_processing.py index b4b6f7928..d562428e5 100644 --- a/weblab/fitting/tests/test_processing.py +++ b/weblab/fitting/tests/test_processing.py @@ -41,19 +41,16 @@ def test_creates_new_fittingresult_and_side_effects( 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 + model_version = model.repocache.latest_version + protocol_version = protocol.repocache.latest_version + fittingspec_version = fittingspec.repocache.latest_version 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, @@ -68,20 +65,20 @@ def test_creates_new_fittingresult_and_side_effects( 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.model_version == model_version + assert version.fittingresult.protocol_version == protocol_version + assert version.fittingresult.fittingspec_version == 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) + model_url = '/entities/models/%d/versions/%s/archive' % (model.pk, model_version.sha) protocol_url = ( '/entities/protocols/%d/versions/%s/archive' % - (protocol.pk, protocol_version)) + (protocol.pk, protocol_version.sha)) fittingspec_url = ( '/fitting/specs/%d/versions/%s/archive' % - (fittingspec.pk, fittingspec_version)) + (fittingspec.pk, fittingspec_version.sha)) dataset_url = '/datasets/%d/archive' % (dataset.pk) assert mock_post.call_count == 1 @@ -137,12 +134,9 @@ def test_uses_existing_fittingresult( ) version, is_new = submit_fitting( - fittingresult.model, - fittingresult.model_version.sha, - fittingresult.protocol, - fittingresult.protocol_version.sha, - fittingresult.fittingspec, - fittingresult.fittingspec_version.sha, + fittingresult.model_version, + fittingresult.protocol_version, + fittingresult.fittingspec_version, fittingresult.dataset, user, False, @@ -166,9 +160,9 @@ def test_raises_exception_on_webservice_error( 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, + model_version, + protocol_version, + fittingspec_version, dataset, user, False @@ -201,9 +195,9 @@ def test_records_submission_error( 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, + model_version, + protocol_version, + fittingspec_version, dataset, user, False @@ -229,9 +223,9 @@ def test_records_inapplicable_result( 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, + model_version, + protocol_version, + fittingspec_version, dataset, user, False diff --git a/weblab/fitting/tests/test_views.py b/weblab/fitting/tests/test_views.py index f7b633b42..5c6969452 100644 --- a/weblab/fitting/tests/test_views.py +++ b/weblab/fitting/tests/test_views.py @@ -3,6 +3,7 @@ import zipfile from io import BytesIO from pathlib import Path +from unittest.mock import patch import pytest from django.core.urlresolvers import reverse @@ -426,6 +427,349 @@ def test_empty_fittingresult_list(self, client, fittingresult_version): assert len(data['getEntityInfos']['entities']) == 0 +@pytest.mark.django_db +class TestCreateFittingResultView: + def test_requires_login(self, client): + response = client.get('/fitting/results/new') + assert response.status_code == 302 + + def test_requires_permission(self, client, logged_in_user): + response = client.get('/fitting/results/new') + assert response.status_code == 302 + + def test_basic_page(self, client, fits_user): + response = client.get('/fitting/results/new') + assert response.status_code == 200 + assert 'form' in response.context + + def test_with_preselected_model(self, client, fits_user, public_model): + response = client.get('/fitting/results/new', {'model': public_model.pk}) + assert response.status_code == 200 + assert response.context['form'].initial['model'] == public_model + + def test_with_preselected_protocol(self, client, fits_user, public_protocol): + response = client.get('/fitting/results/new', {'protocol': public_protocol.pk}) + assert response.status_code == 200 + assert response.context['form'].initial['protocol'] == public_protocol + + def test_with_preselected_fittingspec(self, client, fits_user, public_fittingspec): + response = client.get('/fitting/results/new', {'fittingspec': public_fittingspec.pk}) + assert response.status_code == 200 + assert response.context['form'].initial['fittingspec'] == public_fittingspec + + def test_with_preselected_dataset(self, client, fits_user, public_dataset): + response = client.get('/fitting/results/new', {'dataset': public_dataset.pk}) + assert response.status_code == 200 + assert response.context['form'].initial['dataset'] == public_dataset + + def test_with_preselected_model_version(self, client, fits_user, public_model): + version = public_model.repocache.latest_version + response = client.get('/fitting/results/new', {'model_version': version.pk}) + assert response.status_code == 200 + assert response.context['form'].initial['model'] == public_model + assert response.context['form'].initial['model_version'] == version + + def test_with_preselected_protocol_version(self, client, fits_user, public_protocol): + version = public_protocol.repocache.latest_version + response = client.get('/fitting/results/new', {'protocol_version': version.pk}) + assert response.status_code == 200 + assert response.context['form'].initial['protocol'] == public_protocol + assert response.context['form'].initial['protocol_version'] == version + + def test_with_preselected_fittingspec_version(self, client, fits_user, public_fittingspec): + version = public_fittingspec.repocache.latest_version + response = client.get('/fitting/results/new', {'fittingspec_version': version.pk}) + assert response.status_code == 200 + assert response.context['form'].initial['fittingspec'] == public_fittingspec + assert response.context['form'].initial['fittingspec_version'] == version + + def test_with_non_visible_model(self, client, fits_user, private_model): + response = client.get('/fitting/results/new', {'model': private_model.pk}) + assert response.status_code == 404 + + def test_with_non_visible_model_version(self, client, fits_user, private_model): + version = private_model.repocache.latest_version + response = client.get('/fitting/results/new', {'model_version': version.pk}) + assert response.status_code == 404 + + def test_with_non_visible_protocol(self, client, fits_user, private_protocol): + response = client.get('/fitting/results/new', {'protocol': private_protocol.pk}) + assert response.status_code == 404 + + def test_with_non_visible_protocol_version(self, client, fits_user, private_protocol): + version = private_protocol.repocache.latest_version + response = client.get('/fitting/results/new', {'protocol_version': version.pk}) + assert response.status_code == 404 + + def test_with_non_visible_fittingspec(self, client, fits_user, private_fittingspec): + response = client.get('/fitting/results/new', {'fittingspec': private_fittingspec.pk}) + assert response.status_code == 404 + + def test_with_non_visible_fittingspec_version(self, client, fits_user, private_fittingspec): + version = private_fittingspec.repocache.latest_version + response = client.get('/fitting/results/new', {'fittingspec_version': version.pk}) + assert response.status_code == 404 + + def test_with_non_visible_dataset(self, client, fits_user, private_dataset): + response = client.get('/fitting/results/new', {'dataset': private_dataset.pk}) + assert response.status_code == 404 + + @patch('fitting.views.submit_fitting') + def test_submits_to_backend(self, mock_submit, client, fits_user, public_model, public_protocol, + public_fittingspec, public_dataset, helpers): + model_version = public_model.repocache.latest_version + protocol_version = public_protocol.repocache.latest_version + fittingspec_version = public_fittingspec.repocache.latest_version + helpers.link_to_protocol(public_protocol, public_fittingspec, public_dataset) + + runnable = recipes.fittingresult_version.make() + mock_submit.return_value = (runnable, False) + + response = client.post('/fitting/results/new', { + 'model': public_model.pk, + 'model_version': model_version.pk, + 'protocol': public_protocol.pk, + 'protocol_version': protocol_version.pk, + 'fittingspec': public_fittingspec.pk, + 'fittingspec_version': fittingspec_version.pk, + 'dataset': public_dataset.pk, + }) + + assert response.status_code == 302 + mock_submit.assert_called_with( + model_version, + protocol_version, + fittingspec_version, + public_dataset, fits_user, True + ) + + assert response.url == '/fitting/results/%d/versions/%d' % (runnable.fittingresult.pk, runnable.pk) + + +@pytest.mark.django_db +class TestFittingResultFilterJsonView: + def test_requires_login(self, client): + response = client.get('/fitting/results/new/filter') + assert response.status_code == 302 + + def test_requires_permission(self, client, logged_in_user): + response = client.get('/fitting/results/new/filter') + assert response.status_code == 302 + + def test_all_models_and_versions(self, client, fits_user, helpers): + model1 = recipes.model.make() + model2 = recipes.model.make() + m1v1 = helpers.add_cached_version(model1, visibility='public') + m2v1 = helpers.add_cached_version(model2, visibility='public') + + response = client.get('/fitting/results/new/filter', {}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + assert 'fittingResultOptions' in data + options = data['fittingResultOptions'] + assert set(options['models']) == {model1.id, model2.id} + assert set(options['model_versions']) == {m1v1.id, m2v1.id} + + def test_model_and_version_must_be_visible_to_user(self, client, fits_user, helpers): + model1 = recipes.model.make() + model2 = recipes.model.make() + m1v1 = helpers.add_cached_version(model1, visibility='public') + m2v1 = helpers.add_cached_version(model2, visibility='private') # noqa: F841 + + response = client.get('/fitting/results/new/filter', {}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + assert 'fittingResultOptions' in data + options = data['fittingResultOptions'] + assert set(options['models']) == {model1.id} + assert set(options['model_versions']) == {m1v1.id} + + def test_all_protocols_and_versions(self, client, fits_user, helpers): + protocol1 = recipes.protocol.make() + protocol2 = recipes.protocol.make() + p1v1 = helpers.add_cached_version(protocol1, visibility='public') + p2v1 = helpers.add_cached_version(protocol2, visibility='public') + + response = client.get('/fitting/results/new/filter', {}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + options = data['fittingResultOptions'] + assert set(options['protocols']) == {protocol1.id, protocol2.id} + assert set(options['protocol_versions']) == {p1v1.id, p2v1.id} + + def test_protocol_and_version_must_be_visible_to_user(self, client, fits_user, helpers): + protocol1 = recipes.protocol.make() + protocol2 = recipes.protocol.make() + p1v1 = helpers.add_cached_version(protocol1, visibility='public') + p2v1 = helpers.add_cached_version(protocol2, visibility='private') # noqa: F841 + + response = client.get('/fitting/results/new/filter', {}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + assert 'fittingResultOptions' in data + options = data['fittingResultOptions'] + assert set(options['protocols']) == {protocol1.id} + assert set(options['protocol_versions']) == {p1v1.id} + + def test_all_fittingspecs_and_versions(self, client, fits_user, helpers): + fittingspec1 = recipes.fittingspec.make() + fittingspec2 = recipes.fittingspec.make() + f1v1 = helpers.add_cached_version(fittingspec1, visibility='public') + f2v1 = helpers.add_cached_version(fittingspec2, visibility='public') + + response = client.get('/fitting/results/new/filter', {}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + options = data['fittingResultOptions'] + assert set(options['fittingspecs']) == {fittingspec1.id, fittingspec2.id} + assert set(options['fittingspec_versions']) == {f1v1.id, f2v1.id} + + def test_fittingspec_and_version_must_be_visible_to_user(self, client, fits_user, helpers): + fittingspec1 = recipes.fittingspec.make() + fittingspec2 = recipes.fittingspec.make() + p1v1 = helpers.add_cached_version(fittingspec1, visibility='public') + p2v1 = helpers.add_cached_version(fittingspec2, visibility='private') # noqa: F841 + + response = client.get('/fitting/results/new/filter', {}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + assert 'fittingResultOptions' in data + options = data['fittingResultOptions'] + assert set(options['fittingspecs']) == {fittingspec1.id} + assert set(options['fittingspec_versions']) == {p1v1.id} + + def test_all_datasets(self, client, fits_user): + dataset1 = recipes.dataset.make(visibility='public') + dataset2 = recipes.dataset.make(visibility='public') + + response = client.get('/fitting/results/new/filter', {}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + options = data['fittingResultOptions'] + assert set(options['datasets']) == {dataset1.id, dataset2.id} + + def test_dataset_must_be_visible_to_user(self, client, fits_user, helpers): + dataset1 = recipes.dataset.make(visibility='public') + dataset2 = recipes.dataset.make(visibility='private') # noqa: F841 + + response = client.get('/fitting/results/new/filter', {}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + assert 'fittingResultOptions' in data + options = data['fittingResultOptions'] + assert set(options['datasets']) == {dataset1.id} + + def test_versions_restricted_when_model_selected(self, client, fits_user, helpers): + model1 = recipes.model.make() + model2 = recipes.model.make() + m1v1 = helpers.add_cached_version(model1, visibility='public') + m2v1 = helpers.add_cached_version(model2, visibility='public') # noqa: F841 + + response = client.get('/fitting/results/new/filter', {'model': model1.id}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + options = data['fittingResultOptions'] + assert options['models'] == [model1.id, model2.id] + assert options['model_versions'] == [m1v1.id] + + def test_versions_restricted_when_protocol_selected(self, client, fits_user, helpers): + protocol1 = recipes.protocol.make() + protocol2 = recipes.protocol.make() + p1v1 = helpers.add_cached_version(protocol1, visibility='public') + p2v1 = helpers.add_cached_version(protocol2, visibility='public') # noqa: F841 + + response = client.get('/fitting/results/new/filter', {'protocol': protocol1.id}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + options = data['fittingResultOptions'] + assert options['protocols'] == [protocol1.id, protocol2.id] + assert options['protocol_versions'] == [p1v1.id] + + def test_versions_restricted_when_fittingspec_selected(self, client, fits_user, helpers): + fittingspec1 = recipes.fittingspec.make() + fittingspec2 = recipes.fittingspec.make() + f1v1 = helpers.add_cached_version(fittingspec1, visibility='public') + f2v1 = helpers.add_cached_version(fittingspec2, visibility='public') # noqa: F841 + + response = client.get('/fitting/results/new/filter', {'fittingspec': fittingspec1.id}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + options = data['fittingResultOptions'] + assert options['fittingspecs'] == [fittingspec1.id, fittingspec2.id] + assert options['fittingspec_versions'] == [f1v1.id] + + def test_dataset_and_fittingspec_restricted_when_protocol_selected(self, client, fits_user, helpers): + protocol = recipes.protocol.make() + helpers.add_cached_version(protocol, visibility='public') + fittingspec1 = recipes.fittingspec.make(protocol=protocol) + fittingspec2 = recipes.fittingspec.make() + f1v1 = helpers.add_cached_version(fittingspec1, visibility='public') + f2v1 = helpers.add_cached_version(fittingspec2, visibility='public') # noqa: F841 + dataset1 = recipes.dataset.make(protocol=protocol, visibility='public') + dataset2 = recipes.dataset.make(visibility='public') # noqa: F841 + + response = client.get('/fitting/results/new/filter', {'protocol': protocol.id}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + options = data['fittingResultOptions'] + assert options['datasets'] == [dataset1.id] + assert options['fittingspecs'] == [fittingspec1.id] + assert options['fittingspec_versions'] == [f1v1.id] + + def test_protocol_and_fittingspec_restricted_when_dataset_selected(self, client, fits_user, helpers): + protocol1 = recipes.protocol.make() + protocol2 = recipes.protocol.make() + p1v1 = helpers.add_cached_version(protocol1, visibility='public') + p2v1 = helpers.add_cached_version(protocol2, visibility='public') # noqa: F841 + fittingspec1 = recipes.fittingspec.make(protocol=protocol1) + fittingspec2 = recipes.fittingspec.make() + f1v1 = helpers.add_cached_version(fittingspec1, visibility='public') + f2v1 = helpers.add_cached_version(fittingspec2, visibility='public') # noqa: F841 + + dataset = recipes.dataset.make(protocol=protocol1, visibility='public') + + response = client.get('/fitting/results/new/filter', {'dataset': dataset.id}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + options = data['fittingResultOptions'] + assert options['protocols'] == [protocol1.id] + assert options['protocol_versions'] == [p1v1.id] + assert options['fittingspecs'] == [fittingspec1.id] + assert options['fittingspec_versions'] == [f1v1.id] + + def test_protocol_and_dataset_restricted_when_fittingspec_selected(self, client, fits_user, helpers): + protocol1 = recipes.protocol.make() + protocol2 = recipes.protocol.make() + p1v1 = helpers.add_cached_version(protocol1, visibility='public') + p2v1 = helpers.add_cached_version(protocol2, visibility='public') # noqa: F841 + dataset1 = recipes.dataset.make(protocol=protocol1, visibility='public') + dataset2 = recipes.dataset.make(visibility='public') # noqa: F841 + + fittingspec = recipes.fittingspec.make(protocol=protocol1) + + response = client.get('/fitting/results/new/filter', {'fittingspec': fittingspec.id}) + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + options = data['fittingResultOptions'] + assert options['protocols'] == [protocol1.id] + assert options['protocol_versions'] == [p1v1.id] + assert options['datasets'] == [dataset1.id] + + @pytest.mark.django_db class TestFittingSpecRenaming: def test_fittingspec_renaming_success(self, client, logged_in_user, helpers): @@ -474,3 +818,106 @@ def test_dataset_renaming_same_users_fails(self, client, logged_in_user, helpers assert response.status_code == 200 fittingspec = FittingSpec.objects.first() assert fittingspec.name == 'my spec1' + + +@pytest.mark.django_db +class TestRerunFittingView: + def test_requires_login(self, client): + response = client.post('/fitting/results/rerun') + assert response.status_code == 200 + data = json.loads(response.content.decode()) + assert not data['newExperiment']['response'] + assert ( + data['newExperiment']['responseText'] == + 'You are not allowed to run fitting experiments' + ) + + def test_requires_permission(self, client, logged_in_user): + response = client.post('/fitting/results/rerun') + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + assert not data['newExperiment']['response'] + assert ( + data['newExperiment']['responseText'] == + 'You are not allowed to run fitting experiments' + ) + + def test_raises_error_if_no_rerun_id(self, client, fits_user): + response = client.post('/fitting/results/rerun') + assert response.status_code == 200 + + data = json.loads(response.content.decode()) + assert not data['newExperiment']['response'] + assert ( + data['newExperiment']['responseText'] == + 'You must specify a fitting experiment to rerun' + ) + + @patch('fitting.views.submit_fitting') + def test_rerun_experiment( + self, mock_submit, client, fits_user, fittingresult_version + ): + + fittingresult = fittingresult_version.fittingresult + new_runnable = recipes.fittingresult_version.make(fittingresult=fittingresult) + mock_submit.return_value = (new_runnable, True) + + response = client.post( + '/fitting/results/rerun', + { + 'rerun': fittingresult_version.pk, + } + ) + + assert response.status_code == 200 + data = json.loads(response.content.decode()) + url = '/fitting/results/%d/versions/%d' % (fittingresult.id, new_runnable.id) + + assert 'newExperiment' in data + assert data['newExperiment']['expId'] == fittingresult.id + assert data['newExperiment']['versionId'] == new_runnable.id + assert data['newExperiment']['url'] == url + assert data['newExperiment']['expName'] == fittingresult.name + assert data['newExperiment']['status'] == 'QUEUED' + assert data['newExperiment']['response'] is True + message = data['newExperiment']['responseText'] + assert url in message + assert fittingresult.name in message + assert 'submitted to the queue' in message + assert 'Experiment' in message + + @patch('fitting.views.submit_fitting') + def test_rerun_experiment_with_failure( + self, mock_submit, client, fits_user, fittingresult_version + ): + + fittingresult = fittingresult_version.fittingresult + new_runnable = recipes.fittingresult_version.make( + fittingresult=fittingresult, status='FAILED', return_text='something failed' + ) + mock_submit.return_value = (new_runnable, True) + + response = client.post( + '/fitting/results/rerun', + { + 'rerun': fittingresult_version.pk, + } + ) + + assert response.status_code == 200 + data = json.loads(response.content.decode()) + url = '/fitting/results/%d/versions/%d' % (fittingresult.id, new_runnable.id) + + assert 'newExperiment' in data + assert data['newExperiment']['expId'] == fittingresult.id + assert data['newExperiment']['versionId'] == new_runnable.id + assert data['newExperiment']['url'] == url + assert data['newExperiment']['expName'] == fittingresult.name + assert data['newExperiment']['status'] == 'FAILED' + assert data['newExperiment']['response'] is False + message = data['newExperiment']['responseText'] + assert url in message + assert fittingresult.name in message + assert 'could not be run: something failed' in message + assert 'Experiment' in message diff --git a/weblab/fitting/urls.py b/weblab/fitting/urls.py index 9c9a17118..c8b3c9733 100644 --- a/weblab/fitting/urls.py +++ b/weblab/fitting/urls.py @@ -66,6 +66,24 @@ views.FittingResultComparisonJsonView.as_view(), name='compare_json', ), + + url( + r'^new$', + views.FittingResultCreateView.as_view(), + name='new' + ), + + url( + r'^new/filter$', + views.FittingResultFilterJsonView.as_view(), + name='filter_json', + ), + + url( + r'^rerun$', + views.FittingResultRerunView.as_view(), + name='rerun' + ), ] urlpatterns = [ diff --git a/weblab/fitting/views.py b/weblab/fitting/views.py index 78f156427..a7611ace1 100644 --- a/weblab/fitting/views.py +++ b/weblab/fitting/views.py @@ -13,20 +13,31 @@ from braces.views import UserFormKwargsMixin from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.core.exceptions import ObjectDoesNotExist from django.http import JsonResponse +from django.shortcuts import get_object_or_404 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 django.views.generic.edit import CreateView, FormView from core.visibility import VisibilityMixin from datasets import views as dataset_views +from datasets.models import Dataset +from entities.models import ModelEntity, ProtocolEntity from entities.views import EntityNewVersionView, EntityTypeMixin, RenameView +from repocache.models import CachedFittingSpecVersion, CachedModelVersion, CachedProtocolVersion -from .forms import FittingSpecForm, FittingSpecRenameForm, FittingSpecVersionForm -from .models import FittingResult, FittingResultVersion +from .forms import ( + FittingResultCreateForm, + FittingSpecForm, + FittingSpecRenameForm, + FittingSpecVersionForm, +) +from .models import FittingResult, FittingResultVersion, FittingSpec +from .processing import submit_fitting class FittingSpecCreateView( @@ -64,6 +75,7 @@ class FittingResultVersionListView(VisibilityMixin, DetailView): class FittingResultVersionView(VisibilityMixin, DetailView): + """Show a version of a fitting result""" model = FittingResultVersion context_object_name = 'version' @@ -202,3 +214,224 @@ def get(self, request, *args, **kwargs): } return JsonResponse(response) + + +class FittingResultCreateView(LoginRequiredMixin, PermissionRequiredMixin, UserFormKwargsMixin, FormView): + """ + Create and submit a fitting result from models, protocols, fitting specs and datasets + and (where relevant) their versions. + """ + permission_required = 'fitting.run_fits' + form_class = FittingResultCreateForm + + template_name = 'fitting/fittingresult_create_form.html' + + def get_initial(self): + initial = super().get_initial() + model_id = self.request.GET.get('model') + model_version_id = self.request.GET.get('model_version') + protocol_id = self.request.GET.get('protocol') + protocol_version_id = self.request.GET.get('protocol_version') + fittingspec_id = self.request.GET.get('fittingspec') + fittingspec_version_id = self.request.GET.get('fittingspec_version') + dataset_id = self.request.GET.get('dataset') + + if model_version_id: + initial['model_version'] = get_object_or_404( + CachedModelVersion.objects.visible_to_user(self.request.user), + pk=model_version_id) + initial['model'] = initial['model_version'].model + elif model_id: + initial['model'] = get_object_or_404( + ModelEntity.objects.visible_to_user(self.request.user), + pk=model_id) + + if protocol_version_id: + initial['protocol_version'] = get_object_or_404( + CachedProtocolVersion.objects.visible_to_user(self.request.user), + pk=protocol_version_id) + initial['protocol'] = initial['protocol_version'].protocol + elif protocol_id: + initial['protocol'] = get_object_or_404( + ProtocolEntity.objects.visible_to_user(self.request.user), + pk=protocol_id) + + if fittingspec_version_id: + initial['fittingspec_version'] = get_object_or_404( + CachedFittingSpecVersion.objects.visible_to_user(self.request.user), + pk=fittingspec_version_id) + initial['fittingspec'] = initial['fittingspec_version'].fittingspec + elif fittingspec_id: + initial['fittingspec'] = get_object_or_404( + FittingSpec.objects.visible_to_user(self.request.user), + pk=fittingspec_id) + + if dataset_id: + initial['dataset'] = get_object_or_404( + Dataset.objects.visible_to_user(self.request.user), + pk=dataset_id) + + return initial + + def form_valid(self, form): + self.runnable, is_new = submit_fitting( + form.cleaned_data['model_version'], + form.cleaned_data['protocol_version'], + form.cleaned_data['fittingspec_version'], + form.cleaned_data['dataset'], + self.request.user, + True, + ) + + queued = self.runnable.status == FittingResultVersion.STATUS_QUEUED + + if is_new: + if queued: + messages.info(self.request, "Fitting experiment submitted to the queue.") + else: + messages.error(self.request, "Fitting experiment could not be run: " + self.runnable.return_text) + else: + messages.info(self.request, "Fitting experiment was already run.") + + return super().form_valid(form) + + def get_success_url(self): + return reverse('fitting:result:version', args=[self.runnable.fittingresult.pk, self.runnable.pk]) + + +class FittingResultFilterJsonView(LoginRequiredMixin, PermissionRequiredMixin, View): + """ + JSON view of valid fitting result input values based on those already selected + + For example, if a model id is specified (as a GET param), only versions of that model + (which are visible to the user) will be included in the results. Otherwise all visible + models and versions will be in the results (which are simply a list of database + IDs of the relevant objects) + + Connections between protocols, fitting specs and datasets are also enforced. + """ + permission_required = 'fitting.run_fits' + + def get(self, request, *args, **kwargs): + options = {} + + model_versions = CachedModelVersion.objects.visible_to_user(request.user) + models = ModelEntity.objects.filter(cachedmodel__versions__in=model_versions) + protocol_versions = CachedProtocolVersion.objects.visible_to_user(request.user) + protocols = ProtocolEntity.objects.filter(cachedprotocol__versions__in=protocol_versions) + fittingspec_versions = CachedFittingSpecVersion.objects.visible_to_user(request.user) + fittingspecs = FittingSpec.objects.filter(cachedfittingspec__versions__in=fittingspec_versions) + datasets = Dataset.objects.visible_to_user(request.user) + + def _get_int_param(fieldname, _model): + try: + pk = int(request.GET.get(fieldname, '')) + return _model.objects.get(pk=pk) + except ValueError: + pass + except ObjectDoesNotExist: + pass + + model = _get_int_param('model', ModelEntity) + protocol = _get_int_param('protocol', ProtocolEntity) + fittingspec = _get_int_param('fittingspec', FittingSpec) + dataset = _get_int_param('dataset', Dataset) + + if not protocol: + if fittingspec: + protocol = fittingspec.protocol + protocols = [protocol] + elif dataset: + protocol = dataset.protocol + protocols = [protocol] + + # Restrict to versions of specified model + if model: + model_versions = model_versions.filter(entity__entity=model) + + # Restrict to versions of specified fitting spec + if fittingspec: + fittingspec_versions = fittingspec_versions.filter(entity__entity=fittingspec) + + # Restrict to versions of specified protocol + if protocol: + protocol_versions = protocol_versions.filter(entity__entity=protocol) + + # If no fitting spec was chosen yet, restrict to those linked to this protocol + if not fittingspec: + fittingspecs = fittingspecs.filter(protocol=protocol.id) + fsids = [fs.pk for fs in fittingspecs.filter(protocol=protocol.id)] + fittingspec_versions = fittingspec_versions.filter(entity__entity__in=fsids) + + # If no dataset was chosen yet, restrict to those linked to this protocol + if not dataset: + datasets = datasets.filter(protocol=protocol.id) + + # These might be either querysets or ID lists + def _get_ids(qs): + return [item.id for item in qs] + + options['models'] = _get_ids(models) + options['model_versions'] = _get_ids(model_versions) + options['protocols'] = _get_ids(protocols) + options['protocol_versions'] = _get_ids(protocol_versions) + options['fittingspecs'] = _get_ids(fittingspecs) + options['fittingspec_versions'] = _get_ids(fittingspec_versions) + options['datasets'] = _get_ids(datasets) + + return JsonResponse({ + 'fittingResultOptions': options + }) + + +class FittingResultRerunView(PermissionRequiredMixin, View): + permission_required = 'fitting.run_fits' + + def handle_no_permission(self): + return JsonResponse({ + 'newExperiment': { + 'response': False, + 'responseText': 'You are not allowed to run fitting experiments', + } + }) + + def post(self, request, *args, **kwargs): + if 'rerun' in request.POST: + version = get_object_or_404(FittingResultVersion, pk=request.POST['rerun']) + + version, is_new = submit_fitting( + version.fittingresult.model_version, + version.fittingresult.protocol_version, + version.fittingresult.fittingspec_version, + version.fittingresult.dataset, + request.user, + rerun_ok=True + ) + + queued = version.status == FittingResultVersion.STATUS_QUEUED + version_url = reverse('fitting:result:version', args=[version.fittingresult.id, version.id]) + if queued: + msg = " submitted to the queue." + else: + msg = " could not be run: " + version.return_text + + return JsonResponse({ + 'newExperiment': { + 'expId': version.fittingresult.id, + 'versionId': version.id, + 'url': version_url, + 'expName': version.fittingresult.name, + 'status': version.status, + 'response': (not is_new) or queued, + 'responseText': "Experiment {} {}".format( + version_url, version.fittingresult.name, msg + ) + } + }) + else: + return JsonResponse({ + 'newExperiment': { + 'response': False, + 'responseText': 'You must specify a fitting experiment to rerun', + } + }) diff --git a/weblab/repocache/models.py b/weblab/repocache/models.py index 6d2ba638b..80475e449 100644 --- a/weblab/repocache/models.py +++ b/weblab/repocache/models.py @@ -1,5 +1,6 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import models +from guardian.shortcuts import get_objects_for_user from core.models import VisibilityModelMixin from core.visibility import Visibility @@ -202,6 +203,30 @@ def _set_class_links(entity_cache_type, version_cache_type, tag_cache_type): tag_cache_type.CachedVersionClass = version_cache_type +class CachedEntityVersionManager(models.Manager): + def visible_to_user(self, user): + """Query over all cached entity versions that the given user can view. + + This includes those versions of entities of the relevant type for which either: + - the user is the author of the related entity + - the entity version is non-private + - or the entity is explicitly shared with the user + """ + non_private = self.filter(visibility__in=['public', 'moderated']) + + if user.is_authenticated: + shared_pks = get_objects_for_user( + user, 'entities.edit_entity', with_superuser=False + ).values_list('pk', flat=True) + shared = self.filter(entity__entity__pk__in=shared_pks) + owned = self.filter(entity__entity__author=user) + else: + shared = self.none() + owned = self.none() + + return non_private | owned | shared + + #################################################################################################### # # Concrete cache classes go here @@ -216,6 +241,12 @@ class CachedModelVersion(CachedEntityVersion): """Cache for a single version / commit in a CellML model's repository.""" entity = models.ForeignKey(CachedModel, on_delete=models.CASCADE, related_name='versions') + objects = CachedEntityVersionManager() + + @property + def model(self): + return self.entity.entity + class CachedModelTag(CachedEntityTag): """Cache for a tag in a CellML model's repository.""" @@ -235,6 +266,12 @@ class CachedProtocolVersion(CachedEntityVersion): """Cache for a single version / commit in a protocol's repository.""" entity = models.ForeignKey(CachedProtocol, on_delete=models.CASCADE, related_name='versions') + objects = CachedEntityVersionManager() + + @property + def protocol(self): + return self.entity.entity + class CachedProtocolTag(CachedEntityTag): """Cache for a tag in a protocol's repository.""" @@ -254,6 +291,12 @@ class CachedFittingSpecVersion(CachedEntityVersion): """Cache for a single version / commit in a fitting specifications's repository.""" entity = models.ForeignKey(CachedFittingSpec, on_delete=models.CASCADE, related_name='versions') + objects = CachedEntityVersionManager() + + @property + def fittingspec(self): + return self.entity.entity + class CachedFittingSpecTag(CachedEntityTag): """Cache for a tag in a fitting specifications's repository.""" diff --git a/weblab/repocache/tests/test_models.py b/weblab/repocache/tests/test_models.py index 436391063..f9a635227 100644 --- a/weblab/repocache/tests/test_models.py +++ b/weblab/repocache/tests/test_models.py @@ -3,7 +3,12 @@ from core import recipes from repocache.exceptions import RepoCacheMiss -from repocache.models import CachedModel, CachedProtocol +from repocache.models import ( + CachedFittingSpec, + CachedModel, + CachedModelVersion, + CachedProtocol, +) from repocache.populate import populate_entity_cache @@ -12,6 +17,7 @@ class TestEntityCacheModels: @pytest.mark.parametrize("recipe,manager", [ (recipes.cached_model, CachedModel.objects), (recipes.cached_protocol, CachedProtocol.objects), + (recipes.cached_fittingspec, CachedFittingSpec.objects), ]) def test_cachedentity_is_deleted_when_entity_is_deleted(self, recipe, manager): cached = recipe.make() @@ -21,6 +27,7 @@ def test_cachedentity_is_deleted_when_entity_is_deleted(self, recipe, manager): @pytest.mark.parametrize("recipe", [ (recipes.cached_model_version), (recipes.cached_protocol_version), + (recipes.cached_fittingspec_version), ]) def test_related_names_for_versions(self, recipe): version = recipe.make() @@ -29,6 +36,7 @@ def test_related_names_for_versions(self, recipe): @pytest.mark.parametrize("recipe", [ (recipes.cached_model_tag), (recipes.cached_protocol_tag), + (recipes.cached_fittingspec_tag), ]) def test_related_names_for_tags(self, recipe): tag = recipe.make() @@ -37,6 +45,7 @@ def test_related_names_for_tags(self, recipe): @pytest.mark.parametrize("recipe", [ (recipes.cached_model_version), (recipes.cached_protocol_version), + (recipes.cached_fittingspec_version), ]) def test_uniqueness_of_entity_and_version_sha(self, recipe): version = recipe.make() @@ -46,6 +55,7 @@ def test_uniqueness_of_entity_and_version_sha(self, recipe): @pytest.mark.parametrize("recipe", [ (recipes.cached_model_tag), (recipes.cached_protocol_tag), + (recipes.cached_fittingspec_tag), ]) def test_uniqueness_of_entity_and_tag(self, recipe): version = recipe.make() @@ -55,18 +65,29 @@ def test_uniqueness_of_entity_and_tag(self, recipe): @pytest.mark.parametrize("recipe", [ (recipes.cached_model), (recipes.cached_protocol), + (recipes.cached_fittingspec), ]) def test_uniqueness_of_entity(self, recipe): cached = recipe.make() with pytest.raises(IntegrityError): recipe.make(entity=cached.entity) + @pytest.mark.parametrize("recipe,property_name", [ + (recipes.cached_model_version, 'model'), + (recipes.cached_protocol_version, 'protocol'), + (recipes.cached_fittingspec_version, 'fittingspec'), + ]) + def test_entity_property(self, recipe, property_name): + cached = recipe.make() + assert getattr(cached, property_name) == cached.entity.entity + @pytest.mark.django_db class TestEntityCacheModelsVisibility: @pytest.mark.parametrize("recipe", [ (recipes.cached_model_version), (recipes.cached_protocol_version), + (recipes.cached_fittingspec_version), ]) def test_entity_visibility(self, recipe): version = recipe.make(visibility='public') @@ -75,6 +96,7 @@ def test_entity_visibility(self, recipe): @pytest.mark.parametrize("recipe", [ (recipes.cached_model), (recipes.cached_protocol), + (recipes.cached_fittingspec), ]) def test_entity_visibility_is_private_if_no_versions(self, recipe): cached = recipe.make() @@ -83,6 +105,7 @@ def test_entity_visibility_is_private_if_no_versions(self, recipe): @pytest.mark.parametrize("recipe", [ (recipes.cached_model_version), (recipes.cached_protocol_version), + (recipes.cached_fittingspec_version), ]) def test_get_version(self, recipe): version = recipe.make() @@ -91,6 +114,7 @@ def test_get_version(self, recipe): @pytest.mark.parametrize("recipe", [ (recipes.cached_model_version), (recipes.cached_protocol_version), + (recipes.cached_fittingspec_version), ]) def test_set_entity_version_visibility(self, recipe): version = recipe.make() @@ -137,3 +161,60 @@ def test_nice_version(self, model_with_version): model_with_version.repo.tag('v1') populate_entity_cache(model_with_version) assert version.nice_version() == 'v1' + + +@pytest.mark.django_db +class TestCachedEntityVersionVisibleToUser: + def test_visibility_and_sharing(self, user, anon_user, other_user, admin_user, helpers): + # Own entities -> always visible + own_models = recipes.model.make(author=user, _quantity=3) + own_moderated_version = helpers.add_fake_version(own_models[0], 'moderated') + own_public_version = helpers.add_fake_version(own_models[1], 'public') + own_private_version = helpers.add_fake_version(own_models[2], 'private') + + # Other entity type shouldn't show up + own_protocol = recipes.protocol.make(author=user) + helpers.add_fake_version(own_protocol, 'moderated') + + # Non-shared public/moderated entities -> visible + other_public_models = recipes.model.make(author=other_user, _quantity=2) + other_moderated_version = helpers.add_fake_version(other_public_models[0], 'moderated') + other_public_version = helpers.add_fake_version(other_public_models[1], 'public') + + # Non-shared private entities -> not visible + other_private_model = recipes.model.make(author=other_user) + other_private_version = helpers.add_fake_version(other_private_model, 'private') # noqa: F841 + + # Shared public or private entities -> visible + other_shared_model = recipes.model.make(author=other_user) + other_shared_version = helpers.add_fake_version(other_shared_model, 'private') + other_shared_model.add_collaborator(user) + + other_shared_protocol = recipes.protocol.make(author=other_user) + helpers.add_fake_version(other_shared_protocol, 'private') + other_shared_protocol.add_collaborator(user) + + # User can see own versions, plus public, plus those explicitly shared + visible_to_self = CachedModelVersion.objects.visible_to_user(user).all() + assert visible_to_self.count() == 6 + assert set(visible_to_self) == { + own_moderated_version, own_public_version, own_private_version, + other_moderated_version, other_public_version, + other_shared_version + } + +# # Anonymous users only see public things + visible_to_anon = CachedModelVersion.objects.visible_to_user(anon_user).all() + assert visible_to_anon.count() == 4 + assert set(visible_to_anon) == { + own_moderated_version, own_public_version, + other_moderated_version, other_public_version, + } + + # Admins don't get special visibility rights, so only see public entities + visible_to_admin = CachedModelVersion.objects.visible_to_user(admin_user).all() + assert visible_to_admin.count() == 4 + assert set(visible_to_admin) == { + own_moderated_version, own_public_version, + other_moderated_version, other_public_version, + } diff --git a/weblab/static/js/experiment.js b/weblab/static/js/experiment.js index 32a3f1f9c..760f69ad8 100644 --- a/weblab/static/js/experiment.js +++ b/weblab/static/js/experiment.js @@ -912,13 +912,14 @@ function init() { var resubmitAction = document.getElementById("rerunExperimentAction"); if (resubmit && resubmitAction) { + var resubmitHref = $(resubmit).data('href'); resubmit.addEventListener("click", function (ev) { resubmitAction.innerHTML = "loading"; var exp_ver = versions[curVersion.id], jsonObject = { rerun: exp_ver.id, }; - $.post('/experiments/new', jsonObject, function(data) { + $.post(resubmitHref, jsonObject, function(data) { var msg = data.newExperiment.responseText; if (data.newExperiment.response) { diff --git a/weblab/static/js/fitting.js b/weblab/static/js/fitting.js new file mode 100644 index 000000000..dee13f0ca --- /dev/null +++ b/weblab/static/js/fitting.js @@ -0,0 +1,81 @@ + +function init() { + var $model = $("#id_model"); + var $modelVersion = $("#id_model_version"); + var $protocol = $("#id_protocol"); + var $protocolVersion = $("#id_protocol_version"); + var $fittingSpec = $("#id_fittingspec"); + var $fittingSpecVersion = $("#id_fittingspec_version"); + var $dataset = $("#id_dataset"); + + function restrictIds(idList, $dropdown) { + $dropdown.find("option").each(function(i, opt) { + if (opt.value.length > 0) { + $(opt).toggle( + idList.includes(parseInt(opt.value, 10)) + ); + } + }); + } + + function updateDropdowns() { + var modelId = parseInt($model.val(), 10); + var protocolId = parseInt($protocol.val(), 10); + var fittingSpecId = parseInt($fittingSpec.val(), 10); + var datasetId = parseInt($dataset.val(), 10); + + console.log(modelId, protocolId, fittingSpecId, datasetId); + + // Clear selected version if no entity selected + if (isNaN(modelId)) $modelVersion.val(''); + if (isNaN(protocolId)) $protocolVersion.val(''); + if (isNaN(fittingSpecId)) $fittingSpecVersion.val(''); + + // Disable version dropdowns if entity not selected + $modelVersion.prop('disabled', isNaN(modelId)); + $protocolVersion.prop('disabled', isNaN(protocolId)); + $fittingSpecVersion.prop('disabled', isNaN(fittingSpecId)); + + $.getJSON("/fitting/results/new/filter", + { + 'model': modelId, + 'protocol': protocolId, + 'fittingspec': fittingSpecId, + 'dataset': datasetId, + }, + function(json) { + if (json.fittingResultOptions) { + var results = json.fittingResultOptions; + + restrictIds(results.models, $model); + restrictIds(results.model_versions, $modelVersion); + restrictIds(results.protocols, $protocol); + restrictIds(results.protocol_versions, $protocolVersion); + restrictIds(results.fittingspecs, $fittingSpec); + restrictIds(results.fittingspec_versions, $fittingSpecVersion); + restrictIds(results.datasets, $dataset); + } + } + ); + } + + $('#resetbutton').click(function() { + $('form select:enabled').val(''); + }); + + $("form select").change(updateDropdowns); + + // Disabled form fields will not be submitted - re-enable before the form is posted + $('form').submit(function() { + $(':disabled').each(function() { + $(this).removeAttr('disabled'); + }) + }); + + updateDropdowns(); +} + + +module.exports = { + init: init, +}; diff --git a/weblab/static/js/main.js b/weblab/static/js/main.js index 96af8a9a8..a1b860c0e 100644 --- a/weblab/static/js/main.js +++ b/weblab/static/js/main.js @@ -8,6 +8,7 @@ require('./db.js'); var entity = require('./entity.js'); var experiment = require('./experiment.js'); var notifications = require('./lib/notifications.js'); +var fitting = require('./fitting.js'); require('./compare.js'); require('./entity_version_list.js'); require('./experiment_tasks.js'); @@ -168,6 +169,10 @@ function initPage () addText: 'add another', deleteText: 'remove', }); + + if ($('#fitting-result-submit').length > 0) { + fitting.init(); + } } document.addEventListener("DOMContentLoaded", initPage, false); diff --git a/weblab/templates/datasets/dataset_detail.html b/weblab/templates/datasets/dataset_detail.html index 09aa4ee7c..2743eb442 100644 --- a/weblab/templates/datasets/dataset_detail.html +++ b/weblab/templates/datasets/dataset_detail.html @@ -38,6 +38,9 @@ +

{{ dataset.description }} diff --git a/weblab/templates/entities/entity_version.html b/weblab/templates/entities/entity_version.html index 003b19ac5..c110ea4cb 100644 --- a/weblab/templates/entities/entity_version.html +++ b/weblab/templates/entities/entity_version.html @@ -63,10 +63,14 @@

{% endif %}
  • Other versions
  • - diff --git a/weblab/templates/experiments/experimentversion_detail.html b/weblab/templates/experiments/experimentversion_detail.html index 6fc0ab82f..bb64cbaa7 100644 --- a/weblab/templates/experiments/experimentversion_detail.html +++ b/weblab/templates/experiments/experimentversion_detail.html @@ -33,7 +33,7 @@

    {% if perms.create_experiment %} - rerun experiment + rerun experiment {% endif %} {% can_delete_entity version as can_delete %} diff --git a/weblab/templates/fitting/fittingresult_create_form.html b/weblab/templates/fitting/fittingresult_create_form.html new file mode 100644 index 000000000..8810aae65 --- /dev/null +++ b/weblab/templates/fitting/fittingresult_create_form.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %}Submit fitting experiment - {% endblock title %} + +{% block body_id %}fitting-result-submit{% endblock body_id %} + +{% block content %} + +

    Fit a model to data

    + +

    +To run a fitting experiment, you need to select not just the model to fit and the data to fit to, +but also the Web Lab protocol describing how to simulate the data from the model, +and a "fitting specification" detailing the fitting algorithm to use, its settings, +which model parameters should be fit, and how to compare the simulation results to data. +

    + +

    +Available options will be filtered according to the interfaces they declare, +so only "compatible" entities may be used together. +

    + +
    + {% csrf_token %} + {{ form.as_p }} + +

    + + +

    +
    + +{% endblock content %} diff --git a/weblab/templates/fitting/fittingresultversion_detail.html b/weblab/templates/fitting/fittingresultversion_detail.html index 4f1bf2cc9..0bb7eca83 100644 --- a/weblab/templates/fitting/fittingresultversion_detail.html +++ b/weblab/templates/fitting/fittingresultversion_detail.html @@ -33,7 +33,7 @@

    {% if perms.run_fits %} - rerun experiment + rerun experiment {% endif %} {% can_delete_entity version as can_delete %} @@ -48,6 +48,8 @@

    {{ fittingresult.model.name }} @ {{ fittingresult.nice_model_version }} & protocol: {{ fittingresult.protocol.name }} @ {{ fittingresult.nice_protocol_version }} + & fitting spec: + {{ fittingresult.fittingspec.name }} @ {{ fittingresult.nice_fittingspec_version }}