diff --git a/weblab/accounts/managers.py b/weblab/accounts/managers.py index 7461236cc..ac59196f4 100644 --- a/weblab/accounts/managers.py +++ b/weblab/accounts/managers.py @@ -7,8 +7,7 @@ def admins(self): def create_user(self, email, full_name, institution='', password=None): """ - Creates and saves a superuser with the given email, date of - birth and password. + Creates and saves a user with the given details and password. """ user = self.model( email=self.normalize_email(email), @@ -22,8 +21,7 @@ def create_user(self, email, full_name, institution='', password=None): def create_superuser(self, email, full_name, institution, password): """ - Creates and saves a superuser with the given email, date of - birth and password. + Creates and saves a superuser with the given details and password. """ user = self.create_user( email, diff --git a/weblab/accounts/views.py b/weblab/accounts/views.py index ffd183eb1..b397f49c8 100644 --- a/weblab/accounts/views.py +++ b/weblab/accounts/views.py @@ -37,6 +37,7 @@ def get_success_url(self): def get_context_data(self, **kwargs): perms = { + 'entities.create_fittingspec', 'entities.create_protocol', 'entities.create_model', } diff --git a/weblab/config/settings/base.py b/weblab/config/settings/base.py index 6c98030a5..94cd9b89a 100644 --- a/weblab/config/settings/base.py +++ b/weblab/config/settings/base.py @@ -56,6 +56,7 @@ 'datasets', 'entities', 'experiments', + 'fitting', 'repocache', ] diff --git a/weblab/config/urls.py b/weblab/config/urls.py index 619b7ad1f..debb2b0f4 100644 --- a/weblab/config/urls.py +++ b/weblab/config/urls.py @@ -32,5 +32,6 @@ url(r'^entities/', include('entities.urls', namespace='entities')), url(r'^datasets/', include('datasets.urls', namespace='datasets')), url(r'^experiments/', include('experiments.urls', namespace='experiments')), + url(r'^fitting/', include('fitting.urls', namespace='fitting')), url(r'^admin/', admin.site.urls), ] diff --git a/weblab/core/context_processors.py b/weblab/core/context_processors.py index 64e9e399f..be0460a29 100644 --- a/weblab/core/context_processors.py +++ b/weblab/core/context_processors.py @@ -16,4 +16,5 @@ def common(request): 'VISIBILITY_HELP': visibility.HELP_TEXT, 'ERROR_MESSAGES': error_messages, 'INFO_MESSAGES': info_messages, + 'current_namespace': request.resolver_match.namespace, } diff --git a/weblab/core/recipes.py b/weblab/core/recipes.py index e3cd827cd..729fe02a5 100644 --- a/weblab/core/recipes.py +++ b/weblab/core/recipes.py @@ -11,6 +11,11 @@ 'ProtocolEntity', entity_type='protocol', name=seq('myprotocol') ) +fittingspec = Recipe( + 'FittingSpec', + entity_type='fittingspec', name=seq('myspec'), + protocol=foreign_key(protocol), +) model_file = Recipe('EntityFile', entity=foreign_key(model)) protocol_file = Recipe('EntityFile', entity=foreign_key(protocol)) diff --git a/weblab/entities/forms.py b/weblab/entities/forms.py index c995b5f86..2ef48547a 100644 --- a/weblab/entities/forms.py +++ b/weblab/entities/forms.py @@ -15,7 +15,7 @@ def clean_name(self): name = self.cleaned_data['name'] if self._meta.model.objects.filter(name=name).exists(): raise ValidationError( - 'You already have a %s named "%s"' % (self.entity_type, name)) + 'You already have a %s named "%s"' % (self._meta.model.display_type, name)) return name @@ -69,7 +69,9 @@ class EntityVersionForm(forms.Form): def __init__(self, *args, **kwargs): entity_type = kwargs.pop('entity_type') super().__init__(*args, **kwargs) - self.fields['rerun_expts'].label = self.fields['rerun_expts'].label % entity_type + rerun_field = self.fields.get('rerun_expts', None) + if rerun_field: + rerun_field.label = rerun_field.label % entity_type class EntityChangeVisibilityForm(UserKwargModelFormMixin, forms.Form): diff --git a/weblab/entities/migrations/0015_auto_20191128_1601.py b/weblab/entities/migrations/0015_auto_20191128_1601.py new file mode 100644 index 000000000..6f200f4cb --- /dev/null +++ b/weblab/entities/migrations/0015_auto_20191128_1601.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-11-28 16:01 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('entities', '0014_entity_is_fitting_spec'), + ] + + operations = [ + migrations.AlterModelOptions( + name='entity', + options={'ordering': ['name'], 'permissions': (('create_model', 'Can create models'), ('create_protocol', 'Can create protocols'), ('create_fittingspec', 'Can create fitting specifications'), ('edit_entity', 'Can edit entity'), ('moderator', 'Can promote public entity versions to moderated'))}, + ), + migrations.AlterField( + model_name='entity', + name='entity_type', + field=models.CharField(choices=[('model', 'model'), ('protocol', 'protocol'), ('fittingspec', 'fittingspec')], max_length=16), + ), + ] diff --git a/weblab/entities/models.py b/weblab/entities/models.py index 2a7f7c734..5f8133dc2 100644 --- a/weblab/entities/models.py +++ b/weblab/entities/models.py @@ -19,15 +19,28 @@ class Entity(UserCreatedModelMixin, models.Model): + """ + Base class for 'entities' - conceptual entities backed by git repositories. + + Subclasses describe (CellML) models, protocols, and fitting specifications. + + The entity_type column states which concrete type each DB row represents, and is fixed by each subclass. + In addition, other class properties defined in subclasses refer to these types in helpful ways: + - ``other_type`` refers to the other axis on a models vs protocols matrix + - ``display_type`` is used to display the type of the entity to users in templates + - ``url_type`` is used as a URL fragment to refer to this entity type + """ DEFAULT_VISIBILITY = Visibility.PRIVATE VISIBILITY_HELP = VIS_HELP_TEXT ENTITY_TYPE_MODEL = 'model' ENTITY_TYPE_PROTOCOL = 'protocol' + ENTITY_TYPE_FITTINGSPEC = 'fittingspec' ENTITY_TYPE_CHOICES = ( (ENTITY_TYPE_MODEL, ENTITY_TYPE_MODEL), (ENTITY_TYPE_PROTOCOL, ENTITY_TYPE_PROTOCOL), + (ENTITY_TYPE_FITTINGSPEC, ENTITY_TYPE_FITTINGSPEC), ) entity_type = models.CharField( @@ -48,6 +61,7 @@ class Meta: permissions = ( ('create_model', 'Can create models'), ('create_protocol', 'Can create protocols'), + ('create_fittingspec', 'Can create fitting specifications'), # Edit entity is used as an object-level permission ('edit_entity', 'Can edit entity'), ('moderator', 'Can promote public entity versions to moderated'), @@ -269,11 +283,36 @@ def create(self, **kwargs): kwargs['entity_type'] = self.model.entity_type return super().create(**kwargs) + def visible_to_user(self, user): + """Query over all managed entities that the given user can view. + + This includes those entities of the managed ``entity_type`` for which either: + - the user is the author + - the entity has at least one non-private version + - or the entity is explicitly shared with the user + """ + from repocache.models import CACHED_VERSION_TYPE_MAP + CachedEntityVersion = CACHED_VERSION_TYPE_MAP[self.model.entity_type] + non_private = self.annotate( + non_private=models.Exists( + CachedEntityVersion.objects.filter( + entity__entity=models.OuterRef('pk'), + visibility__in=['public', 'moderated'], + ) + ) + ).filter( + non_private=True, + ) + owned = self.filter(author=user) + shared = self.shared_with_user(user) + return owned | non_private | shared + def shared_with_user(self, user): """Query over all managed entities shared explicitly with the given user.""" if user.is_authenticated: - return get_objects_for_user(user, 'entities.edit_entity', with_superuser=False).filter( - entity_type=self.model.entity_type) + shared_pks = get_objects_for_user( + user, 'entities.edit_entity', with_superuser=False).values_list('pk', flat=True) + return self.get_queryset().filter(pk__in=shared_pks) else: return self.none() @@ -281,6 +320,8 @@ def shared_with_user(self, user): class ModelEntity(Entity): entity_type = Entity.ENTITY_TYPE_MODEL other_type = Entity.ENTITY_TYPE_PROTOCOL + display_type = 'model' + url_type = 'model' objects = EntityManager() @@ -292,6 +333,8 @@ class Meta: class ProtocolEntity(Entity): entity_type = Entity.ENTITY_TYPE_PROTOCOL other_type = Entity.ENTITY_TYPE_MODEL + display_type = 'protocol' + url_type = 'protocol' objects = EntityManager() diff --git a/weblab/entities/signals.py b/weblab/entities/signals.py index 62f674d0c..0491ba4d1 100644 --- a/weblab/entities/signals.py +++ b/weblab/entities/signals.py @@ -11,4 +11,5 @@ def entity_deleted(sender, instance, **kwargs): """ Signal callback when an entity is about to be deleted. """ - instance.repo.delete() + if instance.repo_abs_path.exists(): + instance.repo.delete() diff --git a/weblab/entities/templatetags/entities.py b/weblab/entities/templatetags/entities.py index 7c381e024..6e2e13598 100644 --- a/weblab/entities/templatetags/entities.py +++ b/weblab/entities/templatetags/entities.py @@ -23,54 +23,93 @@ def file_type(filename): return get_file_type(filename) -@register.filter -def url_versions(entity): - return reverse('entities:version_list', args=[entity.entity_type, entity.id]) +@register.simple_tag(takes_context=True) +def ns_url(context, name, *args): + """An extended version of the built-in url tag that dynamically figures out the namespace portion. + :param name: the URL pattern name, *without* initial namespace (that will be determined from context) + :param args: any positional args for the URL + """ + ns = context['current_namespace'] + return reverse(ns + ':' + name, args=args) -@register.filter -def url_newversion(entity): - return reverse('entities:newversion', args=[entity.entity_type, entity.id]) +@register.simple_tag(takes_context=True) +def entity_url(context, name, entity, *args): + """An extended version of the built-in url tag specifically for common entity URLs. -@register.filter -def url_tag_version(entity, version): - return reverse('entities:tag_version', args=[entity.id, version.sha]) + :param name: the URL pattern name, *without* initial namespace (that will be determined from context) + :param entity: the entity this URL is about + :param args: any extra positional args for the URL + """ + ns = context['current_namespace'] + return reverse(ns + ':' + name, args=(entity.url_type, entity.id) + args) -@register.filter -def url_entity_comparison_json(entity_versions, entity_type): +@register.simple_tag(takes_context=True) +def entity_version_url(context, name, entity, commit, *args): + """An extended version of the built-in url tag specifically for common entity version URLs. + + :param name: the URL pattern name, *without* initial namespace (that will be determined from context) + :param entity: the entity this URL is about + :param commit: the version this URL is about + :param args: any extra positional args for the URL """ - Build URL for entity comparison json + ns = context['current_namespace'] + url_name = ns + ':' + name + last_tag = _url_friendly_label(entity, commit) + args = (entity.url_type, entity.id, last_tag) + args + return reverse(url_name, args=args) + + +@register.simple_tag(takes_context=True) +def tag_version_url(context, entity, commit): + """Generate the URL for tagging a version of an entity. + + :param entity: the entity this URL is about + :param commit: the version this URL is about """ + ns = context['current_namespace'] + url_name = ns + ':tag_version' + last_tag = _url_friendly_label(entity, commit) + args = (entity.id, last_tag) + return reverse(url_name, args=args) + + +@register.simple_tag(takes_context=True) +def entity_comparison_json_url(context, entity_versions, entity_type): + """Generate a URL for the EntityComparisonJsonView.""" + ns = context['current_namespace'] if entity_versions: version_ids = '/' + '/'.join(entity_versions) else: version_ids = '' - return reverse('entities:compare_json', args=[entity_type, version_ids]) + return reverse(ns + ':compare_json', args=[entity_type, version_ids]) -@register.simple_tag -def url_entity_comparison_base(entity_type): +@register.simple_tag(takes_context=True) +def url_entity_comparison_base(context, entity_type): """ Base URL for entity comparison page """ # Use dummy IDs to set up a comparison URL, then chop them off to # get the base. This will be used by javascript to generate comparisons # between entity versions. - url = reverse('entities:compare', args=[entity_type, '/1:a']) + ns = context['current_namespace'] + url = reverse(ns + ':compare', args=[entity_type, '/1:a']) return url[:-4] -@register.simple_tag -def url_entity_diff_base(entity_type): +@register.simple_tag(takes_context=True) +def url_entity_diff_base(context, entity_type): """ Base URL for entity diff """ # Use dummy IDs to set up a diff URL, then chop them off to # get the base. This will be used by javascript to generate diff URLs # between entity versions. - url = reverse('entities:diff', args=[entity_type, '/1:a/2:b', 'file.json']) + ns = context['current_namespace'] + url = reverse(ns + ':diff', args=[entity_type, '/1:a/2:b', 'file.json']) return url.split('/1:a/2:b')[0] @@ -101,30 +140,6 @@ def _url_friendly_label(entity, commit): return last_tag -@register.filter -def url_version(entity, commit): - """Generate the view URL for a specific version of this entity. - - We try to use the last tag name in the URL, but if there isn't - a tag, or the tag contains a /, or the tag is one of our reserved - names (new, latest), we fall back to the SHA1. - """ - last_tag = _url_friendly_label(entity, commit) - args = [entity.entity_type, entity.id, last_tag] - return reverse('entities:version', args=args) - - -@register.filter -def url_version_json(entity, commit): - """ - Generate the json URL for a specific version of this entity. - """ - url_name = 'entities:version_json' - last_tag = _url_friendly_label(entity, commit) - args = [entity.entity_type, entity.id, last_tag] - return reverse(url_name, args=args) - - @register.filter def url_compare_experiments(entity, commit): """Generate the view URL for comparing experiments using @@ -134,38 +149,15 @@ def url_compare_experiments(entity, commit): """ url_name = 'entities:compare_experiments' last_tag = _url_friendly_label(entity, commit) - args = [entity.entity_type, entity.id, last_tag] + args = [entity.url_type, entity.id, last_tag] return reverse(url_name, args=args) @register.filter -def url_change_version_visibility(entity, commit): +def url_run_experiments(entity, commit): last_tag = _url_friendly_label(entity, commit) - args = [entity.entity_type, entity.id, last_tag] - return reverse('entities:change_visibility', args=args) - - -@register.filter -def url_entity(entity): - url_name = 'entities:detail' - return reverse(url_name, args=[entity.entity_type, entity.id]) - - -@register.filter -def url_new(entity_type): - return reverse('entities:new', args=[entity_type]) - - -@register.filter -def url_delete(entity): - url_name = 'entities:delete' - return reverse(url_name, args=[entity.entity_type, entity.id]) - - -@register.filter -def url_collaborators(entity): - url_name = 'entities:entity_collaborators' - return reverse(url_name, args=[entity.entity_type, entity.id]) + args = [entity.url_type, entity.id, last_tag] + return reverse('entities:runexperiments', args=args) @register.simple_tag(takes_context=True) @@ -189,10 +181,3 @@ def can_delete_entity(context, entity): def can_manage_entity(context, entity): user = context['user'] return entity.is_managed_by(user) - - -@register.filter -def url_run_experiments(entity, commit): - last_tag = _url_friendly_label(entity, commit) - args = [entity.entity_type, entity.id, last_tag] - return reverse('entities:runexperiments', args=args) diff --git a/weblab/entities/tests/test_models.py b/weblab/entities/tests/test_models.py index be45febfb..ebfe1c9ad 100644 --- a/weblab/entities/tests/test_models.py +++ b/weblab/entities/tests/test_models.py @@ -39,6 +39,46 @@ def test_deletion_permissions(): assert not model.is_deletable_by(other_user) +@pytest.mark.django_db +def test_visibility_and_sharing(user, other_user, admin_user, helpers): + """Checks the EntityManager visible_to_user and shared_with_user methods.""" + # Own entities -> always visible + own_models = recipes.model.make(author=user, _quantity=3) + helpers.add_fake_version(own_models[0], 'moderated') + helpers.add_fake_version(own_models[1], 'public') + 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) + helpers.add_fake_version(other_public_models[0], 'moderated') + helpers.add_fake_version(other_public_models[1], 'public') + # Non-shared private entities -> not visible + other_private_model = recipes.model.make(author=other_user) + helpers.add_fake_version(other_private_model, 'private') + # Shared public or private entities -> visible + other_shared_model = recipes.model.make(author=other_user) + helpers.add_fake_version(other_private_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) + + # Getting shared entities just shows those shared explicitly, of the correct type + assert list(ModelEntity.objects.shared_with_user(user).all()) == [other_shared_model] + + # Check visible entities are correct + visible_models = ModelEntity.objects.visible_to_user(user).all() + assert visible_models.count() == 6 + assert set(visible_models) == set(own_models + other_public_models + [other_shared_model]) + + # Admins don't get special visibility rights, so only see public entities + visible_to_admin = ModelEntity.objects.visible_to_user(admin_user).all() + assert visible_to_admin.count() == 4 + assert set(visible_to_admin) == set(own_models[:2] + other_public_models) + + @pytest.mark.django_db class TestEntity: def test_str(self): diff --git a/weblab/entities/tests/test_templatetags.py b/weblab/entities/tests/test_templatetags.py index cb50be192..ae3c9191f 100644 --- a/weblab/entities/tests/test_templatetags.py +++ b/weblab/entities/tests/test_templatetags.py @@ -35,27 +35,29 @@ def test_file_type(): def test_model_urls(model_with_version): model = model_with_version model_version = model.repo.latest_commit - - assert entity_tags.url_new('model') == '/entities/models/new' - assert entity_tags.url_entity(model) == '/entities/models/%d' % model.pk - assert entity_tags.url_delete(model) == '/entities/models/%d/delete' % model.pk - assert entity_tags.url_versions(model) == '/entities/models/%d/versions/' % model.pk - assert entity_tags.url_newversion(model) == '/entities/models/%d/versions/new' % model.pk - assert (entity_tags.url_version(model, model_version) == + context = {'current_namespace': 'entities'} + + assert entity_tags.ns_url(context, 'new', 'model') == '/entities/models/new' + assert entity_tags.entity_url(context, 'detail', model) == '/entities/models/%d' % model.pk + assert entity_tags.entity_url(context, 'delete', model) == '/entities/models/%d/delete' % model.pk + assert entity_tags.entity_url(context, 'version_list', model) == '/entities/models/%d/versions/' % model.pk + assert entity_tags.entity_url(context, 'newversion', model) == '/entities/models/%d/versions/new' % model.pk + assert (entity_tags.entity_version_url(context, 'version', model, model_version) == '/entities/models/%d/versions/%s' % (model.pk, model_version.sha)) - assert (entity_tags.url_version_json(model, model_version) == + assert (entity_tags.entity_version_url(context, 'version_json', model, model_version) == '/entities/models/%d/versions/%s/files.json' % (model.pk, model_version.sha)) assert (entity_tags.url_compare_experiments(model, model_version) == '/entities/models/%d/versions/%s/compare' % (model.pk, model_version.sha)) - assert (entity_tags.url_change_version_visibility(model, model_version) == + assert (entity_tags.entity_version_url(context, 'change_visibility', model, model_version) == '/entities/models/%d/versions/%s/visibility' % (model.pk, model_version.sha)) - assert (entity_tags.url_tag_version(model, model_version) == + assert (entity_tags.tag_version_url(context, model, model_version) == '/entities/tag/%d/%s' % (model.pk, model_version.sha)) - assert entity_tags.url_entity_comparison_base('model') == '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/entities/models/compare' - assert entity_tags.url_entity_diff_base('model') == '/entities/models/diff' - assert (entity_tags.url_entity_comparison_json(['%d:%s' % (model.pk, model_version.sha)], 'model') == + assert entity_tags.url_entity_comparison_base(context, 'model') == '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/entities/models/compare' + assert entity_tags.url_entity_diff_base(context, 'model') == '/entities/models/diff' + + assert (entity_tags.entity_comparison_json_url(context, ['%d:%s' % (model.pk, model_version.sha)], 'model') == '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/entities/models/compare/%d:%s/info' % (model.pk, model_version.sha)) @@ -63,29 +65,32 @@ def test_model_urls(model_with_version): def test_protocol_urls(protocol_with_version): protocol = protocol_with_version protocol_version = protocol.repo.latest_commit + context = {'current_namespace': 'entities'} - assert entity_tags.url_new('protocol') == '/entities/protocols/new' - assert entity_tags.url_entity(protocol) == '/entities/protocols/%d' % protocol.pk - assert entity_tags.url_delete(protocol) == '/entities/protocols/%d/delete' % protocol.pk - assert entity_tags.url_versions(protocol) == '/entities/protocols/%d/versions/' % protocol.pk - assert (entity_tags.url_newversion(protocol) == + assert entity_tags.ns_url(context, 'new', 'protocol') == '/entities/protocols/new' + assert entity_tags.entity_url(context, 'detail', protocol) == '/entities/protocols/%d' % protocol.pk + assert entity_tags.entity_url(context, 'delete', protocol) == '/entities/protocols/%d/delete' % protocol.pk + assert entity_tags.entity_url(context, 'version_list', protocol) == '/entities/protocols/%d/versions/' % protocol.pk + assert (entity_tags.entity_url(context, 'newversion', protocol) == '/entities/protocols/%d/versions/new' % protocol.pk) - assert (entity_tags.url_version(protocol, protocol_version) == + assert (entity_tags.entity_version_url(context, 'version', protocol, protocol_version) == '/entities/protocols/%d/versions/%s' % (protocol.pk, protocol_version.sha)) - assert (entity_tags.url_version_json(protocol, protocol_version) == + assert (entity_tags.entity_version_url(context, 'version_json', protocol, protocol_version) == '/entities/protocols/%d/versions/%s/files.json' % (protocol.pk, protocol_version.sha)) assert (entity_tags.url_compare_experiments(protocol, protocol_version) == '/entities/protocols/%d/versions/%s/compare' % (protocol.pk, protocol_version.sha)) - assert (entity_tags.url_change_version_visibility(protocol, protocol_version) == + assert (entity_tags.entity_version_url(context, 'change_visibility', protocol, protocol_version) == '/entities/protocols/%d/versions/%s/visibility' % (protocol.pk, protocol_version.sha)) - assert (entity_tags.url_tag_version(protocol, protocol_version) == + assert (entity_tags.tag_version_url(context, protocol, protocol_version) == '/entities/tag/%d/%s' % (protocol.pk, protocol_version.sha)) - assert entity_tags.url_entity_comparison_base('protocol') == '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/entities/protocols/compare' - assert entity_tags.url_entity_diff_base('protocol') == '/entities/protocols/diff' + assert entity_tags.url_entity_comparison_base(context, 'protocol') == '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/entities/protocols/compare' + assert entity_tags.url_entity_diff_base(context, 'protocol') == '/entities/protocols/diff' - assert (entity_tags.url_entity_comparison_json(['%d:%s' % (protocol.pk, protocol_version.sha)], 'protocol') == + assert (entity_tags.entity_comparison_json_url(context, + ['%d:%s' % (protocol.pk, protocol_version.sha)], + 'protocol') == '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/entities/protocols/compare/%d:%s/info' % (protocol.pk, protocol_version.sha)) diff --git a/weblab/entities/tests/test_views.py b/weblab/entities/tests/test_views.py index e2ef93974..38e855f3a 100644 --- a/weblab/entities/tests/test_views.py +++ b/weblab/entities/tests/test_views.py @@ -15,12 +15,7 @@ from guardian.shortcuts import assign_perm from core import recipes -from entities.models import ( - AnalysisTask, - Entity, - ModelEntity, - ProtocolEntity, -) +from entities.models import AnalysisTask, ModelEntity, ProtocolEntity from experiments.models import Experiment, PlannedExperiment from repocache.models import ProtocolInterface @@ -101,7 +96,7 @@ def test_owner_can_delete_entity( entity = recipe.make(author=logged_in_user) repo_path = entity.repo_abs_path helpers.add_version(entity) - assert Entity.objects.filter(pk=entity.pk).exists() + assert type(entity).objects.filter(pk=entity.pk).exists() assert repo_path.exists() response = client.post(url % entity.pk) @@ -109,7 +104,7 @@ def test_owner_can_delete_entity( assert response.status_code == 302 assert response.url == list_url - assert not Entity.objects.filter(pk=entity.pk).exists() + assert not type(entity).objects.filter(pk=entity.pk).exists() assert not repo_path.exists() @pytest.mark.usefixtures('logged_in_user') @@ -125,7 +120,7 @@ def test_non_owner_cannot_delete_entity( response = client.post(url % entity.pk) assert response.status_code == 403 - assert Entity.objects.filter(pk=entity.pk).exists() + assert type(entity).objects.filter(pk=entity.pk).exists() assert repo_path.exists() diff --git a/weblab/entities/views.py b/weblab/entities/views.py index f1020411b..12b49a493 100644 --- a/weblab/entities/views.py +++ b/weblab/entities/views.py @@ -40,6 +40,7 @@ from core.filetypes import get_file_type from core.visibility import Visibility, VisibilityMixin from experiments.models import Experiment, ExperimentVersion, PlannedExperiment +from fitting.models import FittingSpec from repocache.exceptions import RepoCacheMiss from repocache.models import CachedProtocolVersion @@ -62,16 +63,26 @@ class EntityTypeMixin: """ @property def model(self): + return next( + et + for et in (ModelEntity, ProtocolEntity, FittingSpec) + if et.url_type == self.kwargs['entity_type'] + ) + + @property + def other_model(self): return next( et for et in (ModelEntity, ProtocolEntity) - if et.entity_type == self.kwargs['entity_type'] + if et.other_type == self.kwargs['entity_type'] ) def get_context_data(self, **kwargs): kwargs.update({ - 'type': self.model.entity_type, + 'entity_type': self.model.entity_type, 'other_type': self.model.other_type, + 'type': self.model.display_type, + 'url_type': self.model.url_type, }) return super().get_context_data(**kwargs) @@ -189,6 +200,7 @@ class EntityVersionJsonView(EntityTypeMixin, EntityVersionMixin, SingleObjectMix def _file_json(self, blob): obj = self._get_object() commit = self.get_commit() + ns = self.request.resolver_match.namespace return { 'id': blob.name, @@ -197,8 +209,8 @@ def _file_json(self, blob): 'size': blob.size, 'created': commit.timestamp, 'url': reverse( - 'entities:file_download', - args=[obj.entity_type, obj.id, commit.sha, blob.name] + ns + ':file_download', + args=[obj.url_type, obj.id, commit.sha, blob.name] ), } @@ -215,13 +227,14 @@ def _planned_experiments(self): def get(self, request, *args, **kwargs): obj = self._get_object() commit = self.get_commit() + ns = self.request.resolver_match.namespace files = [ self._file_json(f) for f in commit.files if f.name not in ['manifest.xml', 'metadata.rdf'] ] - if request.user.has_perm('experiments.create_experiment'): + if request.user.has_perm('experiments.create_experiment') and obj.entity_type in ('model', 'protocol'): planned_experiments = self._planned_experiments() else: planned_experiments = [] @@ -239,16 +252,16 @@ def get(self, request, *args, **kwargs): 'numFiles': len(files), 'planned_experiments': planned_experiments, 'url': reverse( - 'entities:version', - args=[obj.entity_type, obj.id, commit.sha] + ns + ':version', + args=[obj.url_type, obj.id, commit.sha] ), 'download_url': reverse( - 'entities:entity_archive', - args=[obj.entity_type, obj.id, commit.sha] + ns + ':entity_archive', + args=[obj.url_type, obj.id, commit.sha] ), 'change_url': reverse( - 'entities:change_visibility', - args=[obj.entity_type, obj.id, commit.sha] + ns + ':change_visibility', + args=[obj.url_type, obj.id, commit.sha] ), } }) @@ -294,7 +307,7 @@ def get_context_data(self, **kwargs): for version in self.kwargs['versions'].strip('/').split('/'): id, sha = version.split(':') try: - entity = Entity.objects.get(pk=id) + entity = self.model.objects.get(pk=id) if entity.is_version_visible_to_user(sha, self.request.user): valid_versions.append(version) except (RepoCacheMiss, Entity.DoesNotExist): @@ -308,7 +321,7 @@ def get_context_data(self, **kwargs): return super().get_context_data(**kwargs) -class EntityComparisonJsonView(View): +class EntityComparisonJsonView(EntityTypeMixin, View): """ Serve up JSON view of multiple entity versions for comparison """ @@ -320,6 +333,7 @@ def _file_json(self, entity, commit, blob): :param commit: `Commit` object :param blob: `git.Blob` object """ + ns = self.request.resolver_match.namespace return { 'id': blob.name, 'name': blob.name, @@ -328,8 +342,8 @@ def _file_json(self, entity, commit, blob): 'filetype': get_file_type(blob.name), 'size': blob.size, 'url': reverse( - 'entities:file_download', - args=[entity.entity_type, entity.id, commit.sha, blob.name] + ns + ':file_download', + args=[entity.url_type, entity.id, commit.sha, blob.name] ), } @@ -364,7 +378,7 @@ def get(self, request, *args, **kwargs): for version in self.kwargs['versions'].strip('/').split('/'): id, sha = version.split(':') try: - entity = Entity.objects.get(pk=id) + entity = self.model.objects.get(pk=id) if entity.is_version_visible_to_user(sha, request.user): json_entities.append( self._version_json(entity, entity.repo.get_commit(sha)) @@ -381,21 +395,20 @@ def get(self, request, *args, **kwargs): return JsonResponse(response) -class EntityView(VisibilityMixin, SingleObjectMixin, RedirectView): +class EntityView(VisibilityMixin, EntityTypeMixin, SingleObjectMixin, RedirectView): """ View an entity All this does is redirect to the latest version of the entity, if it exists. Otherwise it redirects to the 'add version' page. """ - model = Entity - def get_redirect_url(self, *args, **kwargs): entity = self.get_object() + ns = self.request.resolver_match.namespace if entity.repocache.versions.exists(): - return reverse('entities:version', args=[kwargs['entity_type'], kwargs['pk'], 'latest']) + return reverse(ns + ':version', args=[kwargs['entity_type'], kwargs['pk'], 'latest']) else: - return reverse('entities:newversion', args=[kwargs['entity_type'], kwargs['pk']]) + return reverse(ns + ':newversion', args=[kwargs['entity_type'], kwargs['pk']]) class EntityTagVersionView( @@ -436,7 +449,9 @@ def get_success_url(self): """What page to show when the form was processed OK.""" entity = self._get_object() version = self.kwargs['sha'] - return reverse('entities:version', args=[entity.entity_type, entity.id, version]) + ns = self.request.resolver_match.namespace + url_type = entity.entity_type.replace('fitting', '') # TODO: Horrible hack! + return reverse(ns + ':version', args=[url_type, entity.id, version]) class EntityDeleteView(UserPassesTestMixin, DeleteView): @@ -452,7 +467,8 @@ def test_func(self): return self.get_object().is_deletable_by(self.request.user) def get_success_url(self, *args, **kwargs): - return reverse('entities:list', args=[self.kwargs['entity_type']]) + ns = self.request.resolver_match.namespace + return reverse(ns + ':list', args=[self.kwargs['entity_type']]) class EntityAlterFileView( @@ -532,10 +548,11 @@ def post(self, request, *args, **kwargs): record_experiments_to_run(request.user, entity, commit) # Show the user the new version + ns = self.request.resolver_match.namespace return JsonResponse({ self.RESPONSE_OBJECT: { 'response': True, - 'url': reverse('entities:version', args=[entity.entity_type, entity.id, commit.sha]), + 'url': reverse(ns + ':version', args=[entity.url_type, entity.id, commit.sha]), } }) @@ -561,7 +578,7 @@ def get_initial(self): def get_form_kwargs(self): """Build the kwargs required to instantiate an EntityVersionForm.""" kwargs = super().get_form_kwargs() - kwargs['entity_type'] = self.object.entity_type + kwargs['entity_type'] = self.object.display_type return kwargs def get_context_data(self, **kwargs): @@ -651,8 +668,9 @@ def post(self, request, *args, **kwargs): record_experiments_to_run(request.user, entity, commit) # Show the user the new version + ns = self.request.resolver_match.namespace return HttpResponseRedirect( - reverse('entities:version', args=[entity.entity_type, entity.id, commit.sha])) + reverse(ns + ':version', args=[entity.url_type, entity.id, commit.sha])) else: # Nothing changed, so inform the user and do nothing else. form = self.get_form() @@ -818,8 +836,7 @@ def get(self, request, *args, **kwargs): return response -class EntityCollaboratorsView(LoginRequiredMixin, UserPassesTestMixin, DetailView): - model = Entity +class EntityCollaboratorsView(LoginRequiredMixin, UserPassesTestMixin, EntityTypeMixin, DetailView): formset_class = EntityCollaboratorFormSet template_name = 'entities/entity_collaborators_form.html' context_object_name = 'entity' @@ -862,7 +879,8 @@ def post(self, request, *args, **kwargs): def get_success_url(self): """What page to show when the form was processed OK.""" entity = self.object - return reverse('entities:entity_collaborators', args=[entity.entity_type, entity.id]) + ns = self.request.resolver_match.namespace + return reverse(ns + ':entity_collaborators', args=[entity.url_type, entity.id]) def get_context_data(self, **kwargs): if 'formset' not in kwargs: @@ -1040,9 +1058,7 @@ def get_context_data(self, **kwargs): # ended up using a nested dict as nested lists caused django's unpacking in forloops to # mess things up slightly cached_name = 'cached' + entity.other_type - other_entities = Entity.objects.filter( - entity_type=entity.other_type - ).select_related( + other_entities = self.other_model.objects.select_related( cached_name ).prefetch_related( cached_name + '__versions', diff --git a/weblab/fitting/__init__.py b/weblab/fitting/__init__.py new file mode 100644 index 000000000..aefe936bd --- /dev/null +++ b/weblab/fitting/__init__.py @@ -0,0 +1 @@ +default_app_config = 'fitting.apps.FittingConfig' diff --git a/weblab/fitting/admin.py b/weblab/fitting/admin.py new file mode 100644 index 000000000..11d38a313 --- /dev/null +++ b/weblab/fitting/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin + +from .models import FittingSpec + + +admin.site.register(FittingSpec) diff --git a/weblab/fitting/apps.py b/weblab/fitting/apps.py new file mode 100644 index 000000000..690d60e47 --- /dev/null +++ b/weblab/fitting/apps.py @@ -0,0 +1,17 @@ +from django.apps import AppConfig +from django.db.models.signals import post_save, pre_delete + +from entities.signals import entity_created, entity_deleted + + +class FittingConfig(AppConfig): + name = 'fitting' + + def ready(self): + from .models import FittingSpec + + # Messages might come from the base Entity class or our new subclass + # depending on how the views are set up / where the action is invoked. + # This covers all bases for creation and deletion. + post_save.connect(entity_created, FittingSpec) + pre_delete.connect(entity_deleted, FittingSpec) diff --git a/weblab/fitting/forms.py b/weblab/fitting/forms.py new file mode 100644 index 000000000..e2c8f7b72 --- /dev/null +++ b/weblab/fitting/forms.py @@ -0,0 +1,26 @@ +from entities.forms import EntityForm, EntityVersionForm +from entities.models import ProtocolEntity + +from .models import FittingSpec + + +class FittingSpecForm(EntityForm): + """Used for creating an entirely new fitting specification.""" + class Meta: + model = FittingSpec + fields = ['name', 'protocol'] + + def __init__(self, *args, **kwargs): + """Only show visible protocols in the selection.""" + super().__init__(*args, **kwargs) + self.fields['protocol'].queryset = ProtocolEntity.objects.visible_to_user(self.user) + + # TODO: Perhaps sort available protocols so 'mine' first, then moderated, then others? + + +class FittingSpecVersionForm(EntityVersionForm): + """Used for creating a new version of a fitting specification. + + This works almost the same as other entities, except we can't re-run experiments. + """ + rerun_expts = None diff --git a/weblab/fitting/migrations/0001_initial.py b/weblab/fitting/migrations/0001_initial.py new file mode 100644 index 000000000..ada5ea8a4 --- /dev/null +++ b/weblab/fitting/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-11-28 16:01 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('entities', '0015_auto_20191128_1601'), + ] + + operations = [ + migrations.CreateModel( + name='FittingSpec', + fields=[ + ('entity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='entities.Entity')), + ('protocol', models.ForeignKey(help_text='the experimental scenario used to fit models', on_delete=django.db.models.deletion.CASCADE, related_name='fitting_specs', to='entities.ProtocolEntity')), + ], + options={ + 'verbose_name': 'fitting specification', + }, + bases=('entities.entity',), + ), + ] diff --git a/weblab/fitting/migrations/__init__.py b/weblab/fitting/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/weblab/fitting/models.py b/weblab/fitting/models.py new file mode 100644 index 000000000..71d57ae29 --- /dev/null +++ b/weblab/fitting/models.py @@ -0,0 +1,49 @@ +from django.db import models + +from entities.models import Entity, EntityManager, ProtocolEntity + + +class FittingSpec(Entity): + """ + Represents parameter fitting specifications. + These are versioned entities, backed by a git repository. + + It links to a ProtocolEntity (not a specific version thereof) representing + the experimental scenario which can be used to fit models. + + Running a fitting specification with (specific versions of) a ModelEntity, + ProtocolEntity and Dataset will result in a FittingResult being generated. + """ + entity_type = Entity.ENTITY_TYPE_FITTINGSPEC + other_type = Entity.ENTITY_TYPE_MODEL + is_fitting_spec = True + + protocol = models.ForeignKey( + ProtocolEntity, related_name='fitting_specs', + help_text='the experimental scenario used to fit models', + ) + + objects = EntityManager() + + class Meta: + verbose_name = 'fitting specification' + + # We change the default display & URL form for this entity type, since the default looks bad! + display_type = Meta.verbose_name + url_type = 'spec' + + # The 'edit_entity' object-level permission is only in the entities app, + # so we need to delegate via our parent link when accessing it. + + def is_editable_by(self, user): + return self.entity_ptr.is_editable_by(user) + + def add_collaborator(self, user): + return self.entity_ptr.add_collaborator(user) + + def remove_collaborator(self, user): + return self.entity_ptr.remove_collaborator(user) + + @property + def collaborators(self): + return self.entity_ptr.collaborators diff --git a/weblab/fitting/tests/__init__.py b/weblab/fitting/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/weblab/fitting/tests/test_models.py b/weblab/fitting/tests/test_models.py new file mode 100644 index 000000000..c43fa884f --- /dev/null +++ b/weblab/fitting/tests/test_models.py @@ -0,0 +1,88 @@ +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 repocache.models import CachedFittingSpec + + +@pytest.mark.django_db +class TestNameUniqueness: + def test_user_cannot_have_same_named_fittingspec(self, user): + spec = recipes.fittingspec.make(author=user, name='myspec') + assert str(spec) == 'myspec' + + with pytest.raises(IntegrityError): + recipes.fittingspec.make(author=user, name='myspec') + + def test_user_can_have_same_named_fittingspec_and_other_entities(self, user): + recipes.fittingspec.make(author=user, name='myentity') + recipes.model.make(author=user, name='myentity') + recipes.protocol.make(author=user, name='myentity') + + def test_different_users_can_have_same_named_fittingspec(self, user, other_user): + recipes.fittingspec.make(author=user, name='myspec') + assert recipes.fittingspec.make(author=other_user, name='myspec') + + +@pytest.mark.django_db +def test_permissions(): + user, other_user = recipes.user.make(_quantity=2) + superuser = recipes.user.make(is_superuser=True) + fittingspec = recipes.fittingspec.make(author=user) + + assert fittingspec.viewers == {user} + + assert not fittingspec.is_editable_by(user) + assert fittingspec.is_editable_by(superuser) + assert not fittingspec.is_editable_by(other_user) + + assign_perm('entities.create_fittingspec', user) + user = get_object_or_404(user.__class__, pk=user.id) # Reset permission cache! + assert fittingspec.is_editable_by(user) + + assert fittingspec.is_deletable_by(user) + assert fittingspec.is_deletable_by(superuser) + assert not fittingspec.is_deletable_by(other_user) + + fittingspec.add_collaborator(other_user) + assert other_user in fittingspec.collaborators + assert fittingspec.viewers == {user, other_user} + assert not fittingspec.is_editable_by(other_user) + assert not fittingspec.is_deletable_by(other_user) + + assign_perm('entities.create_fittingspec', other_user) + other_user = get_object_or_404(user.__class__, pk=other_user.id) # Reset permission cache! + assert fittingspec.is_editable_by(other_user) + + fittingspec.remove_collaborator(other_user) + assert other_user not in fittingspec.collaborators + assert not fittingspec.is_editable_by(other_user) + assert not fittingspec.is_deletable_by(other_user) + + +@pytest.mark.django_db +class TestRepository: + def test_repo_path_create_and_delete(self, fake_repo_path): + spec = recipes.fittingspec.make() + path = fake_repo_path / str(spec.author.pk) / 'fittingspecs' / str(spec.pk) + + assert spec.repo._root == str(path) + assert spec.repo_abs_path == path + + def test_repo_is_deleted(self): + spec = recipes.fittingspec.make() + assert spec.repo_abs_path.exists() + spec.delete() + assert not spec.repo_abs_path.exists() + + def test_get_repocache(self): + spec = recipes.fittingspec.make() + assert CachedFittingSpec.objects.count() == 0 + assert spec.repocache + assert CachedFittingSpec.objects.count() == 1 + assert spec.repocache + assert CachedFittingSpec.objects.count() == 1 + spec.delete() + assert CachedFittingSpec.objects.count() == 0 diff --git a/weblab/fitting/urls.py b/weblab/fitting/urls.py new file mode 100644 index 000000000..2ceb658bc --- /dev/null +++ b/weblab/fitting/urls.py @@ -0,0 +1,135 @@ +from django.conf.urls import url + +from entities import views as entity_views + +from . import views +from .models import FittingSpec + + +_COMMIT = r'(?P[^^~:/ ]+)' +_FILENAME = r'(?P[\w\-. \%:]+)' +_FILEVIEW = r'%s/(?P\w+)' % _FILENAME +_ENTITY_TYPE = '(?P%s)s' % FittingSpec.url_type + +urlpatterns = [ + url( + r'^%s/$' % _ENTITY_TYPE, + entity_views.EntityListView.as_view(), + name='list', + ), + + url( + r'^%s/new$' % _ENTITY_TYPE, + views.FittingSpecCreateView.as_view(), + name='new', + ), + + url( + r'^%s/(?P\d+)$' % _ENTITY_TYPE, + entity_views.EntityView.as_view(), + name='detail', + ), + + url( + r'^%s/(?P\d+)/delete$' % _ENTITY_TYPE, + entity_views.EntityDeleteView.as_view(), + name='delete', + ), + + url( + r'^%s/(?P\d+)/versions/$' % _ENTITY_TYPE, + entity_views.EntityVersionListView.as_view(), + name='version_list', + ), + + url( + r'^%s/(?P\d+)/versions/new$' % _ENTITY_TYPE, + views.FittingSpecNewVersionView.as_view(), + name='newversion', + ), + + url( + r'^%s/(?P\d+)/versions/edit$' % _ENTITY_TYPE, + entity_views.EntityAlterFileView.as_view(), + name='alter_file', + ), + + url( + r'^%s/(?P\d+)/versions/%s(?:/%s)?$' % (_ENTITY_TYPE, _COMMIT, _FILEVIEW), + entity_views.EntityVersionView.as_view(), + name='version', + ), + + url( + r'^%s/(?P\d+)/versions/%s/files.json$' % (_ENTITY_TYPE, _COMMIT), + entity_views.EntityVersionJsonView.as_view(), + name='version_json', + ), + + url( + r'^%s/compare(?P(/\d+:%s){1,})(?:/show/%s)?$' % (_ENTITY_TYPE, _COMMIT, _FILEVIEW), + entity_views.EntityComparisonView.as_view(), + name='compare', + ), + + url( + r'^%s/compare(?P(/\d+:%s)*)/info$' % (_ENTITY_TYPE, _COMMIT), + entity_views.EntityComparisonJsonView.as_view(), + name='compare_json', + ), + + url( + r'^%s/compare(?P(/\d+:%s){1,})(?:/show/%s)?$' % (_ENTITY_TYPE, _COMMIT, _FILEVIEW), + entity_views.EntityComparisonView.as_view(), + name='compare', + ), + + url( + r'^%s/compare(?P(/\d+:%s)*)/info$' % (_ENTITY_TYPE, _COMMIT), + entity_views.EntityComparisonJsonView.as_view(), + name='compare_json', + ), + + + url( + r'^%s/(?P\d+)/versions/%s/download/%s$' % (_ENTITY_TYPE, _COMMIT, _FILENAME), + entity_views.EntityFileDownloadView.as_view(), + name='file_download', + ), + + url( + r'^tag/(?P\d+)/%s$' % _COMMIT, + entity_views.EntityTagVersionView.as_view(), + name='tag_version', + ), + + url( + r'^%s/(?P\d+)/versions/%s/visibility$' % (_ENTITY_TYPE, _COMMIT), + entity_views.ChangeVisibilityView.as_view(), + name='change_visibility', + ), + + url( + r'^%s/(?P\d+)/versions/%s/archive$' % (_ENTITY_TYPE, _COMMIT), + entity_views.EntityArchiveView.as_view(), + name='entity_archive', + ), + + url( + r'^(?P\d+)/upload-file$', + entity_views.FileUploadView.as_view(), + name='upload_file', + ), + + url( + r'^%s/(?P\d+)/collaborators$' % _ENTITY_TYPE, + entity_views.EntityCollaboratorsView.as_view(), + name='entity_collaborators', + ), + + url( + r'^%s/diff(?P(/\d+:%s){2})/%s$' % (_ENTITY_TYPE, _COMMIT, _FILENAME), + entity_views.EntityDiffView.as_view(), + name='diff', + ), +] diff --git a/weblab/fitting/views.py b/weblab/fitting/views.py new file mode 100644 index 000000000..5ce650eac --- /dev/null +++ b/weblab/fitting/views.py @@ -0,0 +1,42 @@ +""" +Views for fitting specifications and results. + +As far as possible these reuse code & templates from entities and experiments. + +At present I'm not sure what the best way to do this is. Many of the forms & views will +be the same, but some will differ, and sometimes quite a long way in. So we may need to +create separate versions of everything (but reuse code & templates where possible). Or +we may be able to extend the base classes to be able to delegate to elsewhere, for instance +by removing the hardcoded 'entities:' namespace from reverse() calls. +""" + +from braces.views import UserFormKwargsMixin +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.urls import reverse +from django.views.generic.edit import CreateView + +from entities.views import EntityNewVersionView, EntityTypeMixin + +from .forms import FittingSpecForm, FittingSpecVersionForm + + +class FittingSpecCreateView( + LoginRequiredMixin, PermissionRequiredMixin, EntityTypeMixin, + UserFormKwargsMixin, CreateView +): + """Create a new fitting specification, initially without any versions.""" + template_name = 'entities/entity_form.html' + permission_required = 'entities.create_fittingspec' + form_class = FittingSpecForm + + def get_success_url(self): + return reverse('fitting:newversion', + args=[self.kwargs['entity_type'], self.object.pk]) + + +class FittingSpecNewVersionView(EntityNewVersionView): + """Create a new version of a fitting specification. + + This is almost identical to other entities, except that we can't re-run experiments. + """ + form_class = FittingSpecVersionForm diff --git a/weblab/repocache/migrations/0015_auto_20191204_1427.py b/weblab/repocache/migrations/0015_auto_20191204_1427.py new file mode 100644 index 000000000..d7f4f651c --- /dev/null +++ b/weblab/repocache/migrations/0015_auto_20191204_1427.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2019-12-04 14:27 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('fitting', '0001_initial'), + ('repocache', '0014_auto_20191121_1448'), + ] + + operations = [ + migrations.CreateModel( + name='CachedFittingSpec', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='cachedfittingspec', to='fitting.FittingSpec')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CachedFittingSpecTag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tag', models.CharField(max_length=255)), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='repocache.CachedFittingSpec')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='CachedFittingSpecVersion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('visibility', models.CharField(choices=[('private', 'Private'), ('public', 'Public'), ('moderated', 'Moderated')], help_text='Public = anyone can view
Private = only you can view', max_length=16)), + ('sha', models.CharField(max_length=40)), + ('timestamp', models.DateTimeField()), + ('parsed_ok', models.BooleanField(default=False, help_text='Whether this entity version has been verified as syntactically correct')), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='repocache.CachedFittingSpec')), + ], + options={ + 'ordering': ['-timestamp', '-pk'], + 'get_latest_by': 'timestamp', + 'abstract': False, + }, + ), + migrations.AddField( + model_name='cachedfittingspectag', + name='version', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='repocache.CachedFittingSpecVersion'), + ), + migrations.AlterUniqueTogether( + name='cachedfittingspecversion', + unique_together=set([('entity', 'sha')]), + ), + migrations.AlterUniqueTogether( + name='cachedfittingspectag', + unique_together=set([('entity', 'tag')]), + ), + ] diff --git a/weblab/repocache/migrations/0020_merge_20200116_0913.py b/weblab/repocache/migrations/0020_merge_20200116_0913.py new file mode 100644 index 000000000..214de7b3b --- /dev/null +++ b/weblab/repocache/migrations/0020_merge_20200116_0913.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2020-01-16 09:13 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('repocache', '0019_auto_20200115_1533'), + ('repocache', '0015_auto_20191204_1427'), + ] + + operations = [ + ] diff --git a/weblab/repocache/migrations/0021_auto_20200116_0913.py b/weblab/repocache/migrations/0021_auto_20200116_0913.py new file mode 100644 index 000000000..1aeaa64ed --- /dev/null +++ b/weblab/repocache/migrations/0021_auto_20200116_0913.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.20 on 2020-01-16 09:13 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('repocache', '0020_merge_20200116_0913'), + ] + + operations = [ + migrations.AddField( + model_name='cachedfittingspecversion', + name='author', + field=models.TextField(default=' ', help_text='Author full name'), + ), + migrations.AddField( + model_name='cachedfittingspecversion', + name='master_filename', + field=models.TextField(default=None, help_text='Master filename', null=True), + ), + migrations.AddField( + model_name='cachedfittingspecversion', + name='message', + field=models.TextField(default=' ', help_text='Git commit message'), + ), + migrations.AddField( + model_name='cachedfittingspecversion', + name='numfiles', + field=models.IntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cachedfittingspecversion', + name='timestamp', + field=models.DateTimeField(help_text='When this commit was made'), + ), + ] diff --git a/weblab/repocache/models.py b/weblab/repocache/models.py index 499c87be1..31cc30500 100644 --- a/weblab/repocache/models.py +++ b/weblab/repocache/models.py @@ -4,6 +4,7 @@ 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 @@ -212,14 +213,35 @@ class CachedProtocolTag(CachedEntityTag): _set_class_links(CachedProtocol, CachedProtocolVersion, CachedProtocolTag) +class CachedFittingSpec(CachedEntity): + """Cache for a fitting specifications's repository.""" + entity = models.OneToOneField(FittingSpec, on_delete=models.CASCADE, related_name='cachedfittingspec') + + +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') + + +class CachedFittingSpecTag(CachedEntityTag): + """Cache for a tag in a fitting specifications's repository.""" + entity = models.ForeignKey(CachedFittingSpec, related_name='tags') + version = models.ForeignKey(CachedFittingSpecVersion, on_delete=models.CASCADE, related_name='tags') + + +_set_class_links(CachedFittingSpec, CachedFittingSpecVersion, CachedFittingSpecTag) + + CACHE_TYPE_MAP = { 'model': CachedModel, 'protocol': CachedProtocol, + 'fittingspec': CachedFittingSpec, } CACHED_VERSION_TYPE_MAP = { 'model': CachedModelVersion, 'protocol': CachedProtocolVersion, + 'fittingspec': CachedFittingSpecVersion, } diff --git a/weblab/static/js/compare.js b/weblab/static/js/compare.js index 3dd0ef6a6..ec69ab403 100644 --- a/weblab/static/js/compare.js +++ b/weblab/static/js/compare.js @@ -602,10 +602,12 @@ function parseUrl (event) basicurl = parts.slice(0, i+2).join('/') + '/'; entityType = 'experiment'; entityIds = parts.slice(i+2); - } else if ((parts[i] == 'models' || parts[i] == 'protocols') && parts[i+1] == 'compare') { + break; + } 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); + break; } } diff --git a/weblab/static/sass/style.scss b/weblab/static/sass/style.scss index 982bce588..4492dde67 100644 --- a/weblab/static/sass/style.scss +++ b/weblab/static/sass/style.scss @@ -373,12 +373,13 @@ h1#entityname, h2#entityversionname, h3#entityversionfilename { - margin-bottom:0; + margin-top: 0.5em; + margin-bottom: 0; } .suppl { - color: #888; - font-size: .8em; + color: #888; + font-size: .8em; } diff --git a/weblab/templates/datasets/dataset_detail.html b/weblab/templates/datasets/dataset_detail.html index ca1847853..09aa4ee7c 100644 --- a/weblab/templates/datasets/dataset_detail.html +++ b/weblab/templates/datasets/dataset_detail.html @@ -45,7 +45,6 @@
{% comment %} -
{% endcomment %} diff --git a/weblab/templates/datasets/dataset_list.html b/weblab/templates/datasets/dataset_list.html index d846db8e9..1bf54a728 100644 --- a/weblab/templates/datasets/dataset_list.html +++ b/weblab/templates/datasets/dataset_list.html @@ -5,6 +5,11 @@ {% block content %} + models + protocols + datasets + fitting specifications +

Your datasets

diff --git a/weblab/templates/entities/compare.html b/weblab/templates/entities/compare.html index 95ff569cc..8251a05b8 100644 --- a/weblab/templates/entities/compare.html +++ b/weblab/templates/entities/compare.html @@ -11,8 +11,8 @@

Comparison of {{ type }}s

loading...
diff --git a/weblab/templates/entities/entity_collaborators_form.html b/weblab/templates/entities/entity_collaborators_form.html index 42366b23e..844e712bc 100644 --- a/weblab/templates/entities/entity_collaborators_form.html +++ b/weblab/templates/entities/entity_collaborators_form.html @@ -2,18 +2,18 @@ {% load entities %} {% load staticfiles %} -{% block title %}{{ entity.entity_type|capfirst }} collaborators - {% endblock title %} +{% block title %}{{ entity.display_type|capfirst }} collaborators - {% endblock title %} {% block content %} {% include "./includes/entity_header.html" %} -

Add or remove collaborators for this {{ entity.entity_type }}

+

Add or remove collaborators for this {{ entity.display_type }}

{% csrf_token %} {{ formset.management_form }} -

Collaborators can create new versions of the {{ entity.entity_type }}, add tags and change the {{ entity.entity_type }} visibility.

+

Collaborators can create new versions of the {{ entity.display_type }}, add tags and change the {{ entity.display_type }} visibility.

diff --git a/weblab/templates/entities/entity_confirm_delete.html b/weblab/templates/entities/entity_confirm_delete.html index 5a5388fe8..523832589 100644 --- a/weblab/templates/entities/entity_confirm_delete.html +++ b/weblab/templates/entities/entity_confirm_delete.html @@ -1,11 +1,11 @@ {% extends "base.html" %} -{% block title %}Delete {{ object.entity_type }} - {% endblock title %} +{% block title %}Delete {{ object.display_type }} - {% endblock title %} {% block content %} {% csrf_token %} -

Are you sure you want to delete all versions of {{ object.entity_type }} "{{ object }}"?

+

Are you sure you want to delete all versions of {{ object.display_type }} "{{ object }}"?

This operation cannot be undone.

diff --git a/weblab/templates/entities/entity_list.html b/weblab/templates/entities/entity_list.html index abecfea0d..69b8eb62f 100644 --- a/weblab/templates/entities/entity_list.html +++ b/weblab/templates/entities/entity_list.html @@ -5,16 +5,17 @@ {% block content %} - models - protocols - experiments + models + protocols + datasets + fitting specifications -
+

Your {{ type }}s

- {% can_create_entity type as permission %} + {% can_create_entity entity_type as permission %} {% if permission %} - Create a new {{ type }} + Create a new {{ type }} {% else %} Your account doesn't have the authority to upload {{ type }}s; please contact us to request permission. {% endif %} @@ -22,11 +23,11 @@

Your {{ type }}s