diff --git a/core/management/commands/load_initial_data.py b/core/management/commands/load_initial_data.py index 1e4e1afd..62e24097 100644 --- a/core/management/commands/load_initial_data.py +++ b/core/management/commands/load_initial_data.py @@ -159,7 +159,7 @@ def create_elu_institutions(): data = json.load(handler) for partner in data: _current = {k: v for k, v in partner.items()} - _current.update({'is_published': True}) + _current.update({'_is_published': True}) if 'external_id' in _current: _current['elu_accession'] = _current['external_id'] _current.pop('external_id') diff --git a/core/migrations/0034_auto_20230515_1353.py b/core/migrations/0034_auto_20230515_1353.py new file mode 100644 index 00000000..f079b5ab --- /dev/null +++ b/core/migrations/0034_auto_20230515_1353.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.19 on 2023-05-15 11:53 + +import core.models.utils +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0033_auto_20230515_1147'), + ] + + operations = [ + migrations.RenameField( + model_name='cohort', + old_name='is_published', + new_name='_is_published', + ), + migrations.RenameField( + model_name='partner', + old_name='is_published', + new_name='_is_published', + ), + migrations.RemoveField( + model_name='dataset', + name='is_published', + ), + migrations.RemoveField( + model_name='project', + name='is_published', + ), + ] diff --git a/core/models/access.py b/core/models/access.py index 4258c3c4..8611c763 100644 --- a/core/models/access.py +++ b/core/models/access.py @@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Q, ObjectDoesNotExist +from django.db.models import Q, ObjectDoesNotExist, Count from enumchoicefield import EnumChoiceField, ChoiceEnum @@ -13,7 +13,6 @@ from auditlog.models import AuditlogHistoryField - class StatusChoices(ChoiceEnum): precreated = "Pre-created" active = "Active" @@ -145,7 +144,7 @@ def __str__(self): else: return f"Access ({self.status}) to dataset {self.dataset.title} given to a user: {self.user}/{self.access_notes}" except ObjectDoesNotExist as e: - return f"Access ({self.status}) to dataset {self.dataset.title} given to a deleted contact or user: {self.access_notes}" + return f"Access ({self.status}) to dataset {self.dataset.title} given to a deleted contact or user: {self.access_notes}" def delete(self, force: bool = False): self.status = StatusChoices.terminated @@ -159,12 +158,12 @@ def display_locations(self): @classmethod def find_for_user(cls, user) -> List[str]: - accesses = cls.objects.filter(user=user, dataset__is_published=True) + accesses = cls.objects.annotate(c=Count("dataset__exposures")).filter(user=user, c__gt=0) return cls._filter_expired(accesses) @classmethod def find_for_contact(cls, contact) -> List[str]: - accesses = cls.objects.filter(contact=contact, dataset__is_published=True) + accesses = cls.objects.annotate(c=Count("dataset__exposures")).filter(contact=contact, c__gt=0) return cls._filter_expired(accesses) @staticmethod diff --git a/core/models/cohort.py b/core/models/cohort.py index 31d873c2..6482d865 100644 --- a/core/models/cohort.py +++ b/core/models/cohort.py @@ -1,10 +1,10 @@ from django.db import models from django.conf import settings -from .utils import CoreTrackedModel, TextFieldWithInputWidget +from .utils import CoreTrackedDBModel, TextFieldWithInputWidget -class Cohort(CoreTrackedModel): +class Cohort(CoreTrackedDBModel): class Meta: app_label = 'core' get_latest_by = "added" diff --git a/core/models/dataset.py b/core/models/dataset.py index d41bed7d..ef990c7e 100644 --- a/core/models/dataset.py +++ b/core/models/dataset.py @@ -3,6 +3,8 @@ from django.conf import settings from django.db import models from django.urls import reverse +from django.utils.module_loading import import_string + from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase from core import constants from core.permissions.mapping import PERMISSION_MAPPING @@ -60,6 +62,11 @@ class AppMeta: verbose_name='Sensitivity class', help_text='Sensitivity denotes the security classification of this dataset.') + @property + def is_published(self): + exposures_list = self.exposures.all() + return len(exposures_list) > 0 + @property def data_types(self): all_data_types = set() @@ -163,13 +170,15 @@ def serialize_to_export(self): return d def publish(self, save=True): - if self.project: - self.project.publish() - + generate_id_function_path = getattr(settings, 'IDSERVICE_FUNCTION') + generate_id_function = import_string(generate_id_function_path) + if not self.elu_accession: + self.elu_accession = generate_id_function(self) + if save: + self.save(update_fields=['elu_accession']) + for data_declaration in self.data_declarations.all(): data_declaration.publish_subentities() - - super().publish() # faster lookup for permissions diff --git a/core/models/partner.py b/core/models/partner.py index d5baf46d..5f3cac1c 100644 --- a/core/models/partner.py +++ b/core/models/partner.py @@ -3,7 +3,7 @@ from django_countries.fields import CountryField from model_utils import Choices -from .utils import CoreTrackedModel, TextFieldWithInputWidget +from .utils import CoreTrackedDBModel, TextFieldWithInputWidget from elixir_daisy import settings @@ -21,7 +21,7 @@ ) -class Partner(CoreTrackedModel): +class Partner(CoreTrackedDBModel): """ Represents a partner. { diff --git a/core/models/project.py b/core/models/project.py index 28ec540c..bc5511e2 100644 --- a/core/models/project.py +++ b/core/models/project.py @@ -151,7 +151,9 @@ def __str__(self): return self.acronym or self.title or "undefined" - + @property + def is_published(self): + return any(dataset.is_published for dataset in self.datasets.all()) def to_dict(self): contact_dicts = [] @@ -217,6 +219,9 @@ def serialize_to_export(self): d['publications'] = ','.join(publications) return d + def publish(self): + pass + # faster lookup for permissions # https://django-guardian.readthedocs.io/en/stable/userguide/performance.html#direct-foreign-keys class ProjectUserObjectPermission(UserObjectPermissionBase): diff --git a/core/models/utils.py b/core/models/utils.py index f9a3f132..0e93bc0b 100644 --- a/core/models/utils.py +++ b/core/models/utils.py @@ -1,3 +1,4 @@ +from abc import abstractmethod from json import loads from json.decoder import JSONDecodeError @@ -11,6 +12,7 @@ COMPANY = getattr(settings, "COMPANY", 'Company') + def validate_json(value): if len(value) == 0: return value @@ -18,12 +20,13 @@ def validate_json(value): try: loads(value) if '{' not in value: # Very inaccurate, but should do the trick when the user tries to save e.g. '123' - raise ValidationError(f'`scientific_metadata` field must be a valid JSON containing a dictionary!') + raise ValidationError(f'`scientific_metadata` field must be a valid JSON containing a dictionary!') return value except JSONDecodeError as ex: msg = str(ex) raise ValidationError(f'`scientific_metadata` field must contain a valid JSON! ({msg})') + class classproperty(property): def __get__(self, cls, owner): return self.fget.__get__(None, owner)() @@ -43,11 +46,6 @@ class CoreTrackedModel(CoreModel): blank=True, null=True, max_length=20) - - is_published = models.BooleanField( - default=False, - blank=False, - verbose_name='Is published?') scientific_metadata = models.TextField( default='{}', @@ -56,26 +54,17 @@ class CoreTrackedModel(CoreModel): verbose_name='Additional scientific metadata (in JSON format)', validators=[validate_json] # This will work in ModelForm only ) + class Meta: abstract = True - def publish(self, save=True): - generate_id_function_path = getattr(settings, 'IDSERVICE_FUNCTION') - generate_id_function = import_string(generate_id_function_path) - if not self.is_published: - self.is_published = True - if not self.elu_accession: - self.elu_accession = generate_id_function(self) - if save: - self.save(update_fields=['is_published', 'elu_accession']) + @abstractmethod + def is_published(self): + pass - def generate_elu_accession(self, save=True): - generate_id_function_path = getattr(settings, 'IDSERVICE_FUNCTION') - generate_id_function = import_string(generate_id_function_path) - if not self.elu_accession: - self.elu_accession = generate_id_function(self) - if save: - self.save(update_fields=['elu_accession']) + @abstractmethod + def publish(self): + pass def clean(self): cleaned_data = super().clean() @@ -87,6 +76,36 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) +class CoreTrackedDBModel(CoreTrackedModel): + _is_published = models.BooleanField( + default=False, + blank=False, + verbose_name='Is published?') + + class Meta: + abstract = True + + def publish(self, save=True): + generate_id_function_path = getattr(settings, 'IDSERVICE_FUNCTION') + generate_id_function = import_string(generate_id_function_path) + if not self.is_published: + self.is_published = True + if not self.elu_accession: + self.elu_accession = generate_id_function(self) + if save: + self.save(update_fields=['_is_published', 'elu_accession']) + + @property + def is_published(self): + # Getter method + return self._is_published + + @is_published.setter + def is_published(self, value): + # Setter method + self._is_published = value + + class TextFieldWithInputWidget(TextField): def formfield(self, **kwargs): @@ -105,11 +124,10 @@ class HashedField(models.CharField): A custom field that will store a hash of the provided value. """ description = "Keeps the hash of the string in the DB" - + def pre_save(self, model_instance, add): """ This function is called when the value is about to be saved to the DB. We hash the value and return it. """ value = getattr(model_instance, self.attname) return make_password(value, salt=settings.SECRET_KEY) - diff --git a/core/tests/test_lcsb.py b/core/tests/test_lcsb.py index eca22284..6ee24265 100644 --- a/core/tests/test_lcsb.py +++ b/core/tests/test_lcsb.py @@ -10,7 +10,7 @@ from core.lcsb.rems import create_rems_entitlement from core.models.access import Access from core.models.contact import Contact -from test.factories import ContactFactory, DatasetFactory, UserFactory +from test.factories import ContactFactory, DatasetFactory, UserFactory, ExposureFactory, EndpointFactory from web.views.utils import get_user_or_contact_by_oidc_id @@ -120,7 +120,9 @@ def test_permissions(): contact = ContactFactory() contact.save() - dataset = DatasetFactory(title='Test', local_custodians=[user], elu_accession='123', is_published=True) + dataset = DatasetFactory(title='Test', local_custodians=[user], elu_accession='123') + endpoint = EndpointFactory() + _ = ExposureFactory(dataset=dataset, endpoint=endpoint) dataset.save() access = Access(access_notes='Access contact', contact=contact, dataset=dataset, status=StatusChoices.active) diff --git a/elixir_daisy/settings.py b/elixir_daisy/settings.py index 09236663..74c1584c 100644 --- a/elixir_daisy/settings.py +++ b/elixir_daisy/settings.py @@ -282,6 +282,7 @@ 'consent_status', 'data_types', 'deidentification_method', + 'is_published', ), 'contract': ( 'contacts', diff --git a/web/templates/projects/project.html b/web/templates/projects/project.html index 6f34df2c..63c1b920 100644 --- a/web/templates/projects/project.html +++ b/web/templates/projects/project.html @@ -7,11 +7,6 @@ {% block content %}
- {% if project.is_published %} -
Published
- {% else %} -
Not Published
- {% endif %} Project

{{ project.acronym }}

@@ -78,24 +73,6 @@

{{ project.acronym }}

{% endif %} - {% if request.user.can_publish %} - {% if not project.is_published %} - - publish - - {% else %} - - undo - - {% endif %} - {% endif %} {% if is_admin %} folder Data of {% for dataset in project.datasets.all %}
  • {{ dataset }} + {% if dataset.is_published %} + Publicly Available + {% endif %} +
  • {% endfor %} diff --git a/web/templates/search/_items/datasets.html b/web/templates/search/_items/datasets.html index 0aafb272..3a4b743a 100644 --- a/web/templates/search/_items/datasets.html +++ b/web/templates/search/_items/datasets.html @@ -6,6 +6,7 @@

    {{ dataset.title }}

      +
    • Published: {{ dataset.is_published | yesno }}
    • Datatypes: {% for datatype in dataset.data_types %} {{ datatype }} diff --git a/web/tests/views/test_project_views.py b/web/tests/views/test_project_views.py index 613d32bc..070fb86a 100644 --- a/web/tests/views/test_project_views.py +++ b/web/tests/views/test_project_views.py @@ -150,7 +150,7 @@ def test_project_edit_protected_documents(permissions, group): os.remove(document.content.name) @pytest.mark.parametrize('group', [VIPGroup, DataStewardGroup, LegalGroup, AuditorGroup]) -@pytest.mark.parametrize('url_name', ['project_publish', 'project_unpublish', 'projects_export']) +@pytest.mark.parametrize('url_name', ['projects_export']) def test_projects_publications_and_export(permissions, group, url_name): user = UserFactory(groups=[group()]) kwargs = {} diff --git a/web/urls.py b/web/urls.py index 0cac011f..4e02d282 100644 --- a/web/urls.py +++ b/web/urls.py @@ -24,7 +24,7 @@ PartnerEditView, partner_search_view, \ publish_partner, unpublish_partner from web.views.projects import ProjectCreateView, ProjectEditView, ProjectDetailView, \ - ProjectDelete, publish_project, unpublish_project, dsw_list_projects + ProjectDelete, dsw_list_projects from web.views.publication import PublicationCreateView, PublicationListView, \ PublicationEditView, add_publication_to_project, \ remove_publication_from_project, pick_publication_for_project @@ -164,8 +164,6 @@ path('project//', ProjectDetailView.as_view(), name="project"), path('project//delete', ProjectDelete.as_view(), name="project_delete"), path('project//edit', ProjectEditView.as_view(), name="project_edit"), - path('project//publish', publish_project, name="project_publish"), - path('project//unpublish', unpublish_project, name="project_unpublish"), path('project//add-contact', add_contact_to_project, name="add_contact_to_project"), path('project//add-dataset', datasets.DatasetCreateView.as_view(), name="datasets_add_to_project"), path('project//add-personnel', add_personnel_to_project, name="add_personnel_to_project"), diff --git a/web/views/api.py b/web/views/api.py index 62beacad..fffc5781 100644 --- a/web/views/api.py +++ b/web/views/api.py @@ -11,7 +11,7 @@ from django.core.paginator import Paginator from django.http import JsonResponse, HttpResponse, HttpResponseBadRequest from django.views.decorators.csrf import csrf_exempt -from django.db.models import Q +from django.db.models import Q, Count from django.contrib.auth.hashers import get_hasher from stronghold.decorators import public @@ -174,7 +174,7 @@ def get_filtered_entities(request, model_name): @protect_with_api_key def contracts(request): objects = get_filtered_entities(request, 'Contract') - objects = objects.filter(project__is_published=True) + objects = objects.anotate(c=Count("project__datasets__exposures")).filter(project__is_published=True, c__gt=0) if 'project_id' in request.GET: project_id = request.GET.get('project_id', '') objects = objects.filter(project__id=project_id) diff --git a/web/views/exposure.py b/web/views/exposure.py index 01df2349..fdbdfcf1 100644 --- a/web/views/exposure.py +++ b/web/views/exposure.py @@ -20,9 +20,11 @@ class DataStewardGroupRequiredMixin(UserPassesTestMixin): def test_func(self): return can_publish(self.request.user) + class ExposureCreateView(DataStewardGroupRequiredMixin, CreateView, AjaxViewMixin): model = Exposure form_class = ExposureForm + def dispatch(self, request, *args, **kwargs): """ Hook method to save related dataset. @@ -40,7 +42,8 @@ def form_valid(self, form): self.object.dataset = self.dataset self.object.save() messages.add_message(self.request, messages.SUCCESS, 'exposure endpoint created') - self.dataset.generate_elu_accession() + # generate elu accession for dataset & publish subentities + self.dataset.publish() return super().form_valid(form) def get_form_kwargs(self): @@ -56,6 +59,7 @@ def get_success_url(self, **kwargs): class ExposureEditView(DataStewardGroupRequiredMixin, UpdateView, AjaxViewMixin): model = Exposure form_class = ExposureEditForm + def dispatch(self, request, *args, **kwargs): """ Hook method to save related dataset and endpoint. @@ -100,7 +104,8 @@ def remove_exposure(request, dataset_pk, exposure_pk): exposure = get_object_or_404(Exposure, pk=exposure_pk) if exposure.dataset == dataset: exposure.delete() + if len(dataset.exposures.all()) < 1: + # needed to trigger reindex of dataset and changing dataset.is_published to false + dataset.save() messages.add_message(request, messages.SUCCESS, 'exposure record deleted.') return HttpResponse("exposure deleted") - - diff --git a/web/views/partner.py b/web/views/partner.py index 6e1e2619..39672c11 100644 --- a/web/views/partner.py +++ b/web/views/partner.py @@ -115,7 +115,7 @@ def unpublish_partner(request, pk): partner = get_object_or_404(Partner, pk=pk) if partner.is_published: partner.is_published = False - partner.save(update_fields=['is_published']) + partner.save(update_fields=['_is_published']) return HttpResponseRedirect(reverse_lazy('partner', kwargs={'pk': pk})) diff --git a/web/views/projects.py b/web/views/projects.py index 651e7192..03a35fec 100644 --- a/web/views/projects.py +++ b/web/views/projects.py @@ -3,6 +3,7 @@ from django.contrib.auth.decorators import user_passes_test from django.contrib.contenttypes.models import ContentType from django.db import transaction, IntegrityError +from django.db.models import Count from django.http import HttpResponse from django.shortcuts import render, get_object_or_404, redirect from django.urls import reverse_lazy @@ -267,26 +268,12 @@ def get_context_data(self, **kwargs): return context -@user_passes_test(is_data_steward) -def publish_project(request, pk): - project = get_object_or_404(Project, pk=pk) - project.publish() - return redirect(reverse_lazy('project', kwargs={'pk': project.id})) - - -@user_passes_test(is_data_steward) -def unpublish_project(request, pk): - project = get_object_or_404(Project, pk=pk) - project.is_published = False - project.save() - return redirect(reverse_lazy('project', kwargs={'pk': project.id})) - - def dsw_list_projects(request): #if data steward or admin -> list all the public projects # if(request.user.is_admin() | request.user.is_datasteward()): # objects = Project.objects.all().filter(is_published=True) - objects = (Project.objects.filter(local_custodians=request.user, is_published=True) | Project.objects.filter(company_personnel=request.user, is_published=True)).distinct() + objects = (Project.objects.filter(local_custodians=request.user) | Project.objects.filter(company_personnel=request.user)).distinct() + objects = objects.annotate(c=Count('datasets__exposures')).filter(c__gt=0) return render(request, 'integrations/dsw/project_list.html', { 'dsw_origin': getattr(settings, 'DSW_ORIGIN', 'localhost'), 'projects': [{'url': reverse('project', args=[str(project.id)]),