diff --git a/.env.dist b/.env.dist index 518ad98a..b9e98292 100644 --- a/.env.dist +++ b/.env.dist @@ -23,3 +23,8 @@ DJANGO_EMAIL_PORT= DJANGO_EMAIL_HOST_USER= DJANGO_EMAIL_HOST_PASSWORD= DJANGO_EMAIL_USE_TLS= + +# drupal api +DRUPAL_API_CLIENT_ID= +DRUPAL_API_CLIENT_SECRET= +DRUPAL_API_REL_PATH= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8a8fd23..4570f6cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: # ahead of planned upgrades we can add versions as # needed python-version: [3.8] - mariadb-version: ["10.4"] + mariadb-version: ["10.4", "10.5"] services: mysql: diff --git a/add_cdsa_example_data.py b/add_cdsa_example_data.py index 44ef9d69..da5c0058 100644 --- a/add_cdsa_example_data.py +++ b/add_cdsa_example_data.py @@ -8,14 +8,23 @@ ManagedGroupFactory, ) from django.conf import settings +from django.core.management import call_command from primed.cdsa.tests import factories -from primed.duo.tests.factories import DataUseModifierFactory, DataUsePermissionFactory +from primed.duo.models import DataUseModifier, DataUsePermission from primed.primed_anvil.models import Study, StudySite from primed.primed_anvil.tests.factories import StudyFactory, StudySiteFactory from primed.users.models import User from primed.users.tests.factories import UserFactory +# Load duos +call_command("load_duo") + +# create the CDSA auth group +cdsa_group = ManagedGroupFactory.create(name=settings.ANVIL_CDSA_GROUP_NAME) +# Add PRIMED ADMINS group +cc_admins_group = ManagedGroupFactory.create(name=settings.ANVIL_CC_ADMINS_GROUP_NAME) + # Create major versions major_version = factories.AgreementMajorVersionFactory.create(version=1) @@ -28,11 +37,8 @@ ) # Create a couple signed CDSAs. -dup = DataUsePermissionFactory.create(abbreviation="GRU") -dum = DataUseModifierFactory.create(abbreviation="NPU") - -# create the CDSA auth group -cdsa_group = ManagedGroupFactory.create(name=settings.ANVIL_CDSA_GROUP_NAME) +dup = DataUsePermission.objects.get(abbreviation="GRU") +dum = DataUseModifier.objects.get(abbreviation="NPU") # Create some study sites. StudySiteFactory.create(short_name="CC", full_name="Coordinating Center") @@ -47,7 +53,7 @@ signed_agreement__representative=UserFactory.create(name="Ken Rice"), signed_agreement__signing_institution="UW", signed_agreement__representative_role="Contact PI", - signed_agreement__is_primary=True, + is_primary=True, signed_agreement__version=v10, study_site=StudySite.objects.get(short_name="CC"), ) @@ -60,7 +66,7 @@ signed_agreement__representative=UserFactory.create(name="Sally Adebamowo"), signed_agreement__signing_institution="UM", signed_agreement__representative_role="Contact PI", - signed_agreement__is_primary=True, + is_primary=True, signed_agreement__version=v10, study_site=StudySite.objects.get(short_name="CARDINAL"), ) @@ -73,7 +79,7 @@ signed_agreement__representative=UserFactory.create(name="Bamidele Tayo"), signed_agreement__signing_institution="Loyola", signed_agreement__representative_role="Co-PI", - signed_agreement__is_primary=False, + is_primary=False, signed_agreement__version=v10, study_site=StudySite.objects.get(short_name="CARDINAL"), ) @@ -86,7 +92,7 @@ signed_agreement__representative=UserFactory.create(name="Brackie Mitchell"), signed_agreement__signing_institution="UM", signed_agreement__representative_role="Co-I", - signed_agreement__is_primary=False, + is_primary=False, signed_agreement__version=v11, study_site=StudySite.objects.get(short_name="CARDINAL"), ) @@ -113,6 +119,8 @@ signed_agreement__signing_institution="UW", study=Study.objects.get(short_name="MESA"), signed_agreement__version=v10, + additional_limitations="This data can only be used for testing the app.", + requires_study_review=True, ) GroupGroupMembershipFactory.create( parent_group=cdsa_group, child_group=cdsa_1006.signed_agreement.anvil_access_group @@ -123,7 +131,7 @@ signed_agreement__representative=UserFactory.create(name="Wendy"), signed_agreement__signing_institution="JHU", signed_agreement__representative_role="Field Center PI", - signed_agreement__is_primary=False, + is_primary=False, study=Study.objects.get(short_name="MESA"), signed_agreement__version=v10, ) @@ -136,7 +144,7 @@ signed_agreement__representative=UserFactory.create(name="Jerry"), signed_agreement__signing_institution="Lundquist", signed_agreement__representative_role="Analysis Center PI", - signed_agreement__is_primary=False, + is_primary=False, study=Study.objects.get(short_name="MESA"), signed_agreement__version=v10, ) @@ -219,5 +227,17 @@ workspace__name="DEMO_PRIMED_CDSA_MESA_2", study=Study.objects.get(short_name="MESA"), data_use_permission=dup, + additional_limitations="Additional limitations for workspace.", ) cdsa_workspace_2.data_use_modifiers.add(dum) + + +# Add a workspace with no primary cdsa. +cdsa_workspace_3 = factories.CDSAWorkspaceFactory.create( + workspace__billing_project__name="demo-primed-cdsa", + workspace__name="DEMO_PRIMED_CDSA_ARIC_1", + study=Study.objects.create( + short_name="ARIC", full_name="Atherosclerosis Risk in Communities" + ), + data_use_permission=dup, +) diff --git a/add_data_prep_example_data.py b/add_data_prep_example_data.py new file mode 100644 index 00000000..76136f9b --- /dev/null +++ b/add_data_prep_example_data.py @@ -0,0 +1,44 @@ +# Temporary script to create some test data. +# Run with: python manage.py shell < add_data_prep_example_data.py + + +from primed.cdsa.tests.factories import CDSAWorkspaceFactory +from primed.dbgap.tests.factories import dbGaPWorkspaceFactory +from primed.miscellaneous_workspaces.tests import factories +from primed.primed_anvil.tests.factories import StudyFactory + +# Create a dbGaP workspace. +fhs = StudyFactory.create(short_name="FHS", full_name="Framingham Heart Study") +workspace_dbgap = dbGaPWorkspaceFactory.create( + dbgap_study_accession__dbgap_phs=7, + dbgap_study_accession__studies=[fhs], + dbgap_version=33, + dbgap_participant_set=12, + dbgap_consent_code=1, + dbgap_consent_abbreviation="HMB", + workspace__name="DBGAP_FHS_v33_p12_HMB", +) + +# Create a data prep workspace. +workspace_dbgap_prep = factories.DataPrepWorkspaceFactory.create( + target_workspace=workspace_dbgap.workspace, + workspace__name="DBGAP_FHS_v33_p12_HMB_PREP", +) + + +# Create a CDSA workspace. +workspace_cdsa = CDSAWorkspaceFactory.create( + study__short_name="MESA", + workspace__name="CDSA_MESA_HMB", +) + +# Create a data prep workspace. +factories.DataPrepWorkspaceFactory.create( + target_workspace=workspace_cdsa.workspace, + workspace__name="CDSA_MESA_HMB_PREP_1", + is_active=False, +) +workspace_cdsa_prep = factories.DataPrepWorkspaceFactory.create( + target_workspace=workspace_cdsa.workspace, + workspace__name="CDSA_MESA_HMB_PREP_2", +) diff --git a/config/settings/base.py b/config/settings/base.py index 9710eaeb..01092f24 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -85,6 +85,7 @@ "simple_history", "dbbackup", "django_htmx", + "constance", ] LOCAL_APPS = [ @@ -205,6 +206,7 @@ "django.template.context_processors.static", "django.template.context_processors.tz", "django.contrib.messages.context_processors.messages", + "constance.context_processors.config", "primed.utils.context_processors.settings_context", ], }, @@ -368,6 +370,17 @@ # https://django-tables2.readthedocs.io/en/latest/pages/custom-rendering.html?highlight=django_tables2_template#available-templates DJANGO_TABLES2_TEMPLATE = "django_tables2/bootstrap5.html" +# django-constance +# ------------------------------------------------------------------------------ +CONSTANCE_CONFIG = { + "ANNOUNCEMENT_TEXT": ("", "Site-wide announcement message", str), +} + +CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend" +CONSTANCE_IGNORE_ADMIN_VERSION_CHECK = True +# CONSTANCE_DATABASE_CACHE_BACKEND = "default" +CONSTANCE_DATABASE_CACHE_AUTOFILL_TIMEOUT = None + # django-anvil-consortium-manager # ------------------------------------------------------------------------------ ANVIL_API_SERVICE_ACCOUNT_FILE = env("ANVIL_API_SERVICE_ACCOUNT_FILE") @@ -390,3 +403,13 @@ # Specify the subject for AnVIL account verification emails. ANVIL_ACCOUNT_LINK_EMAIL_SUBJECT = "Verify your AnVIL account email" ANVIL_ACCOUNT_VERIFY_NOTIFICATION_EMAIL = "primedconsortium@uw.edu" + +DRUPAL_API_CLIENT_ID = env("DRUPAL_API_CLIENT_ID", default="") +DRUPAL_API_CLIENT_SECRET = env("DRUPAL_API_CLIENT_SECRET", default="") +DRUPAL_API_REL_PATH = env("DRUPAL_API_REL_PATH", default="mockapi") +DRUPAL_DATA_AUDIT_DEACTIVATE_USERS = env( + "DRUPAL_DATA_AUDIT_DEACTIVATE_USERS", default=False +) +DRUPAL_DATA_AUDIT_REMOVE_USER_SITES = env( + "DRUPAL_DATA_AUDIT_REMOVE_USER_SITES", default=False +) diff --git a/primed/cdsa/adapters.py b/primed/cdsa/adapters.py index e4409ddd..a4767906 100644 --- a/primed/cdsa/adapters.py +++ b/primed/cdsa/adapters.py @@ -1,5 +1,8 @@ from anvil_consortium_manager.adapters.workspace import BaseWorkspaceAdapter from anvil_consortium_manager.forms import WorkspaceForm +from anvil_consortium_manager.models import Workspace + +from primed.miscellaneous_workspaces.tables import DataPrepWorkspaceUserTable from . import forms, models, tables @@ -18,3 +21,22 @@ class CDSAWorkspaceAdapter(BaseWorkspaceAdapter): workspace_data_model = models.CDSAWorkspace workspace_data_form_class = forms.CDSAWorkspaceForm workspace_detail_template_name = "cdsa/cdsaworkspace_detail.html" + + def get_extra_detail_context_data(self, workspace, request): + extra_context = {} + associated_data_prep = Workspace.objects.filter( + dataprepworkspace__target_workspace=workspace + ) + extra_context["associated_data_prep_workspaces"] = DataPrepWorkspaceUserTable( + associated_data_prep + ) + extra_context["data_prep_active"] = associated_data_prep.filter( + dataprepworkspace__is_active=True + ).exists() + # Get the primary CDSA for this study, assuming it exists. + try: + extra_context["primary_cdsa"] = workspace.cdsaworkspace.get_primary_cdsa() + except models.DataAffiliateAgreement.DoesNotExist: + extra_context["primary_cdsa"] = None + + return extra_context diff --git a/primed/cdsa/admin.py b/primed/cdsa/admin.py index cdc71f4d..79abf632 100644 --- a/primed/cdsa/admin.py +++ b/primed/cdsa/admin.py @@ -46,13 +46,13 @@ class SignedAgreement(SimpleHistoryAdmin): "cc_id", "representative", "type", - "is_primary", + # "is_primary", "date_signed", "version", ) list_filter = ( "type", - "is_primary", + # "is_primary", "version", "status", ) @@ -79,7 +79,7 @@ class MemberAgreementAdmin(SimpleHistoryAdmin): ) list_filter = ( "study_site", - "signed_agreement__is_primary", + # "signed_agreement__is_primary", "signed_agreement__status", ) @@ -94,7 +94,7 @@ class DataAffiliateAgreementAdmin(SimpleHistoryAdmin): ) list_filter = ( "study", - "signed_agreement__is_primary", + # "signed_agreement__is_primary", "signed_agreement__status", ) @@ -108,7 +108,7 @@ class NonDataAffiliateAgreementAdmin(SimpleHistoryAdmin): "affiliation", ) list_filter = ( - "signed_agreement__is_primary", + # "signed_agreement__is_primary", "signed_agreement__status", ) diff --git a/primed/cdsa/audit/signed_agreement_audit.py b/primed/cdsa/audit/signed_agreement_audit.py index 929783c4..669df722 100644 --- a/primed/cdsa/audit/signed_agreement_audit.py +++ b/primed/cdsa/audit/signed_agreement_audit.py @@ -119,9 +119,6 @@ class SignedAgreementAccessAudit(PRIMEDAudit): PRIMARY_NOT_ACTIVE = "Primary agreement for this CDSA is not active." # Other errors - ERROR_NON_DATA_AFFILIATE_COMPONENT = ( - "Non-data affiliate agreements must be primary." - ) ERROR_OTHER_CASE = "Signed Agreement did not match any expected situations." results_table_class = SignedAgreementAccessAuditTable @@ -218,23 +215,15 @@ def _audit_component_agreement(self, signed_agreement): if hasattr(signed_agreement, "memberagreement"): # Member primary_qs = models.SignedAgreement.objects.filter( - is_primary=True, + memberagreement__is_primary=True, memberagreement__study_site=signed_agreement.memberagreement.study_site, ) elif hasattr(signed_agreement, "dataaffiliateagreement"): # Data affiliate primary_qs = models.SignedAgreement.objects.filter( - is_primary=True, + dataaffiliateagreement__is_primary=True, dataaffiliateagreement__study=signed_agreement.dataaffiliateagreement.study, ) - elif hasattr(signed_agreement, "nondataaffiliateagreement"): - self.errors.append( - OtherError( - signed_agreement=signed_agreement, - note=self.ERROR_NON_DATA_AFFILIATE_COMPONENT, - ) - ) - return primary_exists = primary_qs.exists() primary_active = primary_qs.filter( status=models.SignedAgreement.StatusChoices.ACTIVE, @@ -320,7 +309,8 @@ def _audit_component_agreement(self, signed_agreement): ) # pragma: no cover def _audit_signed_agreement(self, signed_agreement): - if signed_agreement.is_primary: + agreement_type = signed_agreement.get_agreement_type() + if not hasattr(agreement_type, "is_primary") or agreement_type.is_primary: self._audit_primary_agreement(signed_agreement) else: self._audit_component_agreement(signed_agreement) diff --git a/primed/cdsa/audit/workspace_audit.py b/primed/cdsa/audit/workspace_audit.py index 2e329859..e81d8287 100644 --- a/primed/cdsa/audit/workspace_audit.py +++ b/primed/cdsa/audit/workspace_audit.py @@ -153,7 +153,7 @@ def _audit_workspace(self, workspace): child_group=self.anvil_cdsa_group, ).exists() primary_qs = models.DataAffiliateAgreement.objects.filter( - study=workspace.study, signed_agreement__is_primary=True + study=workspace.study, is_primary=True ) primary_exists = primary_qs.exists() diff --git a/primed/cdsa/forms.py b/primed/cdsa/forms.py index cfa854d2..5ec7d116 100644 --- a/primed/cdsa/forms.py +++ b/primed/cdsa/forms.py @@ -22,12 +22,6 @@ class Meta: class SignedAgreementForm(Bootstrap5MediaFormMixin, forms.ModelForm): """Form for a SignedAgreement object.""" - is_primary = forms.TypedChoiceField( - coerce=lambda x: x == "True", - choices=((True, "Primary"), (False, "Component")), - widget=forms.RadioSelect, - label="Agreement type", - ) version = forms.ModelChoiceField( queryset=models.AgreementVersion.objects.filter(major_version__is_valid=True) ) @@ -41,7 +35,6 @@ class Meta: "signing_institution", "version", "date_signed", - "is_primary", ) widgets = { "representative": autocomplete.ModelSelect2( @@ -63,20 +56,38 @@ class Meta: class MemberAgreementForm(forms.ModelForm): + is_primary = forms.TypedChoiceField( + coerce=lambda x: x == "True", + choices=((True, "Primary"), (False, "Component")), + widget=forms.RadioSelect, + label="Agreement type", + ) + class Meta: model = models.MemberAgreement fields = ( "signed_agreement", "study_site", + "is_primary", ) class DataAffiliateAgreementForm(Bootstrap5MediaFormMixin, forms.ModelForm): + is_primary = forms.TypedChoiceField( + coerce=lambda x: x == "True", + choices=((True, "Primary"), (False, "Component")), + widget=forms.RadioSelect, + label="Agreement type", + ) + class Meta: model = models.DataAffiliateAgreement fields = ( "signed_agreement", "study", + "is_primary", + "additional_limitations", + "requires_study_review", ) widgets = { "study": autocomplete.ModelSelect2( @@ -114,7 +125,7 @@ class Meta: "data_use_permission", "data_use_modifiers", "disease_term", - "data_use_limitations", + "additional_limitations", "gsr_restricted", "acknowledgments", "available_data", diff --git a/primed/cdsa/helpers.py b/primed/cdsa/helpers.py index 5f613969..ffc6a416 100644 --- a/primed/cdsa/helpers.py +++ b/primed/cdsa/helpers.py @@ -13,7 +13,7 @@ def get_study_records_table(): """Return the queryset for study records.""" qs = models.DataAffiliateAgreement.objects.filter( signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, - signed_agreement__is_primary=True, + is_primary=True, ) return tables.StudyRecordsTable(qs) diff --git a/primed/cdsa/migrations/0015_dataaffiliateagreement_additional_limitations_and_more.py b/primed/cdsa/migrations/0015_dataaffiliateagreement_additional_limitations_and_more.py new file mode 100644 index 00000000..2db9518a --- /dev/null +++ b/primed/cdsa/migrations/0015_dataaffiliateagreement_additional_limitations_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.10 on 2024-03-12 21:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cdsa", "0014_gsr_restricted_help_and_verbose"), + ] + + operations = [ + migrations.AddField( + model_name="dataaffiliateagreement", + name="additional_limitations", + field=models.TextField( + blank=True, + help_text="Additional limitations on data use as specified in the signed CDSA.", + ), + ), + migrations.AddField( + model_name="historicaldataaffiliateagreement", + name="additional_limitations", + field=models.TextField( + blank=True, + help_text="Additional limitations on data use as specified in the signed CDSA.", + ), + ), + ] diff --git a/primed/cdsa/migrations/0016_rename_data_use_limitations_cdsaworkspace_additional_limitations_and_more.py b/primed/cdsa/migrations/0016_rename_data_use_limitations_cdsaworkspace_additional_limitations_and_more.py new file mode 100644 index 00000000..6679fa3e --- /dev/null +++ b/primed/cdsa/migrations/0016_rename_data_use_limitations_cdsaworkspace_additional_limitations_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.10 on 2024-03-14 18:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("cdsa", "0015_dataaffiliateagreement_additional_limitations_and_more"), + ] + + operations = [ + migrations.RenameField( + model_name="cdsaworkspace", + old_name="data_use_limitations", + new_name="additional_limitations", + ), + migrations.RenameField( + model_name="historicalcdsaworkspace", + old_name="data_use_limitations", + new_name="additional_limitations", + ), + ] diff --git a/primed/cdsa/migrations/0017_alter_cdsaworkspace_additional_limitations_and_more.py b/primed/cdsa/migrations/0017_alter_cdsaworkspace_additional_limitations_and_more.py new file mode 100644 index 00000000..79b56330 --- /dev/null +++ b/primed/cdsa/migrations/0017_alter_cdsaworkspace_additional_limitations_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.10 on 2024-03-14 18:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "cdsa", + "0016_rename_data_use_limitations_cdsaworkspace_additional_limitations_and_more", + ), + ] + + operations = [ + migrations.AlterField( + model_name="cdsaworkspace", + name="additional_limitations", + field=models.TextField( + blank=True, + help_text="Additional data use limitations that cannot be captured by DUO.", + ), + ), + migrations.AlterField( + model_name="historicalcdsaworkspace", + name="additional_limitations", + field=models.TextField( + blank=True, + help_text="Additional data use limitations that cannot be captured by DUO.", + ), + ), + ] diff --git a/primed/cdsa/migrations/0018_dataaffiliateagreement_requires_study_review_and_more.py b/primed/cdsa/migrations/0018_dataaffiliateagreement_requires_study_review_and_more.py new file mode 100644 index 00000000..de611aa7 --- /dev/null +++ b/primed/cdsa/migrations/0018_dataaffiliateagreement_requires_study_review_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.10 on 2024-03-15 20:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cdsa", "0017_alter_cdsaworkspace_additional_limitations_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="dataaffiliateagreement", + name="requires_study_review", + field=models.BooleanField( + default=False, + help_text="Indicator of whether indicates investigators need to have an approved PRIMED paper proposal where this dataset was selected and approved in order to work with data brought under this CDSA.", + ), + ), + migrations.AddField( + model_name="historicaldataaffiliateagreement", + name="requires_study_review", + field=models.BooleanField( + default=False, + help_text="Indicator of whether indicates investigators need to have an approved PRIMED paper proposal where this dataset was selected and approved in order to work with data brought under this CDSA.", + ), + ), + ] diff --git a/primed/cdsa/migrations/0019_alter_signedagreement_is_primary.py b/primed/cdsa/migrations/0019_alter_signedagreement_is_primary.py new file mode 100644 index 00000000..cc8ab57f --- /dev/null +++ b/primed/cdsa/migrations/0019_alter_signedagreement_is_primary.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.10 on 2024-03-18 17:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cdsa", "0018_dataaffiliateagreement_requires_study_review_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="historicalsignedagreement", + name="is_primary", + field=models.BooleanField( + help_text="Indicator of whether this is a primary Agreement (and not a component Agreement).", + null=True, + ), + ), + migrations.AlterField( + model_name="signedagreement", + name="is_primary", + field=models.BooleanField( + help_text="Indicator of whether this is a primary Agreement (and not a component Agreement).", + null=True, + ), + ), + ] diff --git a/primed/cdsa/migrations/0020_dataaffiliateagreement_is_primary_and_more.py b/primed/cdsa/migrations/0020_dataaffiliateagreement_is_primary_and_more.py new file mode 100644 index 00000000..181a5bac --- /dev/null +++ b/primed/cdsa/migrations/0020_dataaffiliateagreement_is_primary_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 4.2.10 on 2024-03-15 22:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cdsa", "0019_alter_signedagreement_is_primary"), + ] + + operations = [ + migrations.AddField( + model_name="dataaffiliateagreement", + name="is_primary", + field=models.BooleanField( + default=False, + help_text="Indicator of whether this is a primary Agreement (and not a component Agreement).", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="historicaldataaffiliateagreement", + name="is_primary", + field=models.BooleanField( + default=False, + help_text="Indicator of whether this is a primary Agreement (and not a component Agreement).", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="historicalmemberagreement", + name="is_primary", + field=models.BooleanField( + default=False, + help_text="Indicator of whether this is a primary Agreement (and not a component Agreement).", + ), + preserve_default=False, + ), + migrations.AddField( + model_name="memberagreement", + name="is_primary", + field=models.BooleanField( + default=False, + help_text="Indicator of whether this is a primary Agreement (and not a component Agreement).", + ), + preserve_default=False, + ), + ] diff --git a/primed/cdsa/migrations/0021_populate_is_primary.py b/primed/cdsa/migrations/0021_populate_is_primary.py new file mode 100644 index 00000000..11cbd17a --- /dev/null +++ b/primed/cdsa/migrations/0021_populate_is_primary.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.10 on 2024-03-15 22:44 + +from django.db import migrations + +def forward_populate_memberagreement_is_primary(apps, schema_editor): + """Populate the MemberAgreement is_primary field using is_primary from the associated SignedAgreement.""" + MemberAgreement = apps.get_model("cdsa", "MemberAgreement") + for row in MemberAgreement.objects.all(): + row.is_primary = row.signed_agreement.is_primary + row.save(update_fields=["is_primary"]) + + +def forward_populate_dataaffiliateagreement_is_primary(apps, schema_editor): + """Populate the DataAffiliateAgreement is_primary field using is_primary from the associated SignedAgreement.""" + DataAffiliateAgreement = apps.get_model("cdsa", "DataAffiliateAgreement") + for row in DataAffiliateAgreement.objects.all(): + row.is_primary = row.signed_agreement.is_primary + row.save(update_fields=["is_primary"]) + +def backward_populate_memberagreement_is_primary(apps, schema_editor): + """Populate the MemberAgreement is_primary field using is_primary from the associated SignedAgreement.""" + MemberAgreement = apps.get_model("cdsa", "MemberAgreement") + for row in MemberAgreement.objects.all(): + row.signed_agreement.is_primary = row.is_primary + row.signed_agreement.save(update_fields=["is_primary"]) + +def backward_populate_dataaffiliateagreement_is_primary(apps, schema_editor): + """Populate the DataAffiliateAgreement is_primary field using is_primary from the associated SignedAgreement.""" + DataAffiliateAgreement = apps.get_model("cdsa", "DataAffiliateAgreement") + for row in DataAffiliateAgreement.objects.all(): + row.signed_agreement.is_primary = row.is_primary + row.signed_agreement.save(update_fields=["is_primary"]) + +def backward_populate_nondataaffiliateagreement_is_primary(apps, schema_editor): + """Set the NonDataAffiliateAgreement.signed_agreement.is_primary field to True.""" + NonDataAffiliateAgreement = apps.get_model("cdsa", "NonDataAffiliateAgreement") + for row in NonDataAffiliateAgreement.objects.all(): + row.signed_agreement.is_primary = True + row.signed_agreement.save(update_fields=["is_primary"]) + +class Migration(migrations.Migration): + + dependencies = [ + ("cdsa", "0020_dataaffiliateagreement_is_primary_and_more"), + ] + + operations = [ + migrations.RunPython( + forward_populate_memberagreement_is_primary, + reverse_code=backward_populate_memberagreement_is_primary, + ), + migrations.RunPython( + forward_populate_dataaffiliateagreement_is_primary, + reverse_code=backward_populate_dataaffiliateagreement_is_primary, + ), + migrations.RunPython( + migrations.RunPython.noop, + reverse_code=backward_populate_nondataaffiliateagreement_is_primary, + ), + ] diff --git a/primed/cdsa/migrations/0022_remove_signedagreement_is_primary.py b/primed/cdsa/migrations/0022_remove_signedagreement_is_primary.py new file mode 100644 index 00000000..c476b7a0 --- /dev/null +++ b/primed/cdsa/migrations/0022_remove_signedagreement_is_primary.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.10 on 2024-03-18 17:31 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("cdsa", "0021_populate_is_primary"), + ] + + operations = [ + migrations.RemoveField( + model_name="historicalsignedagreement", + name="is_primary", + ), + migrations.RemoveField( + model_name="signedagreement", + name="is_primary", + ), + ] diff --git a/primed/cdsa/models.py b/primed/cdsa/models.py index 4896a3ed..4ba3cdec 100644 --- a/primed/cdsa/models.py +++ b/primed/cdsa/models.py @@ -155,9 +155,6 @@ class SignedAgreement( max_length=31, choices=TYPE_CHOICES, ) - is_primary = models.BooleanField( - help_text="Indicator of whether this is a primary Agreement (and not a component Agreement).", - ) version = models.ForeignKey( AgreementVersion, help_text="The version of the Agreement that was signed.", @@ -178,16 +175,15 @@ class SignedAgreement( def __str__(self): return "{}".format(self.cc_id) - def clean(self): - if self.type == self.NON_DATA_AFFILIATE and self.is_primary is False: - raise ValidationError( - "Non-data affiliate agreements must be primary agreements." - ) - @property def combined_type(self): combined_type = self.get_type_display() - if not self.is_primary: + if self.type == self.MEMBER and not self.get_agreement_type().is_primary: + combined_type = combined_type + " component" + elif ( + self.type == self.DATA_AFFILIATE + and not self.get_agreement_type().is_primary + ): combined_type = combined_type + " component" return combined_type @@ -250,6 +246,9 @@ class MemberAgreement(TimeStampedModel, AgreementTypeModel, models.Model): AGREEMENT_TYPE = SignedAgreement.MEMBER + is_primary = models.BooleanField( + help_text="Indicator of whether this is a primary Agreement (and not a component Agreement).", + ) study_site = models.ForeignKey( StudySite, on_delete=models.CASCADE, @@ -271,12 +270,27 @@ class DataAffiliateAgreement(TimeStampedModel, AgreementTypeModel, models.Model) AGREEMENT_TYPE = SignedAgreement.DATA_AFFILIATE + is_primary = models.BooleanField( + help_text="Indicator of whether this is a primary Agreement (and not a component Agreement).", + ) study = models.ForeignKey( Study, on_delete=models.PROTECT, help_text="Study that this agreement is associated with.", ) anvil_upload_group = models.ForeignKey(ManagedGroup, on_delete=models.PROTECT) + additional_limitations = models.TextField( + blank=True, + help_text="Additional limitations on data use as specified in the signed CDSA.", + ) + requires_study_review = models.BooleanField( + default=False, + help_text=( + "Indicator of whether indicates investigators need to have an approved PRIMED paper proposal " + "where this dataset was selected and approved in order to work with data brought " + "under this CDSA." + ), + ) def get_absolute_url(self): return reverse( @@ -284,6 +298,22 @@ def get_absolute_url(self): kwargs={"cc_id": self.signed_agreement.cc_id}, ) + def clean(self): + super().clean() + # Checks for fields only allowed for primary agreements. + errors = {} + if not self.is_primary: + if self.additional_limitations: + errors["additional_limitations"] = ValidationError( + "Additional limitations are only allowed for primary agreements." + ) + if self.requires_study_review: + errors["requires_study_review"] = ValidationError( + "requires_study_review can only be True for primary agreements." + ) + if errors: + raise ValidationError(errors) + def get_agreement_group(self): return self.study @@ -318,8 +348,9 @@ class CDSAWorkspace( on_delete=models.PROTECT, help_text="The study associated with data in this workspace.", ) - data_use_limitations = models.TextField( - help_text="""The full data use limitations for this workspace.""" + additional_limitations = models.TextField( + help_text="""Additional data use limitations that cannot be captured by DUO.""", + blank=True, ) acknowledgments = models.TextField( help_text="Acknowledgments associated with data in this workspace." @@ -337,3 +368,12 @@ class CDSAWorkspace( class Meta: verbose_name = " CDSA workspace" verbose_name_plural = " CDSA workspaces" + + def get_primary_cdsa(self): + """Return the primary, valid CDSA associated with this workspace.""" + cdsa = DataAffiliateAgreement.objects.get( + study=self.study, + is_primary=True, + signed_agreement__status=SignedAgreement.StatusChoices.ACTIVE, + ) + return cdsa diff --git a/primed/cdsa/tables.py b/primed/cdsa/tables.py index c5f9ab6d..f39d6988 100644 --- a/primed/cdsa/tables.py +++ b/primed/cdsa/tables.py @@ -6,6 +6,7 @@ Workspace, WorkspaceGroupSharing, ) +from django.utils.safestring import mark_safe from primed.primed_anvil.tables import ( BooleanIconColumn, @@ -43,9 +44,7 @@ class SignedAgreementTable(tables.Table): verbose_name="Representative", ) representative_role = tables.Column(verbose_name="Role") - agreement_type = tables.Column( - accessor="combined_type", order_by=("type", "-is_primary") - ) + agreement_type = tables.Column(accessor="combined_type", order_by=("type")) number_accessors = tables.Column( verbose_name="Number of accessors", accessor="anvil_access_group__groupaccountmembership_set__count", @@ -79,7 +78,7 @@ class MemberAgreementTable(tables.Table): signed_agreement__cc_id = tables.Column(linkify=True) study_site = tables.Column(linkify=True) - signed_agreement__is_primary = BooleanIconColumn(verbose_name="Primary?") + is_primary = BooleanIconColumn(verbose_name="Primary?") signed_agreement__representative__name = tables.Column( linkify=lambda record: record.signed_agreement.representative.get_absolute_url(), verbose_name="Representative", @@ -96,7 +95,7 @@ class Meta: fields = ( "signed_agreement__cc_id", "study_site", - "signed_agreement__is_primary", + "is_primary", "signed_agreement__representative__name", "signed_agreement__representative_role", "signed_agreement__signing_institution", @@ -113,7 +112,13 @@ class DataAffiliateAgreementTable(tables.Table): signed_agreement__cc_id = tables.Column(linkify=True) study = tables.Column(linkify=True) - signed_agreement__is_primary = BooleanIconColumn(verbose_name="Primary?") + is_primary = BooleanIconColumn(verbose_name="Primary?") + requires_study_review = BooleanIconColumn( + verbose_name="Study review required?", + orderable=False, + true_icon="dash-circle-fill", + true_color="#ffc107", + ) signed_agreement__representative__name = tables.Column( linkify=lambda record: record.signed_agreement.representative.get_absolute_url(), verbose_name="Representative", @@ -130,7 +135,8 @@ class Meta: fields = ( "signed_agreement__cc_id", "study", - "signed_agreement__is_primary", + "is_primary", + "requires_study_review", "signed_agreement__representative__name", "signed_agreement__representative_role", "signed_agreement__signing_institution", @@ -178,9 +184,7 @@ class RepresentativeRecordsTable(tables.Table): representative__name = tables.Column(verbose_name="Representative") signing_group = tables.Column(accessor="pk", orderable=False) - agreement_type = tables.Column( - accessor="combined_type", order_by=("type", "-is_primary") - ) + agreement_type = tables.Column(accessor="combined_type", order_by=("type")) class Meta: model = models.SignedAgreement @@ -295,11 +299,10 @@ def render_date_shared(self, record): return "—" -class CDSAWorkspaceStaffTable(tables.Table): +class CDSAWorkspaceUserTable(tables.Table): """A table for the CDSAWorkspace model.""" name = tables.Column(linkify=True) - billing_project = tables.Column(linkify=True) cdsaworkspace__data_use_permission__abbreviation = tables.Column( verbose_name="DUO permission", linkify=lambda record: record.cdsaworkspace.data_use_permission.get_absolute_url(), @@ -310,6 +313,12 @@ class CDSAWorkspaceStaffTable(tables.Table): verbose_name="DUO modifiers", linkify_item=True, ) + cdsaworkspace__requires_study_review = BooleanIconColumn( + verbose_name="Study review required?", + orderable=False, + true_icon="dash-circle-fill", + true_color="#ffc107", + ) cdsaworkspace__gsr_restricted = BooleanIconColumn( orderable=False, true_icon="dash-circle-fill", true_color="#ffc107" ) @@ -319,30 +328,31 @@ class Meta: model = Workspace fields = ( "name", - "billing_project", "cdsaworkspace__study", "cdsaworkspace__data_use_permission__abbreviation", "cdsaworkspace__data_use_modifiers", + "cdsaworkspace__requires_study_review", "cdsaworkspace__gsr_restricted", ) order_by = ("name",) - -class CDSAWorkspaceUserTable(tables.Table): + def render_cdsaworkspace__requires_study_review(self, record): + try: + if record.cdsaworkspace.get_primary_cdsa().requires_study_review: + icon = "dash-circle-fill" + color = "#ffc107" + else: + return "" + except models.DataAffiliateAgreement.DoesNotExist: + icon = "question-circle-fill" + color = "red" + return mark_safe(f'') + + +class CDSAWorkspaceStaffTable(CDSAWorkspaceUserTable): """A table for the CDSAWorkspace model.""" - name = tables.Column(linkify=True) - billing_project = tables.Column() - cdsaworkspace__data_use_permission__abbreviation = tables.Column( - verbose_name="DUO permission", - ) - cdsaworkspace__study = tables.Column() - cdsaworkspace__data_use_modifiers = tables.ManyToManyColumn( - transform=lambda x: x.abbreviation, - verbose_name="DUO modifiers", - ) - cdsaworkspace__gsr_restricted = BooleanIconColumn(orderable=False) - is_shared = WorkspaceSharedWithConsortiumColumn() + billing_project = tables.Column(linkify=True) class Meta: model = Workspace @@ -352,6 +362,7 @@ class Meta: "cdsaworkspace__study", "cdsaworkspace__data_use_permission__abbreviation", "cdsaworkspace__data_use_modifiers", + "cdsaworkspace__requires_study_review", "cdsaworkspace__gsr_restricted", ) order_by = ("name",) diff --git a/primed/cdsa/tests/factories.py b/primed/cdsa/tests/factories.py index 610b63b4..6d7473d9 100644 --- a/primed/cdsa/tests/factories.py +++ b/primed/cdsa/tests/factories.py @@ -46,8 +46,6 @@ class SignedAgreementFactory(DjangoModelFactory): models.SignedAgreement.TYPE_CHOICES, getter=lambda c: c[0], ) - # Assume is_primary=True for now. - is_primary = True version = SubFactory(AgreementVersionFactory) date_signed = Faker("date") anvil_access_group = SubFactory( @@ -69,6 +67,7 @@ class MemberAgreementFactory(DjangoModelFactory): SignedAgreementFactory, type=models.SignedAgreement.MEMBER ) study_site = SubFactory(StudySiteFactory) + is_primary = True class Meta: model = models.MemberAgreement @@ -80,6 +79,7 @@ class DataAffiliateAgreementFactory(DjangoModelFactory): SignedAgreementFactory, type=models.SignedAgreement.DATA_AFFILIATE ) study = SubFactory(StudyFactory) + is_primary = True anvil_upload_group = SubFactory( ManagedGroupFactory, name=LazyAttribute( @@ -107,7 +107,6 @@ class Meta: class CDSAWorkspaceFactory(DjangoModelFactory): study = SubFactory(StudyFactory) - data_use_limitations = Faker("paragraph") acknowledgments = Faker("paragraph") requested_by = SubFactory(UserFactory) data_use_permission = SubFactory(DataUsePermissionFactory) diff --git a/primed/cdsa/tests/test_audit.py b/primed/cdsa/tests/test_audit.py index 467ff57f..ef5fc61c 100644 --- a/primed/cdsa/tests/test_audit.py +++ b/primed/cdsa/tests/test_audit.py @@ -322,7 +322,7 @@ def test_member_component_has_primary_in_group(self): study_site = StudySiteFactory.create() factories.MemberAgreementFactory.create(study_site=study_site) this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, study_site=study_site + is_primary=False, study_site=study_site ) # Add the signed agreement access group to the CDSA group. GroupGroupMembershipFactory.create( @@ -344,7 +344,7 @@ def test_member_component_has_primary_not_in_group(self): study_site = StudySiteFactory.create() factories.MemberAgreementFactory.create(study_site=study_site) this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, study_site=study_site + is_primary=False, study_site=study_site ) # # Add the signed agreement access group to the CDSA group. # GroupGroupMembershipFactory.create( @@ -366,7 +366,7 @@ def test_member_component_inactive_has_primary_in_group(self): study_site = StudySiteFactory.create() factories.MemberAgreementFactory.create(study_site=study_site) this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, study_site=study_site, signed_agreement__status=models.SignedAgreement.StatusChoices.WITHDRAWN, ) @@ -390,7 +390,7 @@ def test_member_component_inactive_has_primary_not_in_group(self): study_site = StudySiteFactory.create() factories.MemberAgreementFactory.create(study_site=study_site) this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, study_site=study_site, signed_agreement__status=models.SignedAgreement.StatusChoices.WITHDRAWN, ) @@ -417,7 +417,7 @@ def test_member_component_has_primary_with_invalid_version_in_group(self): signed_agreement__version__major_version__is_valid=False, ) this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, study_site=study_site + is_primary=False, study_site=study_site ) # Add the signed agreement access group to the CDSA group. GroupGroupMembershipFactory.create( @@ -442,7 +442,7 @@ def test_member_component_has_primary_with_invalid_version_not_in_group(self): signed_agreement__version__major_version__is_valid=False, ) this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, study_site=study_site + is_primary=False, study_site=study_site ) # # Add the signed agreement access group to the CDSA group. # GroupGroupMembershipFactory.create( @@ -467,7 +467,7 @@ def test_member_component_has_inactive_primary_in_group(self): signed_agreement__status=models.SignedAgreement.StatusChoices.WITHDRAWN, ) this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, study_site=study_site + is_primary=False, study_site=study_site ) # Add the signed agreement access group to the CDSA group. GroupGroupMembershipFactory.create( @@ -492,7 +492,7 @@ def test_member_component_has_inactive_primary_not_in_group(self): signed_agreement__status=models.SignedAgreement.StatusChoices.WITHDRAWN, ) this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, study_site=study_site + is_primary=False, study_site=study_site ) # # Add the signed agreement access group to the CDSA group. # GroupGroupMembershipFactory.create( @@ -513,7 +513,7 @@ def test_member_component_no_primary_in_group(self): """Member component agreement, with valid version, with no primary, in CDSA group.""" study_site = StudySiteFactory.create() this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, study_site=study_site + is_primary=False, study_site=study_site ) # Add the signed agreement access group to the CDSA group. GroupGroupMembershipFactory.create( @@ -534,7 +534,7 @@ def test_member_component_no_primary_not_in_group(self): """Member component agreement, with valid version, with no primary, not in CDSA group.""" study_site = StudySiteFactory.create() this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, study_site=study_site + is_primary=False, study_site=study_site ) # # Add the signed agreement access group to the CDSA group. # GroupGroupMembershipFactory.create( @@ -556,7 +556,7 @@ def test_member_component_invalid_version_has_primary_in_group(self): study_site = StudySiteFactory.create() factories.MemberAgreementFactory.create(study_site=study_site) this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, study_site=study_site, signed_agreement__version__major_version__is_valid=False, ) @@ -580,7 +580,7 @@ def test_member_component_invalid_version_has_primary_not_in_group(self): study_site = StudySiteFactory.create() factories.MemberAgreementFactory.create(study_site=study_site) this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, study_site=study_site, signed_agreement__version__major_version__is_valid=False, ) @@ -606,7 +606,7 @@ def test_member_component_invalid_version_has_primary_with_invalid_version_in_gr study_site = StudySiteFactory.create() factories.MemberAgreementFactory.create(study_site=study_site) this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, study_site=study_site, signed_agreement__version__major_version__is_valid=False, ) @@ -632,7 +632,7 @@ def test_member_component_invalid_version_has_primary_with_invalid_version_not_i study_site = StudySiteFactory.create() factories.MemberAgreementFactory.create(study_site=study_site) this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, study_site=study_site, signed_agreement__version__major_version__is_valid=False, ) @@ -654,7 +654,7 @@ def test_member_component_invalid_version_has_primary_with_invalid_version_not_i def test_member_component_invalid_version_no_primary_in_group(self): """Member component agreement, with invalid version, with no primary, in CDSA group.""" this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, signed_agreement__version__major_version__is_valid=False, ) # Add the signed agreement access group to the CDSA group. @@ -675,7 +675,7 @@ def test_member_component_invalid_version_no_primary_in_group(self): def test_member_component_invalid_version_no_primary_not_in_group(self): """Member component agreement, with invalid version, with no primary, not in CDSA group.""" this_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, signed_agreement__version__major_version__is_valid=False, ) # # Add the signed agreement access group to the CDSA group. @@ -814,7 +814,7 @@ def test_data_affiliate_component_has_primary_in_group(self): study = StudyFactory.create() factories.DataAffiliateAgreementFactory.create(study=study) this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, study=study + is_primary=False, study=study ) # Add the signed agreement access group to the CDSA group. GroupGroupMembershipFactory.create( @@ -836,7 +836,7 @@ def test_data_affiliate_component_has_primary_not_in_group(self): study = StudyFactory.create() factories.DataAffiliateAgreementFactory.create(study=study) this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, study=study + is_primary=False, study=study ) # # Add the signed agreement access group to the CDSA group. # GroupGroupMembershipFactory.create( @@ -858,7 +858,7 @@ def test_data_affiliate_component_inactive_has_primary_in_group(self): study = StudyFactory.create() factories.DataAffiliateAgreementFactory.create(study=study) this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, study=study, signed_agreement__status=models.SignedAgreement.StatusChoices.WITHDRAWN, ) @@ -882,7 +882,7 @@ def test_data_affiliate_component_inactive_has_primary_not_in_group(self): study = StudyFactory.create() factories.DataAffiliateAgreementFactory.create(study=study) this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, study=study, signed_agreement__status=models.SignedAgreement.StatusChoices.WITHDRAWN, ) @@ -909,7 +909,7 @@ def test_data_affiliate_component_has_primary_with_invalid_version_in_group(self signed_agreement__version__major_version__is_valid=False, ) this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, study=study + is_primary=False, study=study ) # Add the signed agreement access group to the CDSA group. GroupGroupMembershipFactory.create( @@ -936,7 +936,7 @@ def test_data_affiliate_component_has_primary_with_invalid_version_not_in_group( signed_agreement__version__major_version__is_valid=False, ) this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, study=study + is_primary=False, study=study ) # # Add the signed agreement access group to the CDSA group. # GroupGroupMembershipFactory.create( @@ -961,7 +961,7 @@ def test_data_affiliate_component_has_inactive_primary_in_group(self): signed_agreement__status=models.SignedAgreement.StatusChoices.WITHDRAWN, ) this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, study=study + is_primary=False, study=study ) # Add the signed agreement access group to the CDSA group. GroupGroupMembershipFactory.create( @@ -986,7 +986,7 @@ def test_data_affiliate_component_has_inactive_primary_not_in_group(self): signed_agreement__status=models.SignedAgreement.StatusChoices.WITHDRAWN, ) this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, study=study + is_primary=False, study=study ) # # Add the signed agreement access group to the CDSA group. # GroupGroupMembershipFactory.create( @@ -1007,7 +1007,7 @@ def test_data_affiliate_component_no_primary_in_group(self): """Member component agreement, with valid version, with no primary, in CDSA group.""" study = StudyFactory.create() this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, study=study + is_primary=False, study=study ) # Add the signed agreement access group to the CDSA group. GroupGroupMembershipFactory.create( @@ -1028,7 +1028,7 @@ def test_data_affiliate_component_no_primary_not_in_group(self): """Member component agreement, with valid version, with no primary, not in CDSA group.""" study = StudyFactory.create() this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, study=study + is_primary=False, study=study ) # # Add the signed agreement access group to the CDSA group. # GroupGroupMembershipFactory.create( @@ -1050,7 +1050,7 @@ def test_data_affiliate_component_invalid_version_has_primary_in_group(self): study = StudyFactory.create() factories.DataAffiliateAgreementFactory.create(study=study) this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, study=study, signed_agreement__version__major_version__is_valid=False, ) @@ -1074,7 +1074,7 @@ def test_data_affiliate_component_invalid_version_has_primary_not_in_group(self) study = StudyFactory.create() factories.DataAffiliateAgreementFactory.create(study=study) this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, study=study, signed_agreement__version__major_version__is_valid=False, ) @@ -1100,7 +1100,7 @@ def test_data_affiliate_component_invalid_version_has_primary_with_invalid_versi study = StudyFactory.create() factories.DataAffiliateAgreementFactory.create(study=study) this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, study=study, signed_agreement__version__major_version__is_valid=False, ) @@ -1126,7 +1126,7 @@ def test_data_affiliate_component_invalid_version_has_primary_with_invalid_versi study = StudyFactory.create() factories.DataAffiliateAgreementFactory.create(study=study) this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, study=study, signed_agreement__version__major_version__is_valid=False, ) @@ -1148,7 +1148,7 @@ def test_data_affiliate_component_invalid_version_has_primary_with_invalid_versi def test_data_affiliate_component_invalid_version_no_primary_in_group(self): """Member component agreement, with invalid version, with no primary, in CDSA group.""" this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, signed_agreement__version__major_version__is_valid=False, ) # Add the signed agreement access group to the CDSA group. @@ -1169,7 +1169,7 @@ def test_data_affiliate_component_invalid_version_no_primary_in_group(self): def test_data_affiliate_component_invalid_version_no_primary_not_in_group(self): """Member component agreement, with invalid version, with no primary, not in CDSA group.""" this_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, signed_agreement__version__major_version__is_valid=False, ) # # Add the signed agreement access group to the CDSA group. @@ -1303,46 +1303,6 @@ def test_non_data_affiliate_primary_valid_not_active_not_in_group(self): self.assertEqual(record.signed_agreement, this_agreement.signed_agreement) self.assertEqual(record.note, cdsa_audit.INACTIVE_AGREEMENT) - def test_non_data_affiliate_component_in_cdsa_group(self): - """Non data affiliate component agreement.""" - this_agreement = factories.NonDataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False - ) - # Add the signed agreement access group to the CDSA group. - GroupGroupMembershipFactory.create( - parent_group=self.cdsa_group, - child_group=this_agreement.signed_agreement.anvil_access_group, - ) - cdsa_audit = signed_agreement_audit.SignedAgreementAccessAudit() - cdsa_audit._audit_signed_agreement(this_agreement.signed_agreement) - self.assertEqual(len(cdsa_audit.verified), 0) - self.assertEqual(len(cdsa_audit.needs_action), 0) - self.assertEqual(len(cdsa_audit.errors), 1) - record = cdsa_audit.errors[0] - self.assertIsInstance(record, signed_agreement_audit.OtherError) - self.assertEqual(record.signed_agreement, this_agreement.signed_agreement) - self.assertEqual(record.note, cdsa_audit.ERROR_NON_DATA_AFFILIATE_COMPONENT) - - def test_non_data_affiliate_component_not_in_cdsa_group(self): - """Non data affiliate component agreement.""" - this_agreement = factories.NonDataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False - ) - # Do not add the signed agreement access group to the CDSA group. - # GroupGroupMembershipFactory.create( - # parent_group=self.cdsa_group, - # child_group=this_agreement.signed_agreement.anvil_access_group, - # ) - cdsa_audit = signed_agreement_audit.SignedAgreementAccessAudit() - cdsa_audit._audit_signed_agreement(this_agreement.signed_agreement) - self.assertEqual(len(cdsa_audit.verified), 0) - self.assertEqual(len(cdsa_audit.needs_action), 0) - self.assertEqual(len(cdsa_audit.errors), 1) - record = cdsa_audit.errors[0] - self.assertIsInstance(record, signed_agreement_audit.OtherError) - self.assertEqual(record.signed_agreement, this_agreement.signed_agreement) - self.assertEqual(record.note, cdsa_audit.ERROR_NON_DATA_AFFILIATE_COMPONENT) - class SignedAgreementAccessAuditTableTest(TestCase): """Tests for the `SignedAgreementAccessAuditTable` table.""" @@ -1804,9 +1764,7 @@ def test_primary_inactive_in_auth_domain(self): def test_component_agreement_not_in_auth_domain(self): study = StudyFactory.create() workspace = factories.CDSAWorkspaceFactory.create(study=study) - factories.DataAffiliateAgreementFactory.create( - study=study, signed_agreement__is_primary=False - ) + factories.DataAffiliateAgreementFactory.create(study=study, is_primary=False) # Do not add the CDSA group to the auth domain. # GroupGroupMembershipFactory.create( # parent_group=workspace.workspace.authorization_domains.first(), @@ -1826,9 +1784,7 @@ def test_component_agreement_not_in_auth_domain(self): def test_component_agreement_in_auth_domain(self): study = StudyFactory.create() workspace = factories.CDSAWorkspaceFactory.create(study=study) - factories.DataAffiliateAgreementFactory.create( - study=study, signed_agreement__is_primary=False - ) + factories.DataAffiliateAgreementFactory.create(study=study, is_primary=False) # Add the CDSA group to the auth domain. GroupGroupMembershipFactory.create( parent_group=workspace.workspace.authorization_domains.first(), diff --git a/primed/cdsa/tests/test_commands.py b/primed/cdsa/tests/test_commands.py index 916bd62f..fbef817c 100644 --- a/primed/cdsa/tests/test_commands.py +++ b/primed/cdsa/tests/test_commands.py @@ -68,9 +68,7 @@ def test_study_records_zero(self): self.assertEqual(len(lines), 1) def test_study_records_one(self): - factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True - ) + factories.DataAffiliateAgreementFactory.create(is_primary=True) out = StringIO() call_command("cdsa_records", "--outdir", self.outdir, "--no-color", stdout=out) with open(os.path.join(self.outdir, "study_records.tsv")) as f: @@ -135,7 +133,7 @@ def test_command_output_no_records(self): def test_command_run_audit_one_agreement_verified(self): """Test command output with one verified instance.""" - factories.MemberAgreementFactory.create(signed_agreement__is_primary=False) + factories.MemberAgreementFactory.create(is_primary=False) out = StringIO() call_command("run_cdsa_audit", "--no-color", stdout=out) expected_output = ( @@ -174,9 +172,7 @@ def test_command_run_audit_one_agreement_needs_action(self): def test_command_run_audit_one_agreement_error(self): """Test command output with one error instance.""" - agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False - ) + agreement = factories.MemberAgreementFactory.create(is_primary=False) GroupGroupMembershipFactory.create( parent_group=self.cdsa_group, child_group=agreement.signed_agreement.anvil_access_group, @@ -197,7 +193,7 @@ def test_command_run_audit_one_agreement_error(self): def test_command_run_audit_one_agreement_verified_email(self): """No email is sent when there are no errors.""" - factories.MemberAgreementFactory.create(signed_agreement__is_primary=False) + factories.MemberAgreementFactory.create(is_primary=False) out = StringIO() call_command( "run_cdsa_audit", "--no-color", email="test@example.com", stdout=out @@ -233,9 +229,7 @@ def test_command_run_audit_one_agreement_needs_action_email(self): def test_command_run_audit_one_agreement_error_email(self): """Test command output with one error instance.""" - agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False - ) + agreement = factories.MemberAgreementFactory.create(is_primary=False) GroupGroupMembershipFactory.create( parent_group=self.cdsa_group, child_group=agreement.signed_agreement.anvil_access_group, diff --git a/primed/cdsa/tests/test_forms.py b/primed/cdsa/tests/test_forms.py index 7662e422..1dcea0af 100644 --- a/primed/cdsa/tests/test_forms.py +++ b/primed/cdsa/tests/test_forms.py @@ -35,7 +35,6 @@ def test_valid(self): "signing_institution": "Test insitution", "version": self.agreement_version, "date_signed": "2023-01-01", - "is_primary": True, } form = self.form_class(data=form_data) self.assertTrue(form.is_valid()) @@ -49,7 +48,6 @@ def test_missing_representative(self): "signing_institution": "Test insitution", "version": self.agreement_version, "date_signed": "2023-01-01", - "is_primary": True, } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) @@ -67,7 +65,6 @@ def test_missing_cc_id(self): "signing_institution": "Test insitution", "version": self.agreement_version, "date_signed": "2023-01-01", - "is_primary": True, } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) @@ -85,7 +82,6 @@ def test_missing_representative_role(self): "signing_institution": "Test insitution", "version": self.agreement_version, "date_signed": "2023-01-01", - "is_primary": True, } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) @@ -103,7 +99,6 @@ def test_missing_signing_institution(self): # "signing_institution": "Test insitution", "version": self.agreement_version, "date_signed": "2023-01-01", - "is_primary": True, } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) @@ -121,7 +116,6 @@ def test_missing_version(self): "signing_institution": "Test insitution", # "version": self.agreement_version, "date_signed": "2023-01-01", - "is_primary": True, } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) @@ -139,7 +133,6 @@ def test_missing_date_signed(self): "signing_institution": "Test insitution", "version": self.agreement_version, # "date_signed": "2023-01-01", - "is_primary": True, } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) @@ -148,24 +141,6 @@ def test_missing_date_signed(self): self.assertEqual(len(form.errors["date_signed"]), 1) self.assertIn("required", form.errors["date_signed"][0]) - def test_missing_is_primary(self): - """Form is invalid when missing representative_role.""" - form_data = { - "cc_id": 1234, - "representative": self.representative, - "representative_role": "Test role", - "signing_institution": "Test insitution", - "version": self.agreement_version, - "date_signed": "2023-01-01", - # "is_primary": True, - } - form = self.form_class(data=form_data) - self.assertFalse(form.is_valid()) - self.assertEqual(len(form.errors), 1) - self.assertIn("is_primary", form.errors) - self.assertEqual(len(form.errors["is_primary"]), 1) - self.assertIn("required", form.errors["is_primary"][0]) - def test_invalid_cc_id_zero(self): """Form is invalid when cc_id is zero.""" form_data = { @@ -175,7 +150,6 @@ def test_invalid_cc_id_zero(self): "signing_institution": "Test insitution", "version": self.agreement_version, "date_signed": "2023-01-01", - "is_primary": True, } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) @@ -194,7 +168,6 @@ def test_invalid_duplicate_object(self): "signing_institution": "Test insitution", "version": self.agreement_version, "date_signed": "2023-01-01", - "is_primary": True, } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) @@ -213,7 +186,6 @@ def test_invalid_version(self): "signing_institution": "Test insitution", "version": self.agreement_version, "date_signed": "2023-01-01", - "is_primary": True, } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) @@ -281,6 +253,7 @@ def test_valid(self): """Form is valid with necessary input.""" form_data = { "signed_agreement": self.signed_agreement, + "is_primary": True, "study_site": self.study_site, } form = self.form_class(data=form_data) @@ -290,6 +263,7 @@ def test_missing_signed_agreement(self): """Form is invalid when missing signed_agreement.""" form_data = { # "signed_agreement": self.signed_agreement, + "is_primary": True, "study_site": self.study_site, } form = self.form_class(data=form_data) @@ -299,10 +273,25 @@ def test_missing_signed_agreement(self): self.assertEqual(len(form.errors["signed_agreement"]), 1) self.assertIn("required", form.errors["signed_agreement"][0]) + def test_missing_is_primary(self): + """Form is invalid when missing study_site.""" + form_data = { + "signed_agreement": self.signed_agreement, + # "is_primary": True, + "study_site": self.study_site, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("is_primary", form.errors) + self.assertEqual(len(form.errors["is_primary"]), 1) + self.assertIn("required", form.errors["is_primary"][0]) + def test_missing_study_site(self): """Form is invalid when missing study_site.""" form_data = { "signed_agreement": self.signed_agreement, + "is_primary": True, # "study_site": self.study_site, } form = self.form_class(data=form_data) @@ -317,6 +306,7 @@ def test_invalid_signed_agreement_already_has_member_agreement(self): obj = factories.MemberAgreementFactory.create() form_data = { "signed_agreement": obj.signed_agreement, + "is_primary": True, "study_site": self.study_site, } form = self.form_class(data=form_data) @@ -332,6 +322,7 @@ def test_invalid_signed_agreement_wrong_type(self): ) form_data = { "signed_agreement": obj, + "is_primary": True, "study_site": self.study_site, } form = self.form_class(data=form_data) @@ -357,6 +348,7 @@ def test_valid(self): """Form is valid with necessary input.""" form_data = { "signed_agreement": self.signed_agreement, + "is_primary": True, "study": self.study, } form = self.form_class(data=form_data) @@ -366,6 +358,7 @@ def test_missing_signed_agreement(self): """Form is invalid when missing signed_agreement.""" form_data = { # "signed_agreement": self.signed_agreement, + "is_primary": True, "study": self.study, } form = self.form_class(data=form_data) @@ -375,10 +368,25 @@ def test_missing_signed_agreement(self): self.assertEqual(len(form.errors["signed_agreement"]), 1) self.assertIn("required", form.errors["signed_agreement"][0]) + def test_missing_is_primary(self): + """Form is invalid when missing study_site.""" + form_data = { + "signed_agreement": self.signed_agreement, + # "is_primary": True, + "study": self.study, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertEqual(len(form.errors), 1) + self.assertIn("is_primary", form.errors) + self.assertEqual(len(form.errors["is_primary"]), 1) + self.assertIn("required", form.errors["is_primary"][0]) + def test_missing_study(self): """Form is invalid when missing study.""" form_data = { "signed_agreement": self.signed_agreement, + "is_primary": True, # "study": self.study, } form = self.form_class(data=form_data) @@ -393,6 +401,7 @@ def test_invalid_signed_agreement_already_has_agreement_type(self): obj = factories.DataAffiliateAgreementFactory.create() form_data = { "signed_agreement": obj.signed_agreement, + "is_primary": True, "study": self.study, } form = self.form_class(data=form_data) @@ -408,6 +417,7 @@ def test_invalid_signed_agreement_wrong_type(self): ) form_data = { "signed_agreement": obj, + "is_primary": True, "study": self.study, } form = self.form_class(data=form_data) @@ -416,6 +426,60 @@ def test_invalid_signed_agreement_wrong_type(self): self.assertEqual(len(form.errors["signed_agreement"]), 1) self.assertIn("expected type", form.errors["signed_agreement"][0]) + def test_valid_primary_with_additional_limitations(self): + """Form is valid with necessary input.""" + form_data = { + "signed_agreement": self.signed_agreement, + "study": self.study, + "is_primary": True, + "additional_limitations": "test limitations", + } + form = self.form_class(data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_component_with_additional_limitations(self): + """Form is valid with necessary input.""" + form_data = { + "signed_agreement": self.signed_agreement, + "study": self.study, + "is_primary": False, + "additional_limitations": "test limitations", + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn("additional_limitations", form.errors) + self.assertEqual(len(form.errors["additional_limitations"]), 1) + self.assertIn( + "only allowed for primary", form.errors["additional_limitations"][0] + ) + + def test_valid_primary_with_requires_study_review_true(self): + """Form is valid with necessary input.""" + form_data = { + "signed_agreement": self.signed_agreement, + "study": self.study, + "is_primary": True, + "requires_study_review": True, + } + form = self.form_class(data=form_data) + self.assertTrue(form.is_valid()) + + def test_invalid_component_with_requires_study_review_true(self): + """Form is valid with necessary input.""" + form_data = { + "signed_agreement": self.signed_agreement, + "study": self.study, + "is_primary": False, + "requires_study_review": True, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn("requires_study_review", form.errors) + self.assertEqual(len(form.errors["requires_study_review"]), 1) + self.assertIn( + "can only be True for primary", form.errors["requires_study_review"][0] + ) + class NonDataAffiliateAgreementFormTest(TestCase): """Tests for the NonDataAffiliateAgreementForm class.""" @@ -511,7 +575,6 @@ def test_valid(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -526,7 +589,6 @@ def test_valid_with_one_data_use_modifier(self): "requested_by": self.requester, "data_use_permission": self.duo_permission, "data_use_modifier": DataUseModifier.objects.all(), - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -541,7 +603,6 @@ def test_valid_with_two_data_use_modifiers(self): "requested_by": self.requester, "data_use_permission": self.duo_permission, "data_use_modifier": DataUseModifier.objects.all(), - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -555,7 +616,6 @@ def test_invalid_missing_workspace(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -573,7 +633,6 @@ def test_invalid_missing_study(self): # "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -591,7 +650,6 @@ def test_invalid_missing_requested_by(self): "study": self.study, # "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -609,7 +667,6 @@ def test_invalid_missing_data_use_permission(self): "study": self.study, "requested_by": self.requester, # "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -620,23 +677,20 @@ def test_invalid_missing_data_use_permission(self): self.assertEqual(len(form.errors["data_use_permission"]), 1) self.assertIn("required", form.errors["data_use_permission"][0]) - def test_invalid_missing_data_use_limitations(self): + def test_valid_additional_limitations(self): """Form is invalid when missing data_use_limitations.""" form_data = { "workspace": self.workspace, "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - # "data_use_limitations": "test limitations", + "additional_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } form = self.form_class(data=form_data) - self.assertFalse(form.is_valid()) - self.assertEqual(len(form.errors), 1) - self.assertIn("data_use_limitations", form.errors) - self.assertEqual(len(form.errors["data_use_limitations"]), 1) - self.assertIn("required", form.errors["data_use_limitations"][0]) + self.assertTrue(form.is_valid()) + self.assertEqual(form.instance.additional_limitations, "test limitations") def test_invalid_missing_acknowledgments(self): """Form is invalid when missing acknowledgments.""" @@ -645,7 +699,6 @@ def test_invalid_missing_acknowledgments(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", # "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -663,7 +716,6 @@ def test_invalid_missing_gsr_restricted(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", # "gsr_restricted": False, } @@ -682,7 +734,6 @@ def test_invalid_duplicate_workspace(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "gsr_restricted": False, } @@ -701,7 +752,6 @@ def test_valid_one_available_data(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "available_data": [available_data], "gsr_restricted": False, @@ -717,7 +767,6 @@ def test_valid_two_available_data(self): "study": self.study, "requested_by": self.requester, "data_use_permission": self.duo_permission, - "data_use_limitations": "test limitations", "acknowledgments": "test acknowledgmnts", "available_data": available_data, "gsr_restricted": False, diff --git a/primed/cdsa/tests/test_migrations.py b/primed/cdsa/tests/test_migrations.py index 9eb16740..d355392c 100644 --- a/primed/cdsa/tests/test_migrations.py +++ b/primed/cdsa/tests/test_migrations.py @@ -5,7 +5,8 @@ from django_test_migrations.contrib.unittest_case import MigratorTestCase import factory -from . import factories +from primed.primed_anvil.tests.factories import StudySiteFactory + class AgreementMajorVersionMigrationsTest(MigratorTestCase): """Tests for the migrations associated with creating the new AgreementMajorVersion model.""" @@ -100,3 +101,225 @@ def test_agreement_version_major_version_correctly_populated(self): agreement_version.full_clean() self.assertEqual(agreement_version.major_version, major_version) self.assertEqual(agreement_version.minor_version, 6) + + +class PopulateIsPrimaryMigrationsForwardTest(MigratorTestCase): + """Tests for the migrations associated with creating the new AgreementMajorVersion model.""" + + migrate_from = ("cdsa", "0018_dataaffiliateagreement_requires_study_review_and_more") + migrate_to = ("cdsa", "0022_remove_signedagreement_is_primary") + + def prepare(self): + """Prepare some data before the migration.""" + # Get model definition for the old state. + User = self.old_state.apps.get_model("users", "User") + ManagedGroup = self.old_state.apps.get_model("anvil_consortium_manager", "ManagedGroup") + StudySite = self.old_state.apps.get_model("primed_anvil", "StudySite") + Study = self.old_state.apps.get_model("primed_anvil", "Study") + AgreementMajorVersion = self.old_state.apps.get_model("cdsa", "AgreementMajorVersion") + AgreementVersion = self.old_state.apps.get_model("cdsa", "AgreementVersion") + SignedAgreement = self.old_state.apps.get_model("cdsa", "SignedAgreement") + MemberAgreement = self.old_state.apps.get_model("cdsa", "MemberAgreement") + DataAffiliateAgreement = self.old_state.apps.get_model("cdsa", "DataAffiliateAgreement") + NonDataAffiliateAgreement = self.old_state.apps.get_model("cdsa", "NonDataAffiliateAgreement") + # Populate some signed agreements. + agreement_version = AgreementVersion.objects.create( + major_version=AgreementMajorVersion.objects.create(version=1, is_valid=True), + minor_version=0, + ) + tmp = SignedAgreement.objects.create( + cc_id=1, + representative=User.objects.create_user(username="test1", password="test1"), + representative_role="Test role 1", + signing_institution="Test institution 1", + type="member", + version=agreement_version, + anvil_access_group=ManagedGroup.objects.create(name="testaccess1", email="testaccess1@example.com"), + is_primary=True + ) + self.member_agreement_1 = MemberAgreement.objects.create( + signed_agreement=tmp, + study_site=StudySite.objects.create(short_name="test1", full_name="Test Study 1"), + ) + tmp = SignedAgreement.objects.create( + cc_id=2, + representative=User.objects.create_user(username="test2", password="test2"), + representative_role="Test role 2", + signing_institution="Test institution 2", + type="member", + version=agreement_version, + anvil_access_group=ManagedGroup.objects.create(name="testaccess2", email="testaccess2@example.com"), + is_primary=False + ) + self.member_agreement_2 = MemberAgreement.objects.create( + signed_agreement=tmp, + study_site=StudySite.objects.get(short_name="test1"), + ) + tmp = SignedAgreement.objects.create( + cc_id=3, + representative=User.objects.create_user(username="test3", password="test3"), + representative_role="Test role 3", + signing_institution="Test institution 3", + type="data_affiliate", + version=agreement_version, + anvil_access_group=ManagedGroup.objects.create(name="testaccess3", email="testaccess3@example.com"), + is_primary=True + ) + self.data_affiliate_agreement_1 = DataAffiliateAgreement.objects.create( + signed_agreement=tmp, + study=Study.objects.create(short_name="test2", full_name="Test Study Site 2"), + anvil_upload_group=ManagedGroup.objects.create(name="testupload1", email="testupload1@example.com"), + ) + tmp = SignedAgreement.objects.create( + cc_id=4, + representative=User.objects.create_user(username="test4", password="test4"), + representative_role="Test role 4", + signing_institution="Test institution 4", + type="data_affiliate", + version=agreement_version, + anvil_access_group=ManagedGroup.objects.create(name="testaccess4", email="testaccess4@example.com"), + is_primary=False + ) + self.data_affiliate_agreement_2 = DataAffiliateAgreement.objects.create( + signed_agreement=tmp, + study=Study.objects.get(short_name="test2"), + anvil_upload_group=ManagedGroup.objects.create(name="testupload2", email="testupload2@example.com"), + ) + tmp = SignedAgreement.objects.create( + cc_id=5, + representative=User.objects.create_user(username="test5", password="test5"), + representative_role="Test role 5", + signing_institution="Test institution 5", + type="non_data_affiliate", + version=agreement_version, + anvil_access_group=ManagedGroup.objects.create(name="testaccess5", email="testaccess5@example.com"), + is_primary=False + ) + self.non_data_affiliate_agreement = NonDataAffiliateAgreement.objects.create( + signed_agreement=tmp, + ) + + def test_is_primary_correctly_populated(self): +# import ipdb; ipdb.set_trace() + MemberAgreement = self.new_state.apps.get_model("cdsa", "MemberAgreement") + DataAffiliateAgreement = self.new_state.apps.get_model("cdsa", "DataAffiliateAgreement") + NonDataAffiliateAgreement = self.new_state.apps.get_model("cdsa", "NonDataAffiliateAgreement") + instance = MemberAgreement.objects.get(pk=self.member_agreement_1.pk) + self.assertEqual(instance.is_primary, True) + instance = MemberAgreement.objects.get(pk=self.member_agreement_2.pk) + self.assertEqual(instance.is_primary, False) + instance = DataAffiliateAgreement.objects.get(pk=self.data_affiliate_agreement_1.pk) + self.assertEqual(instance.is_primary, True) + instance = DataAffiliateAgreement.objects.get(pk=self.data_affiliate_agreement_2.pk) + self.assertEqual(instance.is_primary, False) + instance = NonDataAffiliateAgreement.objects.get(pk=self.non_data_affiliate_agreement.pk) + self.assertFalse(hasattr(self.non_data_affiliate_agreement, "is_primary")) + + +class PopulateIsPrimaryMigrationsBackwardTest(MigratorTestCase): + """Tests for the migrations associated with creating the new AgreementMajorVersion model.""" + + migrate_from = ("cdsa", "0022_remove_signedagreement_is_primary") + migrate_to = ("cdsa", "0018_dataaffiliateagreement_requires_study_review_and_more") + + def prepare(self): + """Prepare some data before the migration.""" + # Get model definition for the old state. + User = self.old_state.apps.get_model("users", "User") + ManagedGroup = self.old_state.apps.get_model("anvil_consortium_manager", "ManagedGroup") + StudySite = self.old_state.apps.get_model("primed_anvil", "StudySite") + Study = self.old_state.apps.get_model("primed_anvil", "Study") + AgreementMajorVersion = self.old_state.apps.get_model("cdsa", "AgreementMajorVersion") + AgreementVersion = self.old_state.apps.get_model("cdsa", "AgreementVersion") + SignedAgreement = self.old_state.apps.get_model("cdsa", "SignedAgreement") + MemberAgreement = self.old_state.apps.get_model("cdsa", "MemberAgreement") + DataAffiliateAgreement = self.old_state.apps.get_model("cdsa", "DataAffiliateAgreement") + NonDataAffiliateAgreement = self.old_state.apps.get_model("cdsa", "NonDataAffiliateAgreement") + # Populate some signed agreements. + agreement_version = AgreementVersion.objects.create( + major_version=AgreementMajorVersion.objects.create(version=1, is_valid=True), + minor_version=0, + ) + tmp = SignedAgreement.objects.create( + cc_id=1, + representative=User.objects.create_user(username="test1", password="test1"), + representative_role="Test role 1", + signing_institution="Test institution 1", + type="member", + version=agreement_version, + anvil_access_group=ManagedGroup.objects.create(name="testaccess1", email="testaccess1@example.com"), + ) + self.member_agreement_1 = MemberAgreement.objects.create( + signed_agreement=tmp, + study_site=StudySite.objects.create(short_name="test1", full_name="Test Study 1"), + is_primary=True, + ) + tmp = SignedAgreement.objects.create( + cc_id=2, + representative=User.objects.create_user(username="test2", password="test2"), + representative_role="Test role 2", + signing_institution="Test institution 2", + type="member", + version=agreement_version, + anvil_access_group=ManagedGroup.objects.create(name="testaccess2", email="testaccess2@example.com"), + ) + self.member_agreement_2 = MemberAgreement.objects.create( + signed_agreement=tmp, + study_site=StudySite.objects.get(short_name="test1"), + is_primary=False, + ) + tmp = SignedAgreement.objects.create( + cc_id=3, + representative=User.objects.create_user(username="test3", password="test3"), + representative_role="Test role 3", + signing_institution="Test institution 3", + type="data_affiliate", + version=agreement_version, + anvil_access_group=ManagedGroup.objects.create(name="testaccess3", email="testaccess3@example.com"), + ) + self.data_affiliate_agreement_1 = DataAffiliateAgreement.objects.create( + signed_agreement=tmp, + study=Study.objects.create(short_name="test2", full_name="Test Study Site 2"), + anvil_upload_group=ManagedGroup.objects.create(name="testupload1", email="testupload1@example.com"), + is_primary=True, + ) + tmp = SignedAgreement.objects.create( + cc_id=4, + representative=User.objects.create_user(username="test4", password="test4"), + representative_role="Test role 4", + signing_institution="Test institution 4", + type="data_affiliate", + version=agreement_version, + anvil_access_group=ManagedGroup.objects.create(name="testaccess4", email="testaccess4@example.com"), + ) + self.data_affiliate_agreement_2 = DataAffiliateAgreement.objects.create( + signed_agreement=tmp, + study=Study.objects.get(short_name="test2"), + anvil_upload_group=ManagedGroup.objects.create(name="testupload2", email="testupload2@example.com"), + is_primary=False, + ) + tmp = SignedAgreement.objects.create( + cc_id=5, + representative=User.objects.create_user(username="test5", password="test5"), + representative_role="Test role 5", + signing_institution="Test institution 5", + type="non_data_affiliate", + version=agreement_version, + anvil_access_group=ManagedGroup.objects.create(name="testaccess5", email="testaccess5@example.com"), + ) + self.non_data_affiliate_agreement = NonDataAffiliateAgreement.objects.create( + signed_agreement=tmp, + ) + + def test_is_primary_correctly_populated(self): + SignedAgreement = self.new_state.apps.get_model("cdsa", "SignedAgreement") + instance = SignedAgreement.objects.get(pk=self.member_agreement_1.signed_agreement.pk) + self.assertEqual(instance.is_primary, True) + instance = SignedAgreement.objects.get(pk=self.member_agreement_2.signed_agreement.pk) + self.assertEqual(instance.is_primary, False) + instance = SignedAgreement.objects.get(pk=self.data_affiliate_agreement_1.signed_agreement.pk) + self.assertEqual(instance.is_primary, True) + instance = SignedAgreement.objects.get(pk=self.data_affiliate_agreement_2.signed_agreement.pk) + self.assertEqual(instance.is_primary, False) + instance = SignedAgreement.objects.get(pk=self.non_data_affiliate_agreement.signed_agreement.pk) + self.assertTrue(instance.is_primary) diff --git a/primed/cdsa/tests/test_models.py b/primed/cdsa/tests/test_models.py index 0e41a4f5..eb9fbe66 100644 --- a/primed/cdsa/tests/test_models.py +++ b/primed/cdsa/tests/test_models.py @@ -8,7 +8,7 @@ ManagedGroupFactory, WorkspaceFactory, ) -from django.core.exceptions import NON_FIELD_ERRORS, ValidationError +from django.core.exceptions import ValidationError from django.db.models import ProtectedError from django.db.utils import IntegrityError from django.test import TestCase, override_settings @@ -179,7 +179,6 @@ def test_model_saving(self): representative_role="foo", signing_institution="bar", type=models.SignedAgreement.MEMBER, - is_primary=True, version=agreement_version, anvil_access_group=group, ) @@ -318,22 +317,14 @@ def test_status_field(self): def test_get_combined_type(self): obj = factories.MemberAgreementFactory() self.assertEqual(obj.signed_agreement.combined_type, "Member") - obj = factories.MemberAgreementFactory(signed_agreement__is_primary=False) + obj = factories.MemberAgreementFactory(is_primary=False) self.assertEqual(obj.signed_agreement.combined_type, "Member component") obj = factories.DataAffiliateAgreementFactory() self.assertEqual(obj.signed_agreement.combined_type, "Data affiliate") - obj = factories.DataAffiliateAgreementFactory( - signed_agreement__is_primary=False - ) + obj = factories.DataAffiliateAgreementFactory(is_primary=False) self.assertEqual(obj.signed_agreement.combined_type, "Data affiliate component") obj = factories.NonDataAffiliateAgreementFactory() self.assertEqual(obj.signed_agreement.combined_type, "Non-data affiliate") - obj = factories.NonDataAffiliateAgreementFactory( - signed_agreement__is_primary=False - ) - self.assertEqual( - obj.signed_agreement.combined_type, "Non-data affiliate component" - ) def test_get_agreement_type(self): obj = factories.MemberAgreementFactory() @@ -351,25 +342,6 @@ def test_get_agreement_group(self): obj = factories.NonDataAffiliateAgreementFactory() self.assertEqual(obj.signed_agreement.agreement_group, obj.affiliation) - def test_clean_non_data_affiliate_is_primary_false(self): - """ValidationError is raised when is_primary is False for a non-data affiliate.""" - user = UserFactory.create() - group = ManagedGroupFactory.create() - agreement_version = factories.AgreementVersionFactory.create() - instance = factories.SignedAgreementFactory.build( - representative=user, - anvil_access_group=group, - version=agreement_version, - type=models.SignedAgreement.NON_DATA_AFFILIATE, - is_primary=False, - ) - with self.assertRaises(ValidationError) as e: - instance.full_clean() - self.assertEqual(len(e.exception.message_dict), 1) - self.assertIn(NON_FIELD_ERRORS, e.exception.message_dict) - self.assertEqual(len(e.exception.message_dict[NON_FIELD_ERRORS]), 1) - self.assertIn("primary", e.exception.message_dict[NON_FIELD_ERRORS][0]) - def test_is_in_cdsa_group(self): """is_in_cdsa_group works as expected.""" obj = factories.SignedAgreementFactory.create() @@ -414,10 +386,18 @@ def test_model_saving(self): instance = models.MemberAgreement( signed_agreement=signed_agreement, study_site=study_site, + is_primary=True, ) instance.save() self.assertIsInstance(instance, models.MemberAgreement) + def test_is_primary(self): + """Creation using the model constructor and .save() works.""" + instance = factories.MemberAgreementFactory.create(is_primary=True) + self.assertEqual(instance.is_primary, True) + instance = factories.MemberAgreementFactory.create(is_primary=False) + self.assertEqual(instance.is_primary, False) + def test_clean_incorrect_type(self): signed_agreement = factories.SignedAgreementFactory.create( type=models.SignedAgreement.DATA_AFFILIATE @@ -471,6 +451,20 @@ def test_get_agreement_group(self): class DataAffiliateAgreementTest(TestCase): """Tests for the DataAffiliateAgreement model.""" + def test_defaults(self): + upload_group = ManagedGroupFactory.create() + signed_agreement = factories.SignedAgreementFactory.create( + type=models.SignedAgreement.DATA_AFFILIATE + ) + study = StudyFactory.create() + instance = models.DataAffiliateAgreement( + signed_agreement=signed_agreement, + study=study, + anvil_upload_group=upload_group, + ) + self.assertFalse(instance.requires_study_review) + self.assertEqual(instance.additional_limitations, "") + def test_model_saving(self): """Creation using the model constructor and .save() works.""" upload_group = ManagedGroupFactory.create() @@ -482,10 +476,18 @@ def test_model_saving(self): signed_agreement=signed_agreement, study=study, anvil_upload_group=upload_group, + is_primary=True, ) instance.save() self.assertIsInstance(instance, models.DataAffiliateAgreement) + def test_is_primary(self): + """Creation using the model constructor and .save() works.""" + instance = factories.DataAffiliateAgreementFactory.create(is_primary=True) + self.assertEqual(instance.is_primary, True) + instance = factories.DataAffiliateAgreementFactory.create(is_primary=False) + self.assertEqual(instance.is_primary, False) + def test_clean_incorrect_type(self): signed_agreement = factories.SignedAgreementFactory.create( type=models.SignedAgreement.MEMBER @@ -506,6 +508,28 @@ def test_clean_incorrect_type(self): e.exception.error_dict["signed_agreement"][0].messages[0], ) + def test_clean_additional_limitations_primary(self): + instance = factories.DataAffiliateAgreementFactory.create( + is_primary=True, + additional_limitations="foo bar", + ) + instance.full_clean() + + def test_clean_additional_limitations_not_primary(self): + instance = factories.DataAffiliateAgreementFactory.create( + is_primary=False, + additional_limitations="foo bar", + ) + with self.assertRaises(ValidationError) as e: + instance.clean() + self.assertEqual(len(e.exception.error_dict), 1) + self.assertIn("additional_limitations", e.exception.error_dict) + self.assertEqual(len(e.exception.error_dict["additional_limitations"]), 1) + self.assertIn( + "only allowed for primary agreements", + e.exception.error_dict["additional_limitations"][0].message, + ) + def test_str_method(self): """The custom __str__ method returns the correct string.""" instance = factories.DataAffiliateAgreementFactory.create() @@ -541,6 +565,29 @@ def test_get_agreement_group(self): instance = factories.DataAffiliateAgreementFactory.create() self.assertEqual(instance.get_agreement_group(), instance.study) + def test_requires_study_review_primary(self): + """Can set requires_study_review""" + instance = factories.DataAffiliateAgreementFactory.create( + requires_study_review=True + ) + self.assertTrue(instance.requires_study_review) + + def test_requires_study_review_not_primary(self): + """ValidationError when trying to set requires_study_review=True for components.""" + instance = factories.DataAffiliateAgreementFactory.create( + is_primary=False, + requires_study_review=True, + ) + with self.assertRaises(ValidationError) as e: + instance.clean() + self.assertEqual(len(e.exception.error_dict), 1) + self.assertIn("requires_study_review", e.exception.error_dict) + self.assertEqual(len(e.exception.error_dict["requires_study_review"]), 1) + self.assertIn( + "can only be True for primary", + e.exception.error_dict["requires_study_review"][0].message, + ) + class NonDataAffiliateAgreementTest(TestCase): """Tests for the NonDataAffiliateAgreement model.""" @@ -616,7 +663,6 @@ def test_model_saving(self): requester = UserFactory.create() instance = models.CDSAWorkspace( study=study, - data_use_limitations="test limitations", acknowledgments="test acknowledgments", requested_by=requester, workspace=workspace, @@ -657,3 +703,64 @@ def test_available_data(self): instance.available_data.add(*available_data) self.assertIn(available_data[0], instance.available_data.all()) self.assertIn(available_data[1], instance.available_data.all()) + + def test_additional_limitations(self): + """Can have additional_limitations set.""" + instance = factories.CDSAWorkspaceFactory.create(additional_limitations="foo") + self.assertEqual(instance.additional_limitations, "foo") + + def test_get_primary_cdsa(self): + """get_primary_cdsa returns the primary valid CDSA for the study.""" + instance = factories.CDSAWorkspaceFactory.create() + agreement = factories.DataAffiliateAgreementFactory.create( + is_primary=True, + signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, + study=instance.study, + ) + self.assertEqual(instance.get_primary_cdsa(), agreement) + + def test_get_primary_cdsa_not_primary(self): + instance = factories.CDSAWorkspaceFactory.create() + factories.DataAffiliateAgreementFactory.create( + is_primary=False, + signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, + study=instance.study, + ) + with self.assertRaises(models.DataAffiliateAgreement.DoesNotExist): + instance.get_primary_cdsa() + + def test_get_primary_cdsa_not_active(self): + instance = factories.CDSAWorkspaceFactory.create() + factories.DataAffiliateAgreementFactory.create( + is_primary=True, + signed_agreement__status=models.SignedAgreement.StatusChoices.LAPSED, + study=instance.study, + ) + with self.assertRaises(models.DataAffiliateAgreement.DoesNotExist): + instance.get_primary_cdsa() + + def test_get_primary_cdsa_different_study(self): + instance = factories.CDSAWorkspaceFactory.create() + factories.DataAffiliateAgreementFactory.create( + is_primary=True, + signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, + # study=instance.study, + ) + with self.assertRaises(models.DataAffiliateAgreement.DoesNotExist): + instance.get_primary_cdsa() + + def test_get_primary_cdsa_multiple_agreements(self): + """get_primary_cdsa returns the primary valid CDSA for the study.""" + instance = factories.CDSAWorkspaceFactory.create() + factories.DataAffiliateAgreementFactory.create( + is_primary=True, + signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, + study=instance.study, + ) + factories.DataAffiliateAgreementFactory.create( + is_primary=True, + signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, + study=instance.study, + ) + with self.assertRaises(models.DataAffiliateAgreement.MultipleObjectsReturned): + instance.get_primary_cdsa() diff --git a/primed/cdsa/tests/test_tables.py b/primed/cdsa/tests/test_tables.py index c7c8c9de..83f6bf55 100644 --- a/primed/cdsa/tests/test_tables.py +++ b/primed/cdsa/tests/test_tables.py @@ -339,15 +339,11 @@ def test_row_count_with_two_agreements_multiple_members(self): self.assertEqual(len(table.rows), 5) def test_includes_components(self): - agreement_1 = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=True - ) + agreement_1 = factories.MemberAgreementFactory.create(is_primary=True) GroupAccountMembershipFactory.create( group__signedagreement=agreement_1.signed_agreement ) - agreement_2 = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False - ) + agreement_2 = factories.MemberAgreementFactory.create(is_primary=False) GroupAccountMembershipFactory.create( group__signedagreement=agreement_2.signed_agreement ) @@ -476,6 +472,32 @@ def test_ordering(self): self.assertEqual(table.data[0], instance_2.workspace) self.assertEqual(table.data[1], instance_1.workspace) + def test_render_requires_study_review(self): + table = self.table_class(self.model.objects.all()) + # CDSA workspace with no data_affiliate_agreement. + cdsa_workspace = factories.CDSAWorkspaceFactory.create() + self.assertIn( + "question-circle-fill", + table.render_cdsaworkspace__requires_study_review(cdsa_workspace.workspace), + ) + # With a primary - no review required. + agreement = factories.DataAffiliateAgreementFactory.create( + is_primary=True, + requires_study_review=False, + study=cdsa_workspace.study, + ) + self.assertEqual( + "", + table.render_cdsaworkspace__requires_study_review(cdsa_workspace.workspace), + ) + # With a primary - review required. + agreement.requires_study_review = True + agreement.save() + self.assertIn( + "dash-circle-fill", + table.render_cdsaworkspace__requires_study_review(cdsa_workspace.workspace), + ) + class CDSAWorkspaceUserTableTest(TestCase): """Tests for the CDSAWorkspaceUserTable class.""" @@ -505,3 +527,29 @@ def test_ordering(self): table = self.table_class(self.model.objects.all()) self.assertEqual(table.data[0], instance_2.workspace) self.assertEqual(table.data[1], instance_1.workspace) + + def test_render_requires_study_review(self): + table = self.table_class(self.model.objects.all()) + # CDSA workspace with no data_affiliate_agreement. + cdsa_workspace = factories.CDSAWorkspaceFactory.create() + self.assertIn( + "question-circle-fill", + table.render_cdsaworkspace__requires_study_review(cdsa_workspace.workspace), + ) + # With a primary - no review required. + agreement = factories.DataAffiliateAgreementFactory.create( + is_primary=True, + requires_study_review=False, + study=cdsa_workspace.study, + ) + self.assertEqual( + "", + table.render_cdsaworkspace__requires_study_review(cdsa_workspace.workspace), + ) + # With a primary - review required. + agreement.requires_study_review = True + agreement.save() + self.assertIn( + "dash-circle-fill", + table.render_cdsaworkspace__requires_study_review(cdsa_workspace.workspace), + ) diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 41a2caaf..ab203d0a 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -21,7 +21,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.contrib.messages import get_messages -from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied +from django.core.exceptions import PermissionDenied from django.http import Http404 from django.shortcuts import resolve_url from django.test import RequestFactory, TestCase, override_settings @@ -30,6 +30,8 @@ from freezegun import freeze_time from primed.duo.tests.factories import DataUseModifierFactory, DataUsePermissionFactory +from primed.miscellaneous_workspaces.tables import DataPrepWorkspaceUserTable +from primed.miscellaneous_workspaces.tests.factories import DataPrepWorkspaceFactory from primed.primed_anvil.tests.factories import ( AvailableDataFactory, StudyFactory, @@ -1492,7 +1494,7 @@ def test_can_create_object(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1509,7 +1511,6 @@ def test_can_create_object(self): self.assertEqual(new_agreement.representative_role, "Test role") self.assertEqual(new_agreement.signing_institution, "Test institution") self.assertEqual(new_agreement.date_signed, date.fromisoformat("2023-01-01")) - self.assertEqual(new_agreement.is_primary, True) # Type was set correctly. self.assertEqual(new_agreement.type, new_agreement.MEMBER) # AnVIL group was set correctly. @@ -1525,6 +1526,7 @@ def test_can_create_object(self): new_agreement_type = models.MemberAgreement.objects.latest("pk") self.assertEqual(new_agreement.memberagreement, new_agreement_type) self.assertEqual(new_agreement_type.study_site, study_site) + self.assertEqual(new_agreement_type.is_primary, True) def test_redirect_url(self): """Redirects to successful url.""" @@ -1556,7 +1558,7 @@ def test_redirect_url(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1597,7 +1599,7 @@ def test_success_message(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1624,7 +1626,7 @@ def test_error_missing_cc_id(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1660,7 +1662,7 @@ def test_invalid_cc_id(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1694,7 +1696,7 @@ def test_error_missing_representative(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1728,7 +1730,7 @@ def test_error_invalid_representative(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1764,7 +1766,7 @@ def test_error_missing_representative_role(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1800,7 +1802,7 @@ def test_error_missing_signing_institution(self): # "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1835,7 +1837,7 @@ def test_error_missing_version(self): "signing_institution": "Test institution", # "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1870,7 +1872,7 @@ def test_error_invalid_version(self): "signing_institution": "Test institution", "version": 999, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1906,7 +1908,7 @@ def test_error_missing_date_signed(self): "signing_institution": "Test institution", "version": agreement_version.pk, # "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1942,7 +1944,7 @@ def test_error_missing_is_primary(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - # "is_primary": True, + # "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1957,11 +1959,14 @@ def test_error_missing_is_primary(self): # Form has errors in the correct field. self.assertIn("form", response.context_data) form = response.context_data["form"] - self.assertFalse(form.is_valid()) - self.assertEqual(len(form.errors), 1) - self.assertIn("is_primary", form.errors) - self.assertEqual(len(form.errors["is_primary"]), 1) - self.assertIn("required", form.errors["is_primary"][0]) + self.assertTrue(form.is_valid()) + formset = response.context_data["formset"] + self.assertFalse(formset.is_valid()) + self.assertFalse(formset.forms[0].is_valid()) + self.assertEqual(len(formset.forms[0].errors), 1) + self.assertIn("is_primary", formset.forms[0].errors) + self.assertEqual(len(formset.forms[0].errors["is_primary"]), 1) + self.assertIn("required", formset.forms[0].errors["is_primary"][0]) def test_error_missing_memberagreement_study_site(self): """Form shows an error when study_site is missing.""" @@ -1977,7 +1982,7 @@ def test_error_missing_memberagreement_study_site(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -1989,10 +1994,10 @@ def test_error_missing_memberagreement_study_site(self): # No new objects were created. self.assertEqual(models.SignedAgreement.objects.count(), 0) self.assertEqual(models.MemberAgreement.objects.count(), 0) - # Form has errors in the correct field. self.assertIn("form", response.context_data) form = response.context_data["form"] self.assertTrue(form.is_valid()) + # Formset has errors in the correct field. formset = response.context_data["formset"] self.assertFalse(formset.is_valid()) self.assertFalse(formset.forms[0].is_valid()) @@ -2015,7 +2020,7 @@ def test_error_invalid_memberagreement_study_site(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2053,7 +2058,7 @@ def test_error_duplicate_project_id(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2114,7 +2119,7 @@ def test_creates_anvil_access_group(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2165,7 +2170,7 @@ def test_creates_anvil_groups_different_setting_access_group_prefix(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2213,7 +2218,7 @@ def test_creates_anvil_groups_different_setting_cc_admins_group_name(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2251,7 +2256,7 @@ def test_manage_group_create_api_error(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2291,7 +2296,7 @@ def test_managed_group_already_exists_in_app(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2341,7 +2346,7 @@ def test_admin_group_membership_api_error(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2510,6 +2515,29 @@ def test_change_status_button_user_has_view_perm(self): ), ) + def test_response_is_primary(self): + """Response includes info about requires_study_review.""" + instance = factories.MemberAgreementFactory.create( + is_primary=True, + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(instance.signed_agreement.cc_id)) + self.assertContains(response, "Primary?") + self.assertContains( + response, + """
Primary?
Yes
""", # noqa: E501 + html=True, + ) + instance.is_primary = False + instance.save() + response = self.client.get(self.get_url(instance.signed_agreement.cc_id)) + self.assertContains(response, "Primary?") + self.assertContains( + response, + """
Primary?
No
""", # noqa: E501 + html=True, + ) + class MemberAgreementListTest(TestCase): """Tests for the MemberAgreementList view.""" @@ -2711,7 +2739,7 @@ def test_can_create_object(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2728,7 +2756,6 @@ def test_can_create_object(self): self.assertEqual(new_agreement.representative_role, "Test role") self.assertEqual(new_agreement.signing_institution, "Test institution") self.assertEqual(new_agreement.date_signed, date.fromisoformat("2023-01-01")) - self.assertEqual(new_agreement.is_primary, True) # Type was set correctly. self.assertEqual(new_agreement.type, new_agreement.DATA_AFFILIATE) # AnVIL group was set correctly. @@ -2743,6 +2770,7 @@ def test_can_create_object(self): self.assertEqual(models.DataAffiliateAgreement.objects.count(), 1) new_agreement_type = models.DataAffiliateAgreement.objects.latest("pk") self.assertEqual(new_agreement.dataaffiliateagreement, new_agreement_type) + self.assertEqual(new_agreement_type.is_primary, True) self.assertEqual(new_agreement_type.study, study) self.assertIsInstance(new_agreement_type.anvil_upload_group, ManagedGroup) self.assertEqual( @@ -2792,7 +2820,7 @@ def test_redirect_url(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2846,7 +2874,7 @@ def test_success_message(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2860,6 +2888,202 @@ def test_success_message(self): views.DataAffiliateAgreementCreate.success_message, str(messages[0]) ) + def test_can_create_primary_with_requires_study_review(self): + """Can create an object.""" + self.client.force_login(self.user) + representative = UserFactory.create() + agreement_version = factories.AgreementVersionFactory.create() + study = StudyFactory.create() + # API response to create the associated anvil_access_group. + self.anvil_response_mock.add( + responses.POST, + self.api_client.sam_entry_point + + "/api/groups/v1/TEST_PRIMED_CDSA_ACCESS_1234", + status=201, + json={"message": "mock message"}, + ) + self.anvil_response_mock.add( + responses.POST, + self.api_client.sam_entry_point + + "/api/groups/v1/TEST_PRIMED_CDSA_UPLOAD_1234", + status=201, + json={"message": "mock message"}, + ) + # CC admins group membership. + self.anvil_response_mock.add( + responses.PUT, + self.api_client.sam_entry_point + + "/api/groups/v1/TEST_PRIMED_CDSA_ACCESS_1234/admin/TEST_PRIMED_CC_ADMINS@firecloud.org", + status=204, + ) + self.anvil_response_mock.add( + responses.PUT, + self.api_client.sam_entry_point + + "/api/groups/v1/TEST_PRIMED_CDSA_UPLOAD_1234/admin/TEST_PRIMED_CC_ADMINS@firecloud.org", + status=204, + ) + response = self.client.post( + self.get_url(), + { + "cc_id": 1234, + "representative": representative.pk, + "representative_role": "Test role", + "signing_institution": "Test institution", + "version": agreement_version.pk, + "date_signed": "2023-01-01", + "agreementtype-0-is_primary": True, + "agreementtype-TOTAL_FORMS": 1, + "agreementtype-INITIAL_FORMS": 0, + "agreementtype-MIN_NUM_FORMS": 1, + "agreementtype-MAX_NUM_FORMS": 1, + "agreementtype-0-study": study.pk, + "agreementtype-0-requires_study_review": True, + }, + ) + self.assertEqual(response.status_code, 302) + # Check the agreement type. + self.assertEqual(models.DataAffiliateAgreement.objects.count(), 1) + new_agreement_type = models.DataAffiliateAgreement.objects.latest("pk") + self.assertTrue(new_agreement_type.requires_study_review) + + def test_cannot_create_component_with_requires_study_review(self): + """Cannot create a component agreement with requires_study_review=True.""" + self.client.force_login(self.user) + representative = UserFactory.create() + agreement_version = factories.AgreementVersionFactory.create() + study = StudyFactory.create() + response = self.client.post( + self.get_url(), + { + "cc_id": 1234, + "representative": representative.pk, + "representative_role": "Test role", + "signing_institution": "Test institution", + "version": agreement_version.pk, + "date_signed": "2023-01-01", + "agreementtype-0-is_primary": False, + "agreementtype-TOTAL_FORMS": 1, + "agreementtype-INITIAL_FORMS": 0, + "agreementtype-MIN_NUM_FORMS": 1, + "agreementtype-MAX_NUM_FORMS": 1, + "agreementtype-0-study": study.pk, + "agreementtype-0-requires_study_review": True, + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("form", response.context) + form = response.context_data["form"] + self.assertTrue(form.is_valid()) + # Formset has errors in the correct field. + formset = response.context_data["formset"] + self.assertFalse(formset.is_valid()) + self.assertFalse(formset.forms[0].is_valid()) + self.assertEqual(len(formset.forms[0].errors), 1) + self.assertIn("requires_study_review", formset.forms[0].errors) + self.assertEqual(len(formset.forms[0].errors["requires_study_review"]), 1) + self.assertIn( + "can only be True for primary", + formset.forms[0].errors["requires_study_review"][0], + ) + + def test_can_create_primary_with_additional_limitations(self): + """Can create an object.""" + self.client.force_login(self.user) + representative = UserFactory.create() + agreement_version = factories.AgreementVersionFactory.create() + study = StudyFactory.create() + # API response to create the associated anvil_access_group. + self.anvil_response_mock.add( + responses.POST, + self.api_client.sam_entry_point + + "/api/groups/v1/TEST_PRIMED_CDSA_ACCESS_1234", + status=201, + json={"message": "mock message"}, + ) + self.anvil_response_mock.add( + responses.POST, + self.api_client.sam_entry_point + + "/api/groups/v1/TEST_PRIMED_CDSA_UPLOAD_1234", + status=201, + json={"message": "mock message"}, + ) + # CC admins group membership. + self.anvil_response_mock.add( + responses.PUT, + self.api_client.sam_entry_point + + "/api/groups/v1/TEST_PRIMED_CDSA_ACCESS_1234/admin/TEST_PRIMED_CC_ADMINS@firecloud.org", + status=204, + ) + self.anvil_response_mock.add( + responses.PUT, + self.api_client.sam_entry_point + + "/api/groups/v1/TEST_PRIMED_CDSA_UPLOAD_1234/admin/TEST_PRIMED_CC_ADMINS@firecloud.org", + status=204, + ) + response = self.client.post( + self.get_url(), + { + "cc_id": 1234, + "representative": representative.pk, + "representative_role": "Test role", + "signing_institution": "Test institution", + "version": agreement_version.pk, + "date_signed": "2023-01-01", + "agreementtype-0-is_primary": True, + "agreementtype-TOTAL_FORMS": 1, + "agreementtype-INITIAL_FORMS": 0, + "agreementtype-MIN_NUM_FORMS": 1, + "agreementtype-MAX_NUM_FORMS": 1, + "agreementtype-0-study": study.pk, + "agreementtype-0-additional_limitations": "Test limitations", + }, + ) + self.assertEqual(response.status_code, 302) + # Check the agreement type. + self.assertEqual(models.DataAffiliateAgreement.objects.count(), 1) + new_agreement_type = models.DataAffiliateAgreement.objects.latest("pk") + self.assertEqual(new_agreement_type.additional_limitations, "Test limitations") + + def test_cannot_create_component_with_additional_limitations(self): + """Cannot create a component agreement with additional_limitations.""" + self.client.force_login(self.user) + representative = UserFactory.create() + agreement_version = factories.AgreementVersionFactory.create() + study = StudyFactory.create() + response = self.client.post( + self.get_url(), + { + "cc_id": 1234, + "representative": representative.pk, + "representative_role": "Test role", + "signing_institution": "Test institution", + "version": agreement_version.pk, + "date_signed": "2023-01-01", + "agreementtype-0-is_primary": False, + "agreementtype-TOTAL_FORMS": 1, + "agreementtype-INITIAL_FORMS": 0, + "agreementtype-MIN_NUM_FORMS": 1, + "agreementtype-MAX_NUM_FORMS": 1, + "agreementtype-0-study": study.pk, + "agreementtype-0-additional_limitations": "Test limitations", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("form", response.context) + form = response.context_data["form"] + self.assertTrue(form.is_valid()) + # Formset has errors in the correct field. + formset = response.context_data["formset"] + self.assertFalse(formset.is_valid()) + self.assertFalse(formset.forms[0].is_valid()) + self.assertEqual(len(formset.forms[0].errors), 1) + self.assertIn("additional_limitations", formset.forms[0].errors) + self.assertEqual(len(formset.forms[0].errors["additional_limitations"]), 1) + self.assertIn( + "only allowed for primary", + formset.forms[0].errors["additional_limitations"][0], + ) + def test_error_missing_cc_id(self): """Form shows an error when cc_id is missing.""" self.client.force_login(self.user) @@ -2875,7 +3099,7 @@ def test_error_missing_cc_id(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2911,7 +3135,7 @@ def test_invalid_cc_id(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2945,7 +3169,7 @@ def test_error_missing_representative(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2979,7 +3203,7 @@ def test_error_invalid_representative(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3015,7 +3239,7 @@ def test_error_missing_representative_role(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3051,7 +3275,7 @@ def test_error_missing_signing_institution(self): # "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3086,7 +3310,7 @@ def test_error_missing_version(self): "signing_institution": "Test institution", # "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3121,7 +3345,7 @@ def test_error_invalid_version(self): "signing_institution": "Test institution", "version": 9999, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3157,7 +3381,7 @@ def test_error_missing_date_signed(self): "signing_institution": "Test institution", "version": agreement_version.pk, # "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3193,7 +3417,7 @@ def test_error_missing_is_primary(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - # "is_primary": True, + # "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3208,11 +3432,14 @@ def test_error_missing_is_primary(self): # Form has errors in the correct field. self.assertIn("form", response.context_data) form = response.context_data["form"] - self.assertFalse(form.is_valid()) - self.assertEqual(len(form.errors), 1) - self.assertIn("is_primary", form.errors) - self.assertEqual(len(form.errors["is_primary"]), 1) - self.assertIn("required", form.errors["is_primary"][0]) + self.assertTrue(form.is_valid()) + formset = response.context_data["formset"] + self.assertFalse(formset.is_valid()) + self.assertFalse(formset.forms[0].is_valid()) + self.assertEqual(len(formset.forms[0].errors), 1) + self.assertIn("is_primary", formset.forms[0].errors) + self.assertEqual(len(formset.forms[0].errors["is_primary"]), 1) + self.assertIn("required", formset.forms[0].errors["is_primary"][0]) def test_error_missing_study(self): """Form shows an error when study is missing.""" @@ -3228,7 +3455,7 @@ def test_error_missing_study(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3266,7 +3493,7 @@ def test_error_invalid_memberagreement_study_site(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3304,7 +3531,7 @@ def test_error_duplicate_project_id(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3377,7 +3604,7 @@ def test_creates_anvil_groups(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3452,7 +3679,7 @@ def test_creates_anvil_access_group_different_setting(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3519,7 +3746,7 @@ def test_creates_anvil_groups_different_setting_cc_admins_group_name(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3563,7 +3790,7 @@ def test_access_group_create_api_error(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3623,7 +3850,7 @@ def test_upload_group_create_api_error(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3663,7 +3890,7 @@ def test_access_group_already_exists_in_app(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3700,7 +3927,7 @@ def test_upload_group_already_exists_in_app(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3764,7 +3991,7 @@ def test_admin_group_membership_access_api_error(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3830,7 +4057,7 @@ def test_admin_group_membership_upload_api_error(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, + "agreementtype-0-is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4005,6 +4232,76 @@ def test_change_status_button_user_has_view_perm(self): ), ) + def test_response_includes_additional_limitations(self): + """Response includes a link to the study detail page.""" + instance = factories.DataAffiliateAgreementFactory.create( + is_primary=True, + additional_limitations="Test limitations for this data affiliate agreement", + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(instance.signed_agreement.cc_id)) + self.assertContains(response, "Additional limitations") + self.assertContains( + response, "Test limitations for this data affiliate agreement" + ) + + def test_response_with_no_additional_limitations(self): + """Response includes a link to the study detail page.""" + instance = factories.DataAffiliateAgreementFactory.create( + is_primary=True, + additional_limitations="", + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(instance.signed_agreement.cc_id)) + self.assertNotContains(response, "Additional limitations") + + def test_response_is_primary(self): + """Response includes info about requires_study_review.""" + instance = factories.DataAffiliateAgreementFactory.create( + is_primary=True, + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(instance.signed_agreement.cc_id)) + self.assertContains(response, "Primary?") + self.assertContains( + response, + """
Primary?
Yes
""", # noqa: E501 + html=True, + ) + instance.is_primary = False + instance.save() + response = self.client.get(self.get_url(instance.signed_agreement.cc_id)) + self.assertContains(response, "Primary?") + self.assertContains( + response, + """
Primary?
No
""", # noqa: E501 + html=True, + ) + + def test_response_requires_study_review(self): + """Response includes info about requires_study_review.""" + instance = factories.DataAffiliateAgreementFactory.create( + is_primary=True, requires_study_review=True + ) + self.client.force_login(self.user) + response = self.client.get(self.get_url(instance.signed_agreement.cc_id)) + self.assertContains(response, "Study review required?") + # import ipdb; ipdb.set_trace() + self.assertContains( + response, + """
Study review required?
Yes
""", # noqa: E501 + html=True, + ) + instance.requires_study_review = False + instance.save() + response = self.client.get(self.get_url(instance.signed_agreement.cc_id)) + self.assertContains(response, "Study review required?") + self.assertContains( + response, + """
Study review required?
No
""", # noqa: E501 + html=True, + ) + class DataAffiliateAgreementListTest(TestCase): """Tests for the DataAffiliateAgreement view.""" @@ -4193,7 +4490,6 @@ def test_can_create_object(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4210,7 +4506,6 @@ def test_can_create_object(self): self.assertEqual(new_agreement.representative_role, "Test role") self.assertEqual(new_agreement.signing_institution, "Test institution") self.assertEqual(new_agreement.date_signed, date.fromisoformat("2023-01-01")) - self.assertEqual(new_agreement.is_primary, True) # Type was set correctly. self.assertEqual(new_agreement.type, new_agreement.NON_DATA_AFFILIATE) # AnVIL group was set correctly. @@ -4256,7 +4551,6 @@ def test_redirect_url(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4296,7 +4590,6 @@ def test_success_message(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4324,7 +4617,6 @@ def test_error_missing_cc_id(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4359,7 +4651,6 @@ def test_invalid_cc_id(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4392,7 +4683,6 @@ def test_error_missing_representative(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4425,7 +4715,6 @@ def test_error_invalid_representative(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4460,7 +4749,6 @@ def test_error_missing_representative_role(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4495,7 +4783,6 @@ def test_error_missing_signing_institution(self): # "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4529,7 +4816,6 @@ def test_error_missing_version(self): "signing_institution": "Test institution", # "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4563,7 +4849,6 @@ def test_error_invalid_version(self): "signing_institution": "Test institution", "version": 999, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4598,7 +4883,6 @@ def test_error_missing_date_signed(self): "signing_institution": "Test institution", "version": agreement_version.pk, # "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4619,41 +4903,6 @@ def test_error_missing_date_signed(self): self.assertEqual(len(form.errors["date_signed"]), 1) self.assertIn("required", form.errors["date_signed"][0]) - def test_error_missing_is_primary(self): - """Form shows an error when representative is missing.""" - self.client.force_login(self.user) - representative = UserFactory.create() - agreement_version = factories.AgreementVersionFactory.create() - response = self.client.post( - self.get_url(), - { - "cc_id": 1, - "representative": representative.pk, - "representative_role": "Test role", - "signing_institution": "Test institution", - "version": agreement_version.pk, - "date_signed": "2023-01-01", - # "is_primary": True, - "agreementtype-TOTAL_FORMS": 1, - "agreementtype-INITIAL_FORMS": 0, - "agreementtype-MIN_NUM_FORMS": 1, - "agreementtype-MAX_NUM_FORMS": 1, - "agreementtype-0-affiliation": "Foo Bar", - }, - ) - self.assertEqual(response.status_code, 200) - # No new objects were created. - self.assertEqual(models.SignedAgreement.objects.count(), 0) - self.assertEqual(models.MemberAgreement.objects.count(), 0) - # Form has errors in the correct field. - self.assertIn("form", response.context_data) - form = response.context_data["form"] - self.assertFalse(form.is_valid()) - self.assertEqual(len(form.errors), 1) - self.assertIn("is_primary", form.errors) - self.assertEqual(len(form.errors["is_primary"]), 1) - self.assertIn("required", form.errors["is_primary"][0]) - def test_error_missing_nondataaffiliateagreement_affiliation(self): """Form shows an error when study_site is missing.""" self.client.force_login(self.user) @@ -4668,7 +4917,6 @@ def test_error_missing_nondataaffiliateagreement_affiliation(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4707,7 +4955,6 @@ def test_error_duplicate_project_id(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4731,40 +4978,6 @@ def test_error_duplicate_project_id(self): self.assertEqual(len(form.errors["cc_id"]), 1) self.assertIn("already exists", form.errors["cc_id"][0]) - def test_error_is_primary_false(self): - """Form shows an error when trying to create a duplicate dbgap_phs.""" - self.client.force_login(self.user) - representative = UserFactory.create() - agreement_version = factories.AgreementVersionFactory.create() - response = self.client.post( - self.get_url(), - { - "cc_id": 1, - "representative": representative.pk, - "representative_role": "Test role", - "signing_institution": "Test institution", - "version": agreement_version.pk, - "date_signed": "2023-01-01", - "is_primary": False, - "agreementtype-TOTAL_FORMS": 1, - "agreementtype-INITIAL_FORMS": 0, - "agreementtype-MIN_NUM_FORMS": 1, - "agreementtype-MAX_NUM_FORMS": 1, - "agreementtype-0-affiliation": "Foo Bar", - }, - ) - self.assertEqual(response.status_code, 200) - # No new objects were created. - self.assertEqual(models.SignedAgreement.objects.count(), 0) - self.assertEqual(models.NonDataAffiliateAgreement.objects.count(), 0) - # Form has errors in the correct field. - form = response.context_data["form"] - self.assertFalse(form.is_valid()) - self.assertEqual(len(form.errors), 1) - self.assertIn(NON_FIELD_ERRORS, form.errors) - self.assertEqual(len(form.errors[NON_FIELD_ERRORS]), 1) - self.assertIn("primary", form.errors[NON_FIELD_ERRORS][0]) - def test_post_blank_data(self): """Posting blank data does not create an object.""" self.client.force_login(self.user) @@ -4801,7 +5014,6 @@ def test_creates_anvil_access_group(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4851,7 +5063,6 @@ def test_creates_anvil_groups_different_setting(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4898,7 +5109,6 @@ def test_creates_anvil_groups_different_setting_cc_admins_group_name(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4935,7 +5145,6 @@ def test_manage_group_create_api_error(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -4974,7 +5183,6 @@ def test_managed_group_already_exists_in_app(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": True, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -5457,9 +5665,7 @@ def test_context_needs_action_table_grant(self): def test_context_error_table_has_access(self): """error shows a record when audit finds that access needs to be removed.""" - member_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False - ) + member_agreement = factories.MemberAgreementFactory.create(is_primary=False) GroupGroupMembershipFactory.create( parent_group=self.anvil_cdsa_group, child_group=member_agreement.signed_agreement.anvil_access_group, @@ -6856,25 +7062,19 @@ def test_table_no_rows(self): def test_table_three_rows(self): """Three rows are shown if there are three SignedAgreement objects.""" - factories.DataAffiliateAgreementFactory.create_batch( - 3, signed_agreement__is_primary=True - ) + factories.DataAffiliateAgreementFactory.create_batch(3, is_primary=True) self.client.force_login(self.user) response = self.client.get(self.get_url()) self.assertIn("table", response.context_data) self.assertEqual(len(response.context_data["table"].rows), 3) def test_only_shows_data_affiliate_records(self): - member_agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=True - ) + member_agreement = factories.MemberAgreementFactory.create(is_primary=True) data_affiliate_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True + is_primary=True ) non_data_affiliate_agreement = ( - factories.NonDataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True - ) + factories.NonDataAffiliateAgreementFactory.create() ) self.client.force_login(self.user) response = self.client.get(self.get_url()) @@ -6886,10 +7086,10 @@ def test_only_shows_data_affiliate_records(self): def test_only_shows_primary_data_affiliate_records(self): primary_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True + is_primary=True ) component_agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False + is_primary=False ) self.client.force_login(self.user) response = self.client.get(self.get_url()) @@ -6963,7 +7163,7 @@ def test_table_no_rows(self): def test_table_one_agreement_no_members(self): """No row is shown if there is one agreement with no account group members.""" - factories.MemberAgreementFactory.create(signed_agreement__is_primary=True) + factories.MemberAgreementFactory.create(is_primary=True) self.client.force_login(self.user) response = self.client.get(self.get_url()) self.assertIn("table", response.context_data) @@ -6971,9 +7171,7 @@ def test_table_one_agreement_no_members(self): def test_table_one_agreement_one_member(self): """One row is shown if there is one agreement and one account group member.""" - agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=True - ) + agreement = factories.MemberAgreementFactory.create(is_primary=True) GroupAccountMembershipFactory.create( group=agreement.signed_agreement.anvil_access_group ) @@ -6984,9 +7182,7 @@ def test_table_one_agreement_one_member(self): def test_table_one_agreements_two_members(self): """Two rows are shown if there is one agreement with two account group members.""" - agreement = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=True - ) + agreement = factories.MemberAgreementFactory.create(is_primary=True) GroupAccountMembershipFactory.create_batch( 2, group=agreement.signed_agreement.anvil_access_group ) @@ -6997,15 +7193,11 @@ def test_table_one_agreements_two_members(self): def test_table_two_agreements(self): """Multiple rows is shown if there are two agreements and multiple account group members.""" - agreement_1 = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=True - ) + agreement_1 = factories.MemberAgreementFactory.create(is_primary=True) GroupAccountMembershipFactory.create_batch( 2, group=agreement_1.signed_agreement.anvil_access_group ) - agreement_2 = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=True - ) + agreement_2 = factories.MemberAgreementFactory.create(is_primary=True) GroupAccountMembershipFactory.create_batch( 3, group=agreement_2.signed_agreement.anvil_access_group ) @@ -7015,21 +7207,15 @@ def test_table_two_agreements(self): self.assertEqual(len(response.context_data["table"].rows), 5) def test_only_shows_records_for_all_agreement_types(self): - agreement_1 = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=True - ) + agreement_1 = factories.MemberAgreementFactory.create(is_primary=True) GroupAccountMembershipFactory.create( group=agreement_1.signed_agreement.anvil_access_group ) - agreement_2 = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True - ) + agreement_2 = factories.DataAffiliateAgreementFactory.create(is_primary=True) GroupAccountMembershipFactory.create( group=agreement_2.signed_agreement.anvil_access_group ) - agreement_3 = factories.NonDataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True - ) + agreement_3 = factories.NonDataAffiliateAgreementFactory.create() GroupAccountMembershipFactory.create( group=agreement_3.signed_agreement.anvil_access_group ) @@ -7039,28 +7225,18 @@ def test_only_shows_records_for_all_agreement_types(self): self.assertEqual(len(table.rows), 3) def test_shows_includes_component_agreements(self): - agreement_1 = factories.MemberAgreementFactory.create( - signed_agreement__is_primary=False - ) + agreement_1 = factories.MemberAgreementFactory.create(is_primary=False) GroupAccountMembershipFactory.create( group=agreement_1.signed_agreement.anvil_access_group ) - agreement_2 = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False - ) + agreement_2 = factories.DataAffiliateAgreementFactory.create(is_primary=False) GroupAccountMembershipFactory.create( group=agreement_2.signed_agreement.anvil_access_group ) - agreement_3 = factories.NonDataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False - ) - GroupAccountMembershipFactory.create( - group=agreement_3.signed_agreement.anvil_access_group - ) self.client.force_login(self.user) response = self.client.get(self.get_url()) table = response.context_data["table"] - self.assertEqual(len(table.rows), 3) + self.assertEqual(len(table.rows), 2) def test_does_not_show_anvil_upload_group_members(self): agreement = factories.DataAffiliateAgreementFactory.create() @@ -7260,6 +7436,237 @@ def test_render_duo_modifiers(self): self.assertContains(response, modifiers[0].abbreviation) self.assertContains(response, modifiers[1].abbreviation) + def test_associated_data_prep_view_user(self): + """View users do not see the associated data prep section""" + user = User.objects.create_user(username="test-view", password="test-view") + user.user_permissions.add( + Permission.objects.get( + codename=AnVILProjectManagerAccess.VIEW_PERMISSION_CODENAME + ) + ) + + obj = factories.CDSAWorkspaceFactory.create() + DataPrepWorkspaceFactory.create(target_workspace=obj.workspace) + self.client.force_login(user) + response = self.client.get(obj.get_absolute_url()) + self.assertNotContains(response, "Associated data prep workspaces") + + def test_associated_data_prep_staff_view_user(self): + """Staff view users do see the associated data prep section.""" + obj = factories.CDSAWorkspaceFactory.create() + DataPrepWorkspaceFactory.create(target_workspace=obj.workspace) + self.client.force_login(self.user) + response = self.client.get(obj.get_absolute_url()) + self.assertContains(response, "Associated data prep workspaces") + + def test_associated_data_prep_workspaces_context_exists(self): + obj = factories.CDSAWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(obj.get_absolute_url()) + self.assertIn("associated_data_prep_workspaces", response.context_data) + self.assertIsInstance( + response.context_data["associated_data_prep_workspaces"], + DataPrepWorkspaceUserTable, + ) + + def test_only_show_one_associated_data_prep_workspace(self): + cdsa_obj = factories.CDSAWorkspaceFactory.create() + dataPrep_obj = DataPrepWorkspaceFactory.create( + target_workspace=cdsa_obj.workspace + ) + self.client.force_login(self.user) + response = self.client.get(cdsa_obj.get_absolute_url()) + self.assertIn("associated_data_prep_workspaces", response.context_data) + self.assertEqual( + len(response.context_data["associated_data_prep_workspaces"].rows), 1 + ) + self.assertIn( + dataPrep_obj.workspace, + response.context_data["associated_data_prep_workspaces"].data, + ) + + def test_show_two_associated_data_prep_workspaces(self): + cdsa_obj = factories.CDSAWorkspaceFactory.create() + dataPrep_obj1 = DataPrepWorkspaceFactory.create( + target_workspace=cdsa_obj.workspace + ) + dataPrep_obj2 = DataPrepWorkspaceFactory.create( + target_workspace=cdsa_obj.workspace + ) + self.client.force_login(self.user) + response = self.client.get(cdsa_obj.get_absolute_url()) + self.assertIn("associated_data_prep_workspaces", response.context_data) + self.assertEqual( + len(response.context_data["associated_data_prep_workspaces"].rows), 2 + ) + self.assertIn( + dataPrep_obj1.workspace, + response.context_data["associated_data_prep_workspaces"].data, + ) + self.assertIn( + dataPrep_obj2.workspace, + response.context_data["associated_data_prep_workspaces"].data, + ) + + def test_context_data_prep_active_with_no_prep_workspace(self): + instance = factories.CDSAWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("data_prep_active", response.context_data) + self.assertFalse(response.context["data_prep_active"]) + + def test_context_data_prep_active_with_one_inactive_prep_workspace(self): + instance = factories.CDSAWorkspaceFactory.create() + DataPrepWorkspaceFactory.create( + target_workspace=instance.workspace, is_active=False + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("data_prep_active", response.context_data) + self.assertFalse(response.context["data_prep_active"]) + + def test_context_data_prep_active_with_one_active_prep_workspace(self): + instance = factories.CDSAWorkspaceFactory.create() + DataPrepWorkspaceFactory.create( + target_workspace=instance.workspace, is_active=True + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("data_prep_active", response.context_data) + self.assertTrue(response.context["data_prep_active"]) + + def test_context_data_prep_active_with_one_active_one_inactive_prep_workspace(self): + instance = factories.CDSAWorkspaceFactory.create() + DataPrepWorkspaceFactory.create( + target_workspace=instance.workspace, is_active=True + ) + DataPrepWorkspaceFactory.create( + target_workspace=instance.workspace, is_active=True + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("data_prep_active", response.context_data) + self.assertTrue(response.context["data_prep_active"]) + + def test_response_context_primary_cdsa(self): + agreement = factories.DataAffiliateAgreementFactory.create( + is_primary=True, + ) + instance = factories.CDSAWorkspaceFactory.create( + study=agreement.study, + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("primary_cdsa", response.context) + self.assertEqual(response.context["primary_cdsa"], agreement) + + def test_response_includes_additional_limitations(self): + """Response includes DataAffiliate additional limitations if they exist.""" + agreement = factories.DataAffiliateAgreementFactory.create( + is_primary=True, + additional_limitations="Test limitations for this data affiliate agreement", + ) + instance = factories.CDSAWorkspaceFactory.create( + study=agreement.study, + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertContains( + response, "Test limitations for this data affiliate agreement" + ) + + def test_response_data_use_limitations(self): + """All data use limitations appear in the response content.""" + instance = factories.CDSAWorkspaceFactory.create( + data_use_permission__definition="Test permission.", + data_use_permission__abbreviation="P", + additional_limitations="Test additional limitations for workspace", + ) + modifier_1 = DataUseModifierFactory.create( + abbreviation="M1", definition="Test modifier 1." + ) + modifier_2 = DataUseModifierFactory.create( + abbreviation="M2", definition="Test modifier 2." + ) + instance.data_use_modifiers.add(modifier_1, modifier_2) + # Create an agreement with data use limitations. + factories.DataAffiliateAgreementFactory.create( + is_primary=True, + study=instance.study, + additional_limitations="Test limitations for this data affiliate agreement", + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertContains(response, "
  • P: Test permission.
  • ") + self.assertContains(response, "
  • M1: Test modifier 1.
  • ") + self.assertContains(response, "
  • M2: Test modifier 2.
  • ") + self.assertContains( + response, + "
    Additional limitations from CDSA
    ", + ) + self.assertContains( + response, + "
  • Test limitations for this data affiliate agreement
  • ", + ) + self.assertContains( + response, + "
    Additional limitations for this consent group
    ", + ) + self.assertContains( + response, + "
  • Test additional limitations for workspace
  • ", + ) + + def test_response_requires_study_review_true(self): + """Response includes DataAffiliate info about study review required if true.""" + agreement = factories.DataAffiliateAgreementFactory.create( + is_primary=True, + requires_study_review=True, + ) + instance = factories.CDSAWorkspaceFactory.create( + study=agreement.study, + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertContains(response, "Study review required") + + def test_response_requires_study_review_false(self): + """Response includes DataAffiliate info about study review required if true.""" + agreement = factories.DataAffiliateAgreementFactory.create( + is_primary=True, + requires_study_review=False, + ) + instance = factories.CDSAWorkspaceFactory.create( + study=agreement.study, + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertNotContains(response, "Study review required") + + def test_response_primary_cdsa(self): + """Response includes note about missing primary cdsa about study review required if true.""" + agreement = factories.DataAffiliateAgreementFactory.create( + is_primary=True, + ) + instance = factories.CDSAWorkspaceFactory.create( + study=agreement.study, + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertContains(response, agreement.get_absolute_url()) + + def test_response_no_primary_cdsa(self): + """Response includes note about missing primary cdsa about study review required if true.""" + instance = factories.CDSAWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertContains( + response, + # """
    Associated CDSA
    mdash;
    """ + """No primary CDSA""" + # """
    Associated CDSA
    """, # noqa: E501 + ) + class CDSAWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): """Tests of the WorkspaceCreate view from ACM with this app's CDSAWorkspace model.""" @@ -7321,7 +7728,6 @@ def test_creates_upload_workspace_without_duos(self): "workspacedata-MAX_NUM_FORMS": 1, "workspacedata-0-study": study.pk, "workspacedata-0-data_use_permission": duo_permission.pk, - "workspacedata-0-data_use_limitations": "test limitations", "workspacedata-0-acknowledgments": "test acknowledgments", "workspacedata-0-requested_by": self.requester.pk, "workspacedata-0-gsr_restricted": False, @@ -7336,7 +7742,6 @@ def test_creates_upload_workspace_without_duos(self): self.assertEqual(new_workspace_data.workspace, new_workspace) self.assertEqual(new_workspace_data.study, study) self.assertEqual(new_workspace_data.data_use_permission, duo_permission) - self.assertEqual(new_workspace_data.data_use_limitations, "test limitations") self.assertEqual(new_workspace_data.acknowledgments, "test acknowledgments") self.assertEqual(new_workspace_data.requested_by, self.requester) @@ -7373,7 +7778,6 @@ def test_creates_upload_workspace_with_duo_modifiers(self): "workspacedata-MIN_NUM_FORMS": 1, "workspacedata-MAX_NUM_FORMS": 1, "workspacedata-0-study": study.pk, - "workspacedata-0-data_use_limitations": "test limitations", "workspacedata-0-acknowledgments": "test acknowledgments", "workspacedata-0-data_use_permission": data_use_permission.pk, "workspacedata-0-data_use_modifiers": [ @@ -7422,7 +7826,6 @@ def test_creates_upload_workspace_with_disease_term(self): "workspacedata-MIN_NUM_FORMS": 1, "workspacedata-MAX_NUM_FORMS": 1, "workspacedata-0-study": study.pk, - "workspacedata-0-data_use_limitations": "test limitations", "workspacedata-0-acknowledgments": "test acknowledgments", "workspacedata-0-data_use_permission": data_use_permission.pk, "workspacedata-0-disease_term": "foo", diff --git a/primed/collaborative_analysis/tables.py b/primed/collaborative_analysis/tables.py index a17c5cb1..dfaeddb7 100644 --- a/primed/collaborative_analysis/tables.py +++ b/primed/collaborative_analysis/tables.py @@ -2,12 +2,10 @@ from anvil_consortium_manager.models import Workspace -class CollaborativeAnalysisWorkspaceStaffTable(tables.Table): +class CollaborativeAnalysisWorkspaceUserTable(tables.Table): """Class to render a table of Workspace objects with CollaborativeAnalysisWorkspace data.""" name = tables.columns.Column(linkify=True) - billing_project = tables.Column(linkify=True) - collaborativeanalysisworkspace__custodian = tables.Column(linkify=True) number_source_workspaces = tables.columns.Column( accessor="pk", verbose_name="Number of source workspaces", @@ -18,7 +16,6 @@ class Meta: model = Workspace fields = ( "name", - "billing_project", "collaborativeanalysisworkspace__custodian", "number_source_workspaces", ) @@ -29,16 +26,12 @@ def render_number_source_workspaces(self, record): return record.collaborativeanalysisworkspace.source_workspaces.count() -class CollaborativeAnalysisWorkspaceUserTable(tables.Table): +class CollaborativeAnalysisWorkspaceStaffTable(CollaborativeAnalysisWorkspaceUserTable): """Class to render a table of Workspace objects with CollaborativeAnalysisWorkspace data.""" name = tables.columns.Column(linkify=True) - billing_project = tables.Column() - number_source_workspaces = tables.columns.Column( - accessor="pk", - verbose_name="Number of source workspaces", - orderable=False, - ) + billing_project = tables.Column(linkify=True) + collaborativeanalysisworkspace__custodian = tables.Column(linkify=True) class Meta: model = Workspace @@ -49,7 +42,3 @@ class Meta: "number_source_workspaces", ) order_by = ("name",) - - def render_number_source_workspaces(self, record): - """Render the number of source workspaces.""" - return record.collaborativeanalysisworkspace.source_workspaces.count() diff --git a/primed/dbgap/adapters.py b/primed/dbgap/adapters.py index 5c567a5b..a2ed57a9 100644 --- a/primed/dbgap/adapters.py +++ b/primed/dbgap/adapters.py @@ -1,5 +1,8 @@ from anvil_consortium_manager.adapters.workspace import BaseWorkspaceAdapter from anvil_consortium_manager.forms import WorkspaceForm +from anvil_consortium_manager.models import Workspace + +from primed.miscellaneous_workspaces.tables import DataPrepWorkspaceUserTable from . import forms, models, tables @@ -16,3 +19,16 @@ class dbGaPWorkspaceAdapter(BaseWorkspaceAdapter): workspace_data_model = models.dbGaPWorkspace workspace_data_form_class = forms.dbGaPWorkspaceForm workspace_detail_template_name = "dbgap/dbgapworkspace_detail.html" + + def get_extra_detail_context_data(self, workspace, request): + extra_context = {} + associated_data_prep = Workspace.objects.filter( + dataprepworkspace__target_workspace=workspace + ) + extra_context["associated_data_prep_workspaces"] = DataPrepWorkspaceUserTable( + associated_data_prep + ) + extra_context["data_prep_active"] = associated_data_prep.filter( + dataprepworkspace__is_active=True + ).exists() + return extra_context diff --git a/primed/dbgap/tables.py b/primed/dbgap/tables.py index 08443706..fa22c5b1 100644 --- a/primed/dbgap/tables.py +++ b/primed/dbgap/tables.py @@ -74,11 +74,10 @@ def render_dbgap_phs(self, value): return "phs{0:06d}".format(value) -class dbGaPWorkspaceStaffTable(tables.Table): +class dbGaPWorkspaceUserTable(tables.Table): """Class to render a table of Workspace objects with dbGaPWorkspace workspace data.""" name = tables.columns.Column(linkify=True) - billing_project = tables.Column(linkify=True) dbgap_accession = dbGaPAccessionColumn( accessor="dbgapworkspace__get_dbgap_accession", dbgap_link_accessor="dbgapworkspace__get_dbgap_link", @@ -91,11 +90,6 @@ class dbGaPWorkspaceStaffTable(tables.Table): dbgapworkspace__dbgap_consent_abbreviation = tables.columns.Column( verbose_name="Consent" ) - number_approved_dars = tables.columns.Column( - accessor="pk", - verbose_name="Approved DARs", - orderable=False, - ) dbgapworkspace__gsr_restricted = BooleanIconColumn( orderable=False, true_icon="dash-circle-fill", true_color="#ffc107" ) @@ -105,43 +99,23 @@ class Meta: model = Workspace fields = ( "name", - "billing_project", "dbgap_accession", "dbgapworkspace__dbgap_consent_abbreviation", - "number_approved_dars", "dbgapworkspace__gsr_restricted", "is_shared", ) order_by = ("name",) - def render_number_approved_dars(self, record): - n = ( - record.dbgapworkspace.get_data_access_requests(most_recent=True) - .filter(dbgap_current_status=models.dbGaPDataAccessRequest.APPROVED) - .count() - ) - return n - -class dbGaPWorkspaceUserTable(tables.Table): +class dbGaPWorkspaceStaffTable(dbGaPWorkspaceUserTable): """Class to render a table of Workspace objects with dbGaPWorkspace workspace data.""" - name = tables.columns.Column(linkify=True) - billing_project = tables.Column() - dbgap_accession = dbGaPAccessionColumn( - accessor="dbgapworkspace__get_dbgap_accession", - dbgap_link_accessor="dbgapworkspace__get_dbgap_link", - order_by=( - "dbgapworkspace__dbgap_study_accession__dbgap_phs", - "dbgapworkspace__dbgap_version", - "dbgapworkspace__dbgap_participant_set", - ), - ) - dbgapworkspace__dbgap_consent_abbreviation = tables.columns.Column( - verbose_name="Consent" + billing_project = tables.Column(linkify=True) + number_approved_dars = tables.columns.Column( + accessor="pk", + verbose_name="Approved DARs", + orderable=False, ) - dbgapworkspace__gsr_restricted = BooleanIconColumn(orderable=False) - is_shared = WorkspaceSharedWithConsortiumColumn() class Meta: model = Workspace @@ -150,11 +124,20 @@ class Meta: "billing_project", "dbgap_accession", "dbgapworkspace__dbgap_consent_abbreviation", + "number_approved_dars", "dbgapworkspace__gsr_restricted", "is_shared", ) order_by = ("name",) + def render_number_approved_dars(self, record): + n = ( + record.dbgapworkspace.get_data_access_requests(most_recent=True) + .filter(dbgap_current_status=models.dbGaPDataAccessRequest.APPROVED) + .count() + ) + return n + class dbGaPApplicationTable(tables.Table): """Class to render a table of dbGaPApplication objects.""" diff --git a/primed/dbgap/tests/test_views.py b/primed/dbgap/tests/test_views.py index e345cbaf..a2ebf614 100644 --- a/primed/dbgap/tests/test_views.py +++ b/primed/dbgap/tests/test_views.py @@ -32,6 +32,8 @@ from freezegun import freeze_time from primed.duo.tests.factories import DataUseModifierFactory, DataUsePermissionFactory +from primed.miscellaneous_workspaces.tables import DataPrepWorkspaceUserTable +from primed.miscellaneous_workspaces.tests.factories import DataPrepWorkspaceFactory from primed.primed_anvil.tests.factories import ( # DataUseModifierFactory,; DataUsePermissionFactory, StudyFactory, ) @@ -951,6 +953,118 @@ def test_links_audit_access_view_permission(self): ), ) + def test_associated_data_prep_view_user(self): + """View users do not see the associated data prep section""" + user = User.objects.create_user(username="test-view", password="test-view") + user.user_permissions.add( + Permission.objects.get( + codename=AnVILProjectManagerAccess.VIEW_PERMISSION_CODENAME + ) + ) + + obj = factories.dbGaPWorkspaceFactory.create() + DataPrepWorkspaceFactory.create(target_workspace=obj.workspace) + self.client.force_login(user) + response = self.client.get(obj.get_absolute_url()) + self.assertNotContains(response, "Associated data prep workspaces") + + def test_associated_data_prep_staff_view_user(self): + """Staff view users do see the associated data prep section.""" + obj = factories.dbGaPWorkspaceFactory.create() + DataPrepWorkspaceFactory.create(target_workspace=obj.workspace) + self.client.force_login(self.user) + response = self.client.get(obj.get_absolute_url()) + self.assertContains(response, "Associated data prep workspaces") + + def test_associated_data_prep_workspaces_context_exists(self): + obj = factories.dbGaPWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(obj.get_absolute_url()) + self.assertIn("associated_data_prep_workspaces", response.context_data) + self.assertIsInstance( + response.context_data["associated_data_prep_workspaces"], + DataPrepWorkspaceUserTable, + ) + + def test_only_show_one_associated_data_prep_workspace(self): + dbGaP_obj = factories.dbGaPWorkspaceFactory.create() + dataPrep_obj = DataPrepWorkspaceFactory.create( + target_workspace=dbGaP_obj.workspace + ) + self.client.force_login(self.user) + response = self.client.get(dbGaP_obj.get_absolute_url()) + self.assertIn("associated_data_prep_workspaces", response.context_data) + self.assertEqual( + len(response.context_data["associated_data_prep_workspaces"].rows), 1 + ) + self.assertIn( + dataPrep_obj.workspace, + response.context_data["associated_data_prep_workspaces"].data, + ) + + def test_show_two_associated_data_prep_workspaces(self): + dbGaP_obj = factories.dbGaPWorkspaceFactory.create() + dataPrep_obj1 = DataPrepWorkspaceFactory.create( + target_workspace=dbGaP_obj.workspace + ) + dataPrep_obj2 = DataPrepWorkspaceFactory.create( + target_workspace=dbGaP_obj.workspace + ) + self.client.force_login(self.user) + response = self.client.get(dbGaP_obj.get_absolute_url()) + self.assertIn("associated_data_prep_workspaces", response.context_data) + self.assertEqual( + len(response.context_data["associated_data_prep_workspaces"].rows), 2 + ) + self.assertIn( + dataPrep_obj1.workspace, + response.context_data["associated_data_prep_workspaces"].data, + ) + self.assertIn( + dataPrep_obj2.workspace, + response.context_data["associated_data_prep_workspaces"].data, + ) + + def test_context_data_prep_active_with_no_prep_workspace(self): + instance = factories.dbGaPWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("data_prep_active", response.context_data) + self.assertFalse(response.context["data_prep_active"]) + + def test_context_data_prep_active_with_one_inactive_prep_workspace(self): + instance = factories.dbGaPWorkspaceFactory.create() + DataPrepWorkspaceFactory.create( + target_workspace=instance.workspace, is_active=False + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("data_prep_active", response.context_data) + self.assertFalse(response.context["data_prep_active"]) + + def test_context_data_prep_active_with_one_active_prep_workspace(self): + instance = factories.dbGaPWorkspaceFactory.create() + DataPrepWorkspaceFactory.create( + target_workspace=instance.workspace, is_active=True + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("data_prep_active", response.context_data) + self.assertTrue(response.context["data_prep_active"]) + + def test_context_data_prep_active_with_one_active_one_inactive_prep_workspace(self): + instance = factories.dbGaPWorkspaceFactory.create() + DataPrepWorkspaceFactory.create( + target_workspace=instance.workspace, is_active=True + ) + DataPrepWorkspaceFactory.create( + target_workspace=instance.workspace, is_active=True + ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("data_prep_active", response.context_data) + self.assertTrue(response.context["data_prep_active"]) + class dbGaPWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): """Tests of the WorkspaceCreate view from ACM with this app's dbGaPWorkspace model.""" diff --git a/primed/duo/models.py b/primed/duo/models.py index 8a79e9b8..7c6efb25 100644 --- a/primed/duo/models.py +++ b/primed/duo/models.py @@ -1,3 +1,5 @@ +import re + from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse @@ -41,6 +43,12 @@ def get_ols_url(self): self.identifier.replace("DUO:", "DUO_") ) + def get_short_definition(self): + text = re.sub(r"This .+? indicates that ", "", self.definition) + # Only capitalize the first letter - keep the remaining text as is. + text = text[0].capitalize() + text[1:] + return text + class DataUsePermission(DUOFields, TreeNode): """A model to track the allowed main consent codes using GA4GH DUO codes.""" diff --git a/primed/duo/tests/test_models.py b/primed/duo/tests/test_models.py index f216087d..baeb1416 100644 --- a/primed/duo/tests/test_models.py +++ b/primed/duo/tests/test_models.py @@ -87,6 +87,24 @@ def test_unique_identifier(self): with self.assertRaises(IntegrityError): instance2.save() + def test_get_short_definition(self): + instance = factories.DataUsePermissionFactory.create( + definition="Test definition" + ) + self.assertEqual(instance.get_short_definition(), "Test definition") + + def test_get_short_definition_re_sub(self): + instance = factories.DataUsePermissionFactory.create( + definition="This XXX indicates that everything is fine." + ) + self.assertEqual(instance.get_short_definition(), "Everything is fine.") + + def test_get_short_definition_capitalization(self): + instance = factories.DataUsePermissionFactory.create( + definition="Test definition XyXy" + ) + self.assertEqual(instance.get_short_definition(), "Test definition XyXy") + class DataUseModifierTest(TestCase): """Tests for the DataUseModifier model.""" @@ -157,6 +175,16 @@ def test_unique_identifier(self): with self.assertRaises(IntegrityError): instance2.save() + def test_get_short_definition(self): + instance = factories.DataUseModifierFactory.create(definition="Test definition") + self.assertEqual(instance.get_short_definition(), "Test definition") + + def test_get_short_definition_re_sub(self): + instance = factories.DataUseModifierFactory.create( + definition="This XXX indicates that use is allowed." + ) + self.assertEqual(instance.get_short_definition(), "Use is allowed.") + class DataUseOntologyTestCase(TestCase): """Tests for the DataUseOntology abstract model.""" diff --git a/primed/duo/tests/test_views.py b/primed/duo/tests/test_views.py index 96f2a5af..e22ca408 100644 --- a/primed/duo/tests/test_views.py +++ b/primed/duo/tests/test_views.py @@ -23,7 +23,7 @@ def setUp(self): self.user = UserFactory.create(username="test", password="test") self.user.user_permissions.add( Permission.objects.get( - codename=AnVILProjectManagerAccess.STAFF_VIEW_PERMISSION_CODENAME + codename=AnVILProjectManagerAccess.VIEW_PERMISSION_CODENAME ) ) @@ -180,7 +180,7 @@ def setUp(self): self.user = UserFactory.create(username="test", password="test") self.user.user_permissions.add( Permission.objects.get( - codename=AnVILProjectManagerAccess.STAFF_VIEW_PERMISSION_CODENAME + codename=AnVILProjectManagerAccess.VIEW_PERMISSION_CODENAME ) ) diff --git a/primed/duo/views.py b/primed/duo/views.py index f27c1e25..308cc3e1 100644 --- a/primed/duo/views.py +++ b/primed/duo/views.py @@ -1,14 +1,11 @@ -from anvil_consortium_manager.auth import ( - AnVILConsortiumManagerStaffViewRequired, - AnVILConsortiumManagerViewRequired, -) +from anvil_consortium_manager.auth import AnVILConsortiumManagerViewRequired from django.http import Http404 from django.views.generic import DetailView, ListView from . import models -class DataUsePermissionList(AnVILConsortiumManagerStaffViewRequired, ListView): +class DataUsePermissionList(AnVILConsortiumManagerViewRequired, ListView): model = models.DataUsePermission @@ -41,7 +38,7 @@ def get_context_data(self, **kwargs): return context -class DataUseModifierList(AnVILConsortiumManagerStaffViewRequired, ListView): +class DataUseModifierList(AnVILConsortiumManagerViewRequired, ListView): model = models.DataUseModifier diff --git a/primed/miscellaneous_workspaces/adapters.py b/primed/miscellaneous_workspaces/adapters.py index 24262017..17bdfb9b 100644 --- a/primed/miscellaneous_workspaces/adapters.py +++ b/primed/miscellaneous_workspaces/adapters.py @@ -93,8 +93,8 @@ class DataPrepWorkspaceAdapter(BaseWorkspaceAdapter): type = "data_prep" name = "Data prep workspace" description = "Workspaces used to prepare data for sharing or update data that is already shared" - list_table_class_staff_view = tables.DataPrepWorkspaceTable - list_table_class_view = tables.DataPrepWorkspaceTable + list_table_class_staff_view = tables.DataPrepWorkspaceStaffTable + list_table_class_view = tables.DataPrepWorkspaceUserTable workspace_form_class = WorkspaceForm workspace_data_model = models.DataPrepWorkspace workspace_data_form_class = forms.DataPrepWorkspaceForm diff --git a/primed/miscellaneous_workspaces/forms.py b/primed/miscellaneous_workspaces/forms.py index 6a0825ac..31eb2e46 100644 --- a/primed/miscellaneous_workspaces/forms.py +++ b/primed/miscellaneous_workspaces/forms.py @@ -1,6 +1,5 @@ """Forms for the `workspaces` app.""" -from anvil_consortium_manager.adapters.workspace import workspace_adapter_registry from anvil_consortium_manager.forms import Bootstrap5MediaFormMixin from dal import autocomplete from django import forms @@ -62,23 +61,11 @@ class Meta: class TemplateWorkspaceForm(forms.ModelForm): """Form for a TemplateWorkspace object.""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Set the intended_workspace_type options, excluding "template". - workspace_type_choices = [ - (key, value) - for key, value in workspace_adapter_registry.get_registered_names().items() - if key != "template" - ] - self.fields["intended_workspace_type"] = forms.ChoiceField( - choices=[("", "---------")] + workspace_type_choices - ) - class Meta: model = models.TemplateWorkspace fields = ( "workspace", - "intended_workspace_type", + "intended_usage", ) diff --git a/primed/miscellaneous_workspaces/migrations/0011_alter_templateworkspace_remove_intended_workspace_type_add_intended_usage.py b/primed/miscellaneous_workspaces/migrations/0011_alter_templateworkspace_remove_intended_workspace_type_add_intended_usage.py new file mode 100644 index 00000000..f632dc22 --- /dev/null +++ b/primed/miscellaneous_workspaces/migrations/0011_alter_templateworkspace_remove_intended_workspace_type_add_intended_usage.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.11 on 2024-03-22 22:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("miscellaneous_workspaces", "0010_update_workspace_type_field"), + ] + + operations = [ + migrations.RemoveField( + model_name="historicaltemplateworkspace", + name="intended_workspace_type", + ), + migrations.RemoveField( + model_name="templateworkspace", + name="intended_workspace_type", + ), + migrations.AddField( + model_name="historicaltemplateworkspace", + name="intended_usage", + field=models.TextField(default=""), + preserve_default=False, + ), + migrations.AddField( + model_name="templateworkspace", + name="intended_usage", + field=models.TextField(default=""), + preserve_default=False, + ), + ] diff --git a/primed/miscellaneous_workspaces/models.py b/primed/miscellaneous_workspaces/models.py index 210bae3b..d702122d 100644 --- a/primed/miscellaneous_workspaces/models.py +++ b/primed/miscellaneous_workspaces/models.py @@ -1,6 +1,5 @@ """Model definitions for the `miscellaneous_workspaces` app.""" -from anvil_consortium_manager.adapters.workspace import workspace_adapter_registry from anvil_consortium_manager.models import BaseWorkspaceData, Workspace from django.core.exceptions import ValidationError from django.db import models @@ -24,28 +23,7 @@ class ResourceWorkspace(RequesterModel, TimeStampedModel, BaseWorkspaceData): class TemplateWorkspace(TimeStampedModel, BaseWorkspaceData): """A model to track template workspaces.""" - intended_workspace_type = models.CharField(max_length=63) - - def clean(self): - """Custom cleaning checks. - - - Verify that intended_workspace_type is one of the registered types, excluding this type.""" - registered_workspace_types = workspace_adapter_registry.get_registered_names() - if self.intended_workspace_type: - if self.intended_workspace_type not in registered_workspace_types: - raise ValidationError( - { - "intended_workspace_type": "intended_workspace_type must be one of the registered types." - } - ) - # We cannot import the adapter here because it would lead to a circular import, but we don't want - # to create a template workspace for TemplateWorkspaces. So check if the type is not "template". - elif self.intended_workspace_type == "template": - raise ValidationError( - { - "intended_workspace_type": "intended_workspace_type may not be 'template'." - } - ) + intended_usage = models.TextField() class OpenAccessWorkspace(RequesterModel, TimeStampedModel, BaseWorkspaceData): diff --git a/primed/miscellaneous_workspaces/tables.py b/primed/miscellaneous_workspaces/tables.py index fd1132df..014f746c 100644 --- a/primed/miscellaneous_workspaces/tables.py +++ b/primed/miscellaneous_workspaces/tables.py @@ -9,30 +9,29 @@ ) -class OpenAccessWorkspaceStaffTable(tables.Table): +class OpenAccessWorkspaceUserTable(tables.Table): """Class to render a table of Workspace objects with OpenAccessWorkspace workspace data.""" name = tables.columns.Column(linkify=True) - billing_project = tables.Column(linkify=True) is_shared = WorkspaceSharedWithConsortiumColumn() + openaccessworkspace__studies = tables.ManyToManyColumn( + linkify_item=True, + ) class Meta: model = Workspace fields = ( "name", - "billing_project", "openaccessworkspace__studies", "is_shared", ) order_by = ("name",) -class OpenAccessWorkspaceUserTable(tables.Table): +class OpenAccessWorkspaceStaffTable(OpenAccessWorkspaceUserTable): """Class to render a table of Workspace objects with OpenAccessWorkspace workspace data.""" - name = tables.columns.Column(linkify=True) - billing_project = tables.Column() - is_shared = WorkspaceSharedWithConsortiumColumn() + billing_project = tables.Column(linkify=True) class Meta: model = Workspace @@ -45,11 +44,10 @@ class Meta: order_by = ("name",) -class DataPrepWorkspaceTable(tables.Table): +class DataPrepWorkspaceUserTable(tables.Table): """Class to render a table of Workspace objects with DataPrepWorkspace workspace data.""" name = tables.columns.Column(linkify=True) - # TODO: Figure out why this is not showing up dataprepworkspace__target_workspace__name = tables.columns.Column( linkify=True, verbose_name="Target workspace" ) @@ -65,3 +63,19 @@ class Meta: "dataprepworkspace__is_active", ) order_by = ("name",) + + +class DataPrepWorkspaceStaffTable(DataPrepWorkspaceUserTable): + """Class to render a table of Workspace objects with DataPrepWorkspace workspace data.""" + + billing_project = tables.columns.Column(linkify=True) + + class Meta: + model = Workspace + fields = ( + "name", + "billing_project", + "dataprepworkspace__target_workspace__name", + "dataprepworkspace__is_active", + ) + order_by = ("name",) diff --git a/primed/miscellaneous_workspaces/tests/factories.py b/primed/miscellaneous_workspaces/tests/factories.py index c562c36a..a68d9e54 100644 --- a/primed/miscellaneous_workspaces/tests/factories.py +++ b/primed/miscellaneous_workspaces/tests/factories.py @@ -1,8 +1,5 @@ -import random - -from anvil_consortium_manager.adapters.workspace import workspace_adapter_registry from anvil_consortium_manager.tests.factories import WorkspaceFactory -from factory import Faker, SubFactory, lazy_attribute +from factory import Faker, SubFactory from factory.django import DjangoModelFactory from primed.users.tests.factories import UserFactory @@ -53,19 +50,11 @@ class TemplateWorkspaceFactory(DjangoModelFactory): WorkspaceFactory, workspace_type=adapters.TemplateWorkspaceAdapter().get_type(), ) + intended_usage = Faker("sentence") class Meta: model = models.TemplateWorkspace - @lazy_attribute - def intended_workspace_type(self): - """Select a random registered workspace_type other than template.""" - registered_types = list( - workspace_adapter_registry.get_registered_adapters().keys() - ) - registered_types.remove(adapters.TemplateWorkspaceAdapter().get_type()) - return random.choice(registered_types) - class OpenAccessWorkspaceFactory(DjangoModelFactory): """A factory for the OpenAccessWorkspace model.""" diff --git a/primed/miscellaneous_workspaces/tests/test_forms.py b/primed/miscellaneous_workspaces/tests/test_forms.py index 40b20431..b6dd125a 100644 --- a/primed/miscellaneous_workspaces/tests/test_forms.py +++ b/primed/miscellaneous_workspaces/tests/test_forms.py @@ -152,7 +152,7 @@ def test_valid(self): """Form is valid with necessary input.""" form_data = { "workspace": self.workspace, - "intended_workspace_type": "resource", + "intended_usage": "Test usage", } form = self.form_class(data=form_data) self.assertTrue(form.is_valid()) @@ -160,7 +160,7 @@ def test_valid(self): def test_invalid_missing_workspace(self): """Form is invalid when missing workspace.""" form_data = { - "intended_workspace_type": "resource", + "intended_usage": "Test usage", } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) @@ -169,7 +169,7 @@ def test_invalid_missing_workspace(self): self.assertEqual(len(form.errors["workspace"]), 1) self.assertIn("required", form.errors["workspace"][0]) - def test_invalid_missing_intended_workspace_type(self): + def test_invalid_missing_intended_usage(self): """Form is invalid if intended_workspace_type is missing.""" form_data = { "workspace": self.workspace, @@ -177,62 +177,22 @@ def test_invalid_missing_intended_workspace_type(self): form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) self.assertEqual(len(form.errors), 1) - self.assertIn("intended_workspace_type", form.errors) - self.assertEqual(len(form.errors["intended_workspace_type"]), 1) - self.assertIn("required", form.errors["intended_workspace_type"][0]) + self.assertIn("intended_usage", form.errors) + self.assertEqual(len(form.errors["intended_usage"]), 1) + self.assertIn("required", form.errors["intended_usage"][0]) - def test_invalid_blank_intended_workspace_type(self): + def test_invalid_blank_intended_usage(self): """Form is invalid if intended_workspace_type is missing.""" form_data = { "workspace": self.workspace, - "intended_workspace_type": "", + "intended_usage": "", } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) self.assertEqual(len(form.errors), 1) - self.assertIn("intended_workspace_type", form.errors) - self.assertEqual(len(form.errors["intended_workspace_type"]), 1) - self.assertIn("required", form.errors["intended_workspace_type"][0]) - - def test_invalid_intended_workspace_type_template(self): - """Form is invalid if intended_workspace_type is "template".""" - form_data = { - "workspace": self.workspace, - "intended_workspace_type": "template", - } - form = self.form_class(data=form_data) - self.assertFalse(form.is_valid()) - self.assertEqual(len(form.errors), 1) - self.assertIn("intended_workspace_type", form.errors) - self.assertEqual(len(form.errors["intended_workspace_type"]), 1) - self.assertIn("template", form.errors["intended_workspace_type"][0]) - - def test_invalid_workspace_type_unregistered_type(self): - """Form is invalid if intended_workspace_type is not a registered type.""" - form_data = { - "workspace": self.workspace, - "intended_workspace_type": "foo", - } - form = self.form_class(data=form_data) - self.assertFalse(form.is_valid()) - self.assertEqual(len(form.errors), 1) - self.assertIn("intended_workspace_type", form.errors) - self.assertEqual(len(form.errors["intended_workspace_type"]), 1) - self.assertIn("valid choice", form.errors["intended_workspace_type"][0]) - - def test_form_all_registered_adapters(self): - """Form is invalid if intended_workspace_type is not a registered type.""" - workspace_types = list(workspace_adapter_registry.get_registered_names().keys()) - for workspace_type in workspace_types: - if workspace_type == "template": - pass - else: - form_data = { - "workspace": self.workspace, - "intended_workspace_type": workspace_type, - } - form = self.form_class(data=form_data) - self.assertTrue(form.is_valid()) + self.assertIn("intended_usage", form.errors) + self.assertEqual(len(form.errors["intended_usage"]), 1) + self.assertIn("required", form.errors["intended_usage"][0]) class OpenAccessWorkspaceFormTest(TestCase): diff --git a/primed/miscellaneous_workspaces/tests/test_models.py b/primed/miscellaneous_workspaces/tests/test_models.py index 7b38836c..c8222656 100644 --- a/primed/miscellaneous_workspaces/tests/test_models.py +++ b/primed/miscellaneous_workspaces/tests/test_models.py @@ -7,7 +7,7 @@ from primed.primed_anvil.tests.factories import AvailableDataFactory, StudyFactory from primed.users.tests.factories import UserFactory -from .. import adapters, models +from .. import models from . import factories @@ -79,7 +79,7 @@ class TemplateWorkspaceTest(TestCase): def test_model_saving(self): """Creation using the model constructor and .save() works.""" workspace = WorkspaceFactory.create() - instance = models.TemplateWorkspace(workspace=workspace) + instance = models.TemplateWorkspace(workspace=workspace, intended_usage="Test") instance.save() self.assertIsInstance(instance, models.TemplateWorkspace) @@ -91,81 +91,6 @@ def test_str_method(self): self.assertIsInstance(str(instance), str) self.assertEqual(str(instance), "test-bp/test-ws") - def test_clean_missing_intended_workspace_type_missing(self): - workspace = WorkspaceFactory.create( - billing_project__name="test-bp", name="test-ws" - ) - instance = models.TemplateWorkspace(workspace=workspace) - with self.assertRaises(ValidationError) as e: - instance.full_clean() - self.assertEqual(len(e.exception.message_dict), 1) - self.assertIn("intended_workspace_type", e.exception.message_dict) - self.assertEqual(len(e.exception.message_dict["intended_workspace_type"]), 1) - self.assertIn( - "cannot be blank", e.exception.message_dict["intended_workspace_type"][0] - ) - - def test_clean_intended_workspace_type_blank(self): - workspace = WorkspaceFactory.create( - billing_project__name="test-bp", name="test-ws" - ) - instance = factories.TemplateWorkspaceFactory.build( - workspace=workspace, - intended_workspace_type="", - ) - with self.assertRaises(ValidationError) as e: - instance.full_clean() - self.assertEqual(len(e.exception.message_dict), 1) - self.assertIn("intended_workspace_type", e.exception.message_dict) - self.assertEqual(len(e.exception.message_dict["intended_workspace_type"]), 1) - self.assertIn( - "cannot be blank", e.exception.message_dict["intended_workspace_type"][0] - ) - - def test_clean_intended_workspace_type_with_registered_adapter(self): - """No ValidationError is raised if intended_workspace_type is a registered type.""" - workspace = WorkspaceFactory.create( - billing_project__name="test-bp", name="test-ws" - ) - instance = factories.TemplateWorkspaceFactory.build(workspace=workspace) - instance.full_clean() - - def test_clean_intended_workspace_type_with_unregistered_adapter(self): - """ValidationError is raised if intended_workspace_type is not a registered type.""" - workspace = WorkspaceFactory.create( - billing_project__name="test-bp", name="test-ws" - ) - instance = factories.TemplateWorkspaceFactory.build( - workspace=workspace, intended_workspace_type="foo" - ) - with self.assertRaises(ValidationError) as e: - instance.full_clean() - self.assertEqual(len(e.exception.message_dict), 1) - self.assertIn("intended_workspace_type", e.exception.message_dict) - self.assertEqual(len(e.exception.message_dict["intended_workspace_type"]), 1) - self.assertIn( - "registered types", e.exception.message_dict["intended_workspace_type"][0] - ) - - def test_clean_intended_workspace_type_template(self): - """ValidationError is raised if intended_workspace_type is set to "template".""" - workspace = WorkspaceFactory.create( - billing_project__name="test-bp", name="test-ws" - ) - template_workspace_type = adapters.TemplateWorkspaceAdapter().get_type() - instance = factories.TemplateWorkspaceFactory.build( - workspace=workspace, intended_workspace_type=template_workspace_type - ) - with self.assertRaises(ValidationError) as e: - instance.full_clean() - self.assertEqual(len(e.exception.message_dict), 1) - self.assertIn("intended_workspace_type", e.exception.message_dict) - self.assertEqual(len(e.exception.message_dict["intended_workspace_type"]), 1) - self.assertIn( - template_workspace_type, - e.exception.message_dict["intended_workspace_type"][0], - ) - class OpenAccessWorkspaceTest(TestCase): """Tests for the OpenAccessWorkspace model.""" diff --git a/primed/miscellaneous_workspaces/tests/test_tables.py b/primed/miscellaneous_workspaces/tests/test_tables.py index de536164..d6252a70 100644 --- a/primed/miscellaneous_workspaces/tests/test_tables.py +++ b/primed/miscellaneous_workspaces/tests/test_tables.py @@ -49,11 +49,32 @@ def test_row_count_with_two_objects(self): self.assertEqual(len(table.rows), 2) -class DataPrepWorkspaceTableTest(TestCase): - """Tests for the DataPrepWorkspaceTable table.""" +class DataPrepWorkspaceUserTableTest(TestCase): + """Tests for the DataPrepWorkspaceUserTable table.""" model_factory = factories.DataPrepWorkspaceFactory - table_class = tables.DataPrepWorkspaceTable + table_class = tables.DataPrepWorkspaceUserTable + + def test_row_count_with_no_objects(self): + table = self.table_class(Workspace.objects.filter(workspace_type="data_prep")) + self.assertEqual(len(table.rows), 0) + + def test_row_count_with_one_object(self): + self.model_factory.create() + table = self.table_class(Workspace.objects.filter(workspace_type="data_prep")) + self.assertEqual(len(table.rows), 1) + + def test_row_count_with_two_objects(self): + self.model_factory.create_batch(2) + table = self.table_class(Workspace.objects.filter(workspace_type="data_prep")) + self.assertEqual(len(table.rows), 2) + + +class DataPrepWorkspaceStaffTableTest(TestCase): + """Tests for the DataPrepWorkspaceUserTable table.""" + + model_factory = factories.DataPrepWorkspaceFactory + table_class = tables.DataPrepWorkspaceStaffTable def test_row_count_with_no_objects(self): table = self.table_class(Workspace.objects.filter(workspace_type="data_prep")) diff --git a/primed/miscellaneous_workspaces/tests/test_views.py b/primed/miscellaneous_workspaces/tests/test_views.py index ed75975b..6e8d5916 100644 --- a/primed/miscellaneous_workspaces/tests/test_views.py +++ b/primed/miscellaneous_workspaces/tests/test_views.py @@ -734,7 +734,7 @@ def test_creates_workspace(self): "workspacedata-INITIAL_FORMS": 0, "workspacedata-MIN_NUM_FORMS": 1, "workspacedata-MAX_NUM_FORMS": 1, - "workspacedata-0-intended_workspace_type": "resource", + "workspacedata-0-intended_usage": "Test usage", }, ) self.assertEqual(response.status_code, 302) @@ -744,7 +744,7 @@ def test_creates_workspace(self): self.assertEqual(models.TemplateWorkspace.objects.count(), 1) new_workspace_data = models.TemplateWorkspace.objects.latest("pk") self.assertEqual(new_workspace_data.workspace, new_workspace) - self.assertEqual(new_workspace_data.intended_workspace_type, "resource") + self.assertEqual(new_workspace_data.intended_usage, "Test usage") class TemplateWorkspaceImportTest(AnVILAPIMockTestMixin, TestCase): @@ -860,7 +860,7 @@ def test_creates_workspace(self): "workspacedata-INITIAL_FORMS": 0, "workspacedata-MIN_NUM_FORMS": 1, "workspacedata-MAX_NUM_FORMS": 1, - "workspacedata-0-intended_workspace_type": "resource", + "workspacedata-0-intended_usage": "Test usage", }, ) self.assertEqual(response.status_code, 302) @@ -870,7 +870,7 @@ def test_creates_workspace(self): self.assertEqual(models.TemplateWorkspace.objects.count(), 1) new_workspace_data = models.TemplateWorkspace.objects.latest("pk") self.assertEqual(new_workspace_data.workspace, new_workspace) - self.assertEqual(new_workspace_data.intended_workspace_type, "resource") + self.assertEqual(new_workspace_data.intended_usage, "Test usage") class OpenAccessWorkspaceDetailTest(TestCase): diff --git a/primed/primed_anvil/admin.py b/primed/primed_anvil/admin.py index ce6d19ee..a4265dad 100644 --- a/primed/primed_anvil/admin.py +++ b/primed/primed_anvil/admin.py @@ -26,18 +26,9 @@ class StudyAdmin(SimpleHistoryAdmin): class StudySiteAdmin(admin.ModelAdmin): """Admin class for the `Study` model.""" - list_display = ( - "short_name", - "full_name", - ) - search_fields = ( - "short_name", - "full_name", - ) - sortable_by = ( - "short_name", - "full_name", - ) + list_display = ("short_name", "full_name", "drupal_node_id") + search_fields = ("short_name", "full_name", "drupal_node_id") + sortable_by = ("short_name", "full_name", "drupal_node_id") @admin.register(models.AvailableData) diff --git a/primed/primed_anvil/helpers.py b/primed/primed_anvil/helpers.py index 88460087..39193ade 100644 --- a/primed/primed_anvil/helpers.py +++ b/primed/primed_anvil/helpers.py @@ -2,6 +2,7 @@ from anvil_consortium_manager.models import WorkspaceGroupSharing from django.db.models import Exists, F, OuterRef, Value +from primed.cdsa.models import CDSAWorkspace from primed.dbgap.models import dbGaPWorkspace from primed.miscellaneous_workspaces.models import OpenAccessWorkspace @@ -34,7 +35,7 @@ def get_summary_table_data(): "access_mechanism", # Rename columns to have the same names. workspace_name=F("workspace__name"), - study=F("dbgap_study_accession__studies__short_name"), + study_name=F("dbgap_study_accession__studies__short_name"), data=F("available_data__name"), ) df_dbgap = pd.DataFrame.from_dict(dbgap) @@ -48,11 +49,25 @@ def get_summary_table_data(): "access_mechanism", # Rename columns to have the same names. workspace_name=F("workspace__name"), - study=F("studies__short_name"), + study_name=F("studies__short_name"), data=F("available_data__name"), ) df_open = pd.DataFrame.from_dict(open) + # Query for CDSAWorkspaces. + cdsa = CDSAWorkspace.objects.annotate( + access_mechanism=Value("CDSA"), + is_shared=Exists(shared), + ).values( + "is_shared", + "access_mechanism", + # Rename columns to have the same names. + workspace_name=F("workspace__name"), + study_name=F("study__short_name"), + data=F("available_data__name"), + ) + df_cdsa = pd.DataFrame.from_dict(cdsa) + # This union may not work with MySQL < 10.3: # https://code.djangoproject.com/ticket/31445 # qs = dbgap.union(open) @@ -65,20 +80,20 @@ def get_summary_table_data(): # df = pd.DataFrame.from_dict(qs) # Instead combine in pandas. - df = pd.concat([df_dbgap, df_open]) + df = pd.concat([df_cdsa, df_dbgap, df_open]) # If there are no workspaces, return an empty list. if df.empty: return [] # Sort by specific columns - df = df.sort_values(by=["study", "access_mechanism"]) + df = df.sort_values(by=["study_name", "access_mechanism"]) # Concatenate multiple studies into a single comma-delimited string. df = ( df.groupby( ["workspace_name", "data", "is_shared", "access_mechanism"], dropna=False, - )["study"] + )["study_name"] .apply(lambda x: ", ".join(x)) .reset_index() .drop("workspace_name", axis=1) @@ -90,7 +105,7 @@ def get_summary_table_data(): data = ( pd.pivot_table( df, - index=["study", "is_shared", "access_mechanism"], + index=["study_name", "is_shared", "access_mechanism"], columns=["data"], # set this to len to count the number of workspaces instead of returning a boolean value. aggfunc=lambda x: len(x) > 0, @@ -100,6 +115,7 @@ def get_summary_table_data(): ) .rename_axis(columns=None) .reset_index() + .rename(columns={"study_name": "study", "B": "c"}) ) # Remove the dummy "no_data" column if it exists. if "no_data" in data: diff --git a/primed/primed_anvil/migrations/0006_studysite_drupal_node_id.py b/primed/primed_anvil/migrations/0006_studysite_drupal_node_id.py new file mode 100644 index 00000000..91ee9bd1 --- /dev/null +++ b/primed/primed_anvil/migrations/0006_studysite_drupal_node_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.19 on 2023-12-06 16:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('primed_anvil', '0005_availabledata'), + ] + + operations = [ + migrations.AddField( + model_name='studysite', + name='drupal_node_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/primed/primed_anvil/models.py b/primed/primed_anvil/models.py index 528d85ca..2277c9b8 100644 --- a/primed/primed_anvil/models.py +++ b/primed/primed_anvil/models.py @@ -14,6 +14,7 @@ class Study(TimeStampedModel, models.Model): full_name = models.CharField( max_length=255, help_text="The full name for this Study." ) + history = HistoricalRecords() class Meta: @@ -32,13 +33,16 @@ def get_absolute_url(self): class StudySite(TimeStampedModel, models.Model): - """A model to track Research Centers.""" + """A model to track Study Sites.""" short_name = models.CharField(max_length=15, unique=True) - """The short name of the Research Center.""" + """The short name of the Study Sites.""" full_name = models.CharField(max_length=255) - """The full name of the Research Center.""" + """The full name of the Study Sites.""" + + drupal_node_id = models.IntegerField(blank=True, null=True) + """Reference node ID for entity in drupal""" def __str__(self): """String method. diff --git a/primed/primed_anvil/tables.py b/primed/primed_anvil/tables.py index 30048579..24534216 100644 --- a/primed/primed_anvil/tables.py +++ b/primed/primed_anvil/tables.py @@ -59,42 +59,38 @@ def _get_bool_value(self, record, value, bound_column): return is_shared -class DefaultWorkspaceStaffTable(tables.Table): +class DefaultWorkspaceUserTable(tables.Table): """Class to use for default workspace tables in PRIMED.""" name = tables.Column(linkify=True, verbose_name="Workspace") - billing_project = tables.Column(linkify=True) - number_groups = tables.Column( - verbose_name="Number of groups shared with", - empty_values=(), - orderable=False, - accessor="workspacegroupsharing_set__count", - ) is_shared = WorkspaceSharedWithConsortiumColumn() class Meta: model = Workspace fields = ( "name", - "billing_project", - "number_groups", "is_shared", ) order_by = ("name",) -class DefaultWorkspaceUserTable(tables.Table): +class DefaultWorkspaceStaffTable(DefaultWorkspaceUserTable): """Class to use for default workspace tables in PRIMED.""" - name = tables.Column(linkify=True, verbose_name="Workspace") - billing_project = tables.Column() - is_shared = WorkspaceSharedWithConsortiumColumn() + billing_project = tables.Column(linkify=True) + number_groups = tables.Column( + verbose_name="Number of groups shared with", + empty_values=(), + orderable=False, + accessor="workspacegroupsharing_set__count", + ) class Meta: model = Workspace fields = ( "name", "billing_project", + "number_groups", "is_shared", ) order_by = ("name",) diff --git a/primed/primed_anvil/tests/test_helpers.py b/primed/primed_anvil/tests/test_helpers.py index d62b5507..78a20b57 100644 --- a/primed/primed_anvil/tests/test_helpers.py +++ b/primed/primed_anvil/tests/test_helpers.py @@ -3,6 +3,7 @@ from anvil_consortium_manager.tests.factories import WorkspaceGroupSharingFactory from django.test import TestCase +from primed.cdsa.tests.factories import CDSAWorkspaceFactory from primed.dbgap.tests.factories import dbGaPWorkspaceFactory from primed.miscellaneous_workspaces.tests.factories import OpenAccessWorkspaceFactory from primed.primed_anvil.tests.factories import AvailableDataFactory, StudyFactory @@ -44,8 +45,8 @@ def test_one_dbgap_workspace_one_study_not_shared_no_available_data(self): def test_one_open_access_workspace_one_study_not_shared_no_available_data(self): AvailableDataFactory.create(name="Foo") study = StudyFactory.create(short_name="TEST") - open_access_workspace = OpenAccessWorkspaceFactory.create() - open_access_workspace.studies.add(study) + workspace = OpenAccessWorkspaceFactory.create() + workspace.studies.add(study) res = helpers.get_summary_table_data() self.assertEqual(len(res), 1) self.assertEqual(len(res[0]), 4) @@ -59,13 +60,11 @@ def test_one_open_access_workspace_one_study_not_shared_no_available_data(self): self.assertIn("Foo", res[0]) self.assertEqual(res[0]["Foo"], False) - def test_one_workspace_one_study_not_shared_with_one_available_data(self): + def test_one_dbgap_workspace_one_study_not_shared_with_one_available_data(self): available_data = AvailableDataFactory.create(name="Foo") study = StudyFactory.create(short_name="TEST") - dbgap_workspace = dbGaPWorkspaceFactory.create( - dbgap_study_accession__studies=[study] - ) - dbgap_workspace.available_data.add(available_data) + workspace = dbGaPWorkspaceFactory.create(dbgap_study_accession__studies=[study]) + workspace.available_data.add(available_data) res = helpers.get_summary_table_data() self.assertEqual(len(res), 1) self.assertEqual(len(res[0]), 4) @@ -79,15 +78,13 @@ def test_one_workspace_one_study_not_shared_with_one_available_data(self): self.assertIn("Foo", res[0]) self.assertEqual(res[0]["Foo"], True) - def test_one_workspace_one_study_not_shared_with_two_available_data(self): + def test_one_dbgap_workspace_one_study_not_shared_with_two_available_data(self): available_data_1 = AvailableDataFactory.create(name="Foo") available_data_2 = AvailableDataFactory.create(name="Bar") study = StudyFactory.create(short_name="TEST") - dbgap_workspace = dbGaPWorkspaceFactory.create( - dbgap_study_accession__studies=[study] - ) - dbgap_workspace.available_data.add(available_data_1) - dbgap_workspace.available_data.add(available_data_2) + workspace = dbGaPWorkspaceFactory.create(dbgap_study_accession__studies=[study]) + workspace.available_data.add(available_data_1) + workspace.available_data.add(available_data_2) res = helpers.get_summary_table_data() self.assertEqual(len(res), 1) self.assertEqual(len(res[0]), 5) @@ -101,7 +98,7 @@ def test_one_workspace_one_study_not_shared_with_two_available_data(self): self.assertIn("Foo", res[0]) self.assertEqual(res[0]["Foo"], True) - def test_one_workspace_two_studies_not_shared_no_available_data(self): + def test_one_dbgap_workspace_two_studies_not_shared_no_available_data(self): AvailableDataFactory.create(name="Foo") study_1 = StudyFactory.create(short_name="TEST") study_2 = StudyFactory.create(short_name="Other") @@ -119,14 +116,12 @@ def test_one_workspace_two_studies_not_shared_no_available_data(self): self.assertIn("Foo", res[0]) self.assertEqual(res[0]["Foo"], False) - def test_one_workspace_one_study_shared_no_available_data(self): + def test_one_dbgap_workspace_one_study_shared_no_available_data(self): AvailableDataFactory.create(name="Foo") study = StudyFactory.create(short_name="TEST") - dbgap_workspace = dbGaPWorkspaceFactory.create( - dbgap_study_accession__studies=[study] - ) + workspace = dbGaPWorkspaceFactory.create(dbgap_study_accession__studies=[study]) WorkspaceGroupSharingFactory.create( - workspace=dbgap_workspace.workspace, group__name="PRIMED_ALL" + workspace=workspace.workspace, group__name="PRIMED_ALL" ) res = helpers.get_summary_table_data() self.assertEqual(len(res), 1) @@ -141,7 +136,7 @@ def test_one_workspace_one_study_shared_no_available_data(self): self.assertIn("Foo", res[0]) self.assertEqual(res[0]["Foo"], False) - def test_two_workspaces_one_study(self): + def test_two_dbgap_workspaces_one_study(self): AvailableDataFactory.create(name="Foo") study = StudyFactory.create(short_name="TEST") dbGaPWorkspaceFactory.create(dbgap_study_accession__studies=[study]) @@ -159,21 +154,21 @@ def test_two_workspaces_one_study(self): self.assertIn("Foo", res[0]) self.assertEqual(res[0]["Foo"], False) - def test_two_workspaces_one_study_one_shared(self): + def test_two_dbgap_workspaces_one_study_one_shared(self): available_data_1 = AvailableDataFactory.create(name="Foo") available_data_2 = AvailableDataFactory.create(name="Bar") study = StudyFactory.create(short_name="TEST") - dbgap_workspace_1 = dbGaPWorkspaceFactory.create( + workspace_1 = dbGaPWorkspaceFactory.create( dbgap_study_accession__studies=[study] ) - dbgap_workspace_1.available_data.add(available_data_1) + workspace_1.available_data.add(available_data_1) WorkspaceGroupSharingFactory.create( - workspace=dbgap_workspace_1.workspace, group__name="PRIMED_ALL" + workspace=workspace_1.workspace, group__name="PRIMED_ALL" ) - dbgap_workspace_2 = dbGaPWorkspaceFactory.create( + workspace_2 = dbGaPWorkspaceFactory.create( dbgap_study_accession__studies=[study] ) - dbgap_workspace_2.available_data.add(available_data_2) + workspace_2.available_data.add(available_data_2) res = helpers.get_summary_table_data() self.assertEqual(len(res), 2) self.assertIn( @@ -197,7 +192,7 @@ def test_two_workspaces_one_study_one_shared(self): res, ) - def test_two_workspaces_multiple_studies(self): + def test_two_dbgap_workspaces_multiple_studies(self): AvailableDataFactory.create(name="Foo") study_1 = StudyFactory.create(short_name="TEST") study_2 = StudyFactory.create(short_name="Other") @@ -229,8 +224,8 @@ def test_one_dbgap_workspace_one_open_access_workspace_different_studies(self): study_1 = StudyFactory.create(short_name="TEST") dbGaPWorkspaceFactory.create(dbgap_study_accession__studies=[study_1]) study_2 = StudyFactory.create(short_name="Other") - open_access_workspace = OpenAccessWorkspaceFactory.create() - open_access_workspace.studies.add(study_2) + workspace = OpenAccessWorkspaceFactory.create() + workspace.studies.add(study_2) res = helpers.get_summary_table_data() self.assertEqual(len(res), 2) self.assertIn( @@ -256,8 +251,8 @@ def test_one_dbgap_workspace_one_open_access_workspace_same_study(self): AvailableDataFactory.create(name="Foo") study = StudyFactory.create(short_name="TEST") dbGaPWorkspaceFactory.create(dbgap_study_accession__studies=[study]) - open_access_workspace = OpenAccessWorkspaceFactory.create() - open_access_workspace.studies.add(study) + workspace = OpenAccessWorkspaceFactory.create() + workspace.studies.add(study) res = helpers.get_summary_table_data() self.assertEqual(len(res), 2) self.assertIn( @@ -284,12 +279,10 @@ def test_one_dbgap_workspace_one_open_access_workspace_different_available_data( ): available_data_1 = AvailableDataFactory.create(name="Foo") study = StudyFactory.create(short_name="TEST") - dbgap_workspace = dbGaPWorkspaceFactory.create( - dbgap_study_accession__studies=[study] - ) - dbgap_workspace.available_data.add(available_data_1) - open_access_workspace = OpenAccessWorkspaceFactory.create() - open_access_workspace.studies.add(study) + workspace = dbGaPWorkspaceFactory.create(dbgap_study_accession__studies=[study]) + workspace.available_data.add(available_data_1) + workspace = OpenAccessWorkspaceFactory.create() + workspace.studies.add(study) res = helpers.get_summary_table_data() self.assertEqual(len(res), 2) self.assertIn( @@ -310,3 +303,244 @@ def test_one_dbgap_workspace_one_open_access_workspace_different_available_data( }, res, ) + + def test_one_cdsa_workspace_not_shared_no_available_data(self): + AvailableDataFactory.create(name="Foo") + study = StudyFactory.create(short_name="TEST") + CDSAWorkspaceFactory.create(study=study) + res = helpers.get_summary_table_data() + self.assertEqual(len(res), 1) + self.assertEqual(len(res[0]), 4) + self.assertIn("study", res[0]) + self.assertEqual(res[0]["study"], "TEST") + self.assertIn("access_mechanism", res[0]) + self.assertEqual(res[0]["access_mechanism"], "CDSA") + self.assertIn("is_shared", res[0]) + self.assertEqual(res[0]["is_shared"], False) + # Available data columns. + self.assertIn("Foo", res[0]) + self.assertEqual(res[0]["Foo"], False) + + def test_one_cdsa_workspace_not_shared_with_one_available_data(self): + available_data = AvailableDataFactory.create(name="Foo") + study = StudyFactory.create(short_name="TEST") + workspace = CDSAWorkspaceFactory.create(study=study) + workspace.available_data.add(available_data) + res = helpers.get_summary_table_data() + self.assertEqual(len(res), 1) + self.assertEqual(len(res[0]), 4) + self.assertIn("study", res[0]) + self.assertEqual(res[0]["study"], "TEST") + self.assertIn("access_mechanism", res[0]) + self.assertEqual(res[0]["access_mechanism"], "CDSA") + self.assertIn("is_shared", res[0]) + self.assertEqual(res[0]["is_shared"], False) + # Available data columns. + self.assertIn("Foo", res[0]) + self.assertEqual(res[0]["Foo"], True) + + def test_one_cdsa_workspace_not_shared_with_two_available_data(self): + available_data_1 = AvailableDataFactory.create(name="Foo") + available_data_2 = AvailableDataFactory.create(name="Bar") + study = StudyFactory.create(short_name="TEST") + workspace = CDSAWorkspaceFactory.create( + study=study, + ) + workspace.available_data.add(available_data_1) + workspace.available_data.add(available_data_2) + res = helpers.get_summary_table_data() + self.assertEqual(len(res), 1) + self.assertEqual(len(res[0]), 5) + self.assertIn("study", res[0]) + self.assertEqual(res[0]["study"], "TEST") + self.assertIn("access_mechanism", res[0]) + self.assertEqual(res[0]["access_mechanism"], "CDSA") + self.assertIn("is_shared", res[0]) + self.assertEqual(res[0]["is_shared"], False) + # Available data columns. + self.assertIn("Foo", res[0]) + self.assertEqual(res[0]["Foo"], True) + + def test_one_cdsa_workspace_one_study_shared_no_available_data(self): + AvailableDataFactory.create(name="Foo") + study = StudyFactory.create(short_name="TEST") + workspace = CDSAWorkspaceFactory.create(study=study) + WorkspaceGroupSharingFactory.create( + workspace=workspace.workspace, group__name="PRIMED_ALL" + ) + res = helpers.get_summary_table_data() + self.assertEqual(len(res), 1) + self.assertEqual(len(res[0]), 4) + self.assertIn("study", res[0]) + self.assertEqual(res[0]["study"], "TEST") + self.assertIn("access_mechanism", res[0]) + self.assertEqual(res[0]["access_mechanism"], "CDSA") + self.assertIn("is_shared", res[0]) + self.assertEqual(res[0]["is_shared"], True) + # Available data columns. + self.assertIn("Foo", res[0]) + self.assertEqual(res[0]["Foo"], False) + + def test_two_cdsa_workspaces_one_study(self): + AvailableDataFactory.create(name="Foo") + study = StudyFactory.create(short_name="TEST") + CDSAWorkspaceFactory.create(study=study) + CDSAWorkspaceFactory.create(study=study) + res = helpers.get_summary_table_data() + self.assertEqual(len(res), 1) + self.assertEqual(len(res[0]), 4) + self.assertIn("study", res[0]) + self.assertEqual(res[0]["study"], "TEST") + self.assertIn("access_mechanism", res[0]) + self.assertEqual(res[0]["access_mechanism"], "CDSA") + self.assertIn("is_shared", res[0]) + self.assertEqual(res[0]["is_shared"], False) + # Available data columns. + self.assertIn("Foo", res[0]) + self.assertEqual(res[0]["Foo"], False) + + def test_two_cdsa_workspaces_one_study_one_shared(self): + available_data_1 = AvailableDataFactory.create(name="Foo") + available_data_2 = AvailableDataFactory.create(name="Bar") + study = StudyFactory.create(short_name="TEST") + workspace_1 = CDSAWorkspaceFactory.create(study=study) + workspace_1.available_data.add(available_data_1) + WorkspaceGroupSharingFactory.create( + workspace=workspace_1.workspace, group__name="PRIMED_ALL" + ) + workspace_2 = CDSAWorkspaceFactory.create(study=study) + workspace_2.available_data.add(available_data_2) + res = helpers.get_summary_table_data() + self.assertEqual(len(res), 2) + self.assertIn( + { + "study": "TEST", + "is_shared": True, + "access_mechanism": "CDSA", + "Foo": True, + "Bar": False, + }, + res, + ) + self.assertIn( + { + "study": "TEST", + "is_shared": False, + "access_mechanism": "CDSA", + "Foo": False, + "Bar": True, + }, + res, + ) + + def test_two_cdsa_workspaces(self): + AvailableDataFactory.create(name="Foo") + study_1 = StudyFactory.create(short_name="TEST") + study_2 = StudyFactory.create(short_name="Other") + CDSAWorkspaceFactory.create(study=study_1) + CDSAWorkspaceFactory.create(study=study_2) + res = helpers.get_summary_table_data() + self.assertEqual(len(res), 2) + self.assertIn( + { + "study": "Other", + "is_shared": False, + "access_mechanism": "CDSA", + "Foo": False, + }, + res, + ) + self.assertIn( + { + "study": "TEST", + "is_shared": False, + "access_mechanism": "CDSA", + "Foo": False, + }, + res, + ) + + def test_one_cdsa_workspace_one_open_access_workspace_different_studies(self): + AvailableDataFactory.create(name="Foo") + study_1 = StudyFactory.create(short_name="TEST") + CDSAWorkspaceFactory.create(study=study_1) + study_2 = StudyFactory.create(short_name="Other") + workspace = OpenAccessWorkspaceFactory.create() + workspace.studies.add(study_2) + res = helpers.get_summary_table_data() + self.assertEqual(len(res), 2) + self.assertIn( + { + "study": "TEST", + "is_shared": False, + "access_mechanism": "CDSA", + "Foo": False, + }, + res, + ) + self.assertIn( + { + "study": "Other", + "is_shared": False, + "access_mechanism": "Open access", + "Foo": False, + }, + res, + ) + + def test_one_cdsa_workspace_one_open_access_workspace_same_study(self): + AvailableDataFactory.create(name="Foo") + study = StudyFactory.create(short_name="TEST") + CDSAWorkspaceFactory.create(study=study) + workspace = OpenAccessWorkspaceFactory.create() + workspace.studies.add(study) + res = helpers.get_summary_table_data() + self.assertEqual(len(res), 2) + self.assertIn( + { + "study": "TEST", + "is_shared": False, + "access_mechanism": "CDSA", + "Foo": False, + }, + res, + ) + self.assertIn( + { + "study": "TEST", + "is_shared": False, + "access_mechanism": "Open access", + "Foo": False, + }, + res, + ) + + def test_one_cdsa_workspace_one_open_access_workspace_different_available_data( + self, + ): + available_data_1 = AvailableDataFactory.create(name="Foo") + study = StudyFactory.create(short_name="TEST") + workspace = CDSAWorkspaceFactory.create(study=study) + workspace.available_data.add(available_data_1) + workspace = OpenAccessWorkspaceFactory.create() + workspace.studies.add(study) + res = helpers.get_summary_table_data() + self.assertEqual(len(res), 2) + self.assertIn( + { + "study": "TEST", + "is_shared": False, + "access_mechanism": "CDSA", + "Foo": True, + }, + res, + ) + self.assertIn( + { + "study": "TEST", + "is_shared": False, + "access_mechanism": "Open access", + "Foo": False, + }, + res, + ) diff --git a/primed/primed_anvil/tests/test_views.py b/primed/primed_anvil/tests/test_views.py index b84d4c5f..9a95ea11 100644 --- a/primed/primed_anvil/tests/test_views.py +++ b/primed/primed_anvil/tests/test_views.py @@ -3,6 +3,7 @@ from anvil_consortium_manager import models as acm_models from anvil_consortium_manager.tests.factories import AccountFactory from anvil_consortium_manager.views import AccountList +from constance.test import override_config from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission @@ -133,6 +134,12 @@ def test_user_has_not_linked_account(self): response, reverse("anvil_consortium_manager:accounts:link") ) + def test_unauthenticated_user_has_not_linked_account_message(self): + response = self.client.get(settings.LOGIN_URL, follow=True) + self.assertNotContains( + response, reverse("anvil_consortium_manager:accounts:link") + ) + def test_staff_view_links(self): user = UserFactory.create() user.user_permissions.add( @@ -163,6 +170,24 @@ def test_view_links(self): response, '"{}"'.format(reverse("anvil_consortium_manager:index")) ) + def test_site_announcement_no_text(self): + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertNotContains(response, """id="alert-announcement""") + + @override_config(ANNOUNCEMENT_TEXT="This is a test announcement") + def test_site_announcement_text(self): + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertContains(response, """id="alert-announcement""") + self.assertContains(response, "This is a test announcement") + + @override_config(ANNOUNCEMENT_TEXT="This is a test announcement") + def test_site_announcement_text_unauthenticated_user(self): + response = self.client.get(self.get_url(), follow=True) + self.assertContains(response, """id="alert-announcement""") + self.assertContains(response, "This is a test announcement") + class StudyDetailTest(TestCase): """Tests for the StudyDetail view.""" @@ -1214,3 +1239,31 @@ def test_table_rows(self): response = self.client.get(self.get_url()) self.assertIn("summary_table", response.context_data) self.assertEqual(len(response.context_data["summary_table"].rows), 2) + + def test_includes_open_access_workspaces(self): + """Open access workspaces are included in the table.""" + study = StudyFactory.create() + open_workspace = OpenAccessWorkspaceFactory.create() + open_workspace.studies.add(study) + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("summary_table", response.context_data) + self.assertEqual(len(response.context_data["summary_table"].rows), 1) + + def test_includes_dbgap_workspaces(self): + """dbGaP workspaces are included in the table.""" + # One open access workspace with one study, with one available data type. + # One dbGaP workspae with two studies. + dbGaPWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("summary_table", response.context_data) + self.assertEqual(len(response.context_data["summary_table"].rows), 1) + + def test_includes_cdsa_workspaces(self): + """CDSA workspaces are included in the table.""" + CDSAWorkspaceFactory.create() + self.client.force_login(self.user) + response = self.client.get(self.get_url()) + self.assertIn("summary_table", response.context_data) + self.assertEqual(len(response.context_data["summary_table"].rows), 1) diff --git a/primed/static/css/project.css b/primed/static/css/project.css index 6243cad2..78fa7855 100644 --- a/primed/static/css/project.css +++ b/primed/static/css/project.css @@ -134,7 +134,6 @@ h3, .h3 { } h4, .h4 { - font-size: calc(1.275rem + 0.3vw); } @media (min-width: 1200px) { @@ -12012,9 +12011,6 @@ a.navbar-brand:hover { background-color: unset; } -.navbar { - min-height: 132px; -} .navbar-brand { height: 100px; diff --git a/primed/static/js/project.js b/primed/static/js/project.js index d26d23b9..9c90629e 100644 --- a/primed/static/js/project.js +++ b/primed/static/js/project.js @@ -1 +1,19 @@ /* Project specific Javascript goes here. */ + +// Handle paste event for text inputs with maxlength. +const checkPasteLength = (e) => { + var paste = (e.clipboardData || window.clipboardData).getData("text"); + maxlength = e.target.getAttribute("maxlength"); + if (paste.length > maxlength) { + alert("String longer than allowed maximum length of " + maxlength + " characters:\n" + paste) + e.preventDefault() + e.stopPropagation() + } +} + +var textInputs = $('form').find("input[maxlength]") +// textInputs.on("paste", checkPasteLength); +for(var i = 0; i < textInputs.length; i++){ + // Console: print the clicked

    element + textInputs[i].addEventListener("paste", checkPasteLength); +} diff --git a/primed/templates/base.html b/primed/templates/base.html index 59db3d77..1789536c 100644 --- a/primed/templates/base.html +++ b/primed/templates/base.html @@ -108,23 +108,32 @@ - {% block extra_navbar %} - {% endblock %}

    -
    +
    - {% if messages %} - {% for message in messages %} - - {% endfor %} + {% if config.ANNOUNCEMENT_TEXT %} + {% endif %} - {% if not request.user.account %} - @@ -80,5 +130,7 @@

    +{% include "snippets/data_prep_workspace_table.html" with table=associated_data_prep_workspaces is_active=data_prep_active %} + {{block.super}} {% endblock after_panel %} diff --git a/primed/templates/cdsa/dataaffiliateagreement_detail.html b/primed/templates/cdsa/dataaffiliateagreement_detail.html index 211b3e92..ac650255 100644 --- a/primed/templates/cdsa/dataaffiliateagreement_detail.html +++ b/primed/templates/cdsa/dataaffiliateagreement_detail.html @@ -20,7 +20,20 @@
    Representative role
    {{ object.signed_agreement.representative_role }}
    Signing institution
    {{ object.signed_agreement.signing_institution }}
    -
    Primary?
    {{ object.signed_agreement.is_primary }}
    +
    Primary?
    + {% if object.is_primary %} + Yes + {% else %} + No + {% endif %} +
    +
    Study review required?
    + {% if object.requires_study_review %} + Yes + {% else %} + No + {% endif %} +
    Agreement version
    {{ object.signed_agreement.version }}
    @@ -45,6 +58,28 @@ {% endblock panel %} +{% block after_panel %} + +{% if object.additional_limitations %} +
    +
    +
    + + Additional limitations +
    +
    +

    + {{ object.additional_limitations }} +

    +
    +
    +
    +{% endif %} + +{{ block.super }} + +{% endblock after_panel %} + {% block action_buttons %} {% if show_update_button %} diff --git a/primed/templates/cdsa/memberagreement_detail.html b/primed/templates/cdsa/memberagreement_detail.html index 381b1f23..c4a4b1d9 100644 --- a/primed/templates/cdsa/memberagreement_detail.html +++ b/primed/templates/cdsa/memberagreement_detail.html @@ -20,7 +20,13 @@
    Representative role
    {{ object.signed_agreement.representative_role }}
    Signing institution
    {{ object.signed_agreement.signing_institution }}
    -
    Primary?
    {{ object.signed_agreement.is_primary }}
    +
    Primary?
    + {% if object.is_primary %} + Yes + {% else %} + No + {% endif %} +
    Agreement version
    {{ object.signed_agreement.version }}
    diff --git a/primed/templates/cdsa/nondataaffiliateagreement_detail.html b/primed/templates/cdsa/nondataaffiliateagreement_detail.html index bcd4b934..4d08830f 100644 --- a/primed/templates/cdsa/nondataaffiliateagreement_detail.html +++ b/primed/templates/cdsa/nondataaffiliateagreement_detail.html @@ -20,7 +20,6 @@
    Representative role
    {{ object.signed_agreement.representative_role }}
    Signing institution
    {{ object.signed_agreement.signing_institution }}
    -
    Primary?
    {{ object.signed_agreement.is_primary }}
    Agreement version
    {{ object.signed_agreement.version }}
    diff --git a/primed/templates/dbgap/dbgapworkspace_detail.html b/primed/templates/dbgap/dbgapworkspace_detail.html index 944beecd..c71d751f 100644 --- a/primed/templates/dbgap/dbgapworkspace_detail.html +++ b/primed/templates/dbgap/dbgapworkspace_detail.html @@ -1,4 +1,5 @@ {% extends "anvil_consortium_manager/workspace_detail.html" %} +{% load render_table from django_tables2%} {% block pills %} {% if workspace_data_object.gsr_restricted %} @@ -93,6 +94,8 @@

    +{% include "snippets/data_prep_workspace_table.html" with table=associated_data_prep_workspaces is_active=data_prep_active %} + {{block.super}} {% endblock after_panel %} diff --git a/primed/templates/snippets/data_prep_workspace_table.html b/primed/templates/snippets/data_prep_workspace_table.html new file mode 100644 index 00000000..32d04cff --- /dev/null +++ b/primed/templates/snippets/data_prep_workspace_table.html @@ -0,0 +1,24 @@ +{% load render_table from django_tables2 %} + +{% if perms.anvil_consortium_manager.anvil_consortium_manager_staff_view %} + +
    +
    +
    +

    + +

    +
    +
    + {% render_table table %} +
    +
    +
    +
    +
    + +{% endif %} diff --git a/primed/templates/users/drupal_data_audit_email.html b/primed/templates/users/drupal_data_audit_email.html new file mode 100644 index 00000000..c957a32f --- /dev/null +++ b/primed/templates/users/drupal_data_audit_email.html @@ -0,0 +1,47 @@ + +{% load static i18n %} +{% load render_table from django_tables2 %} + + + + Drupal Data Audit Report + + +
    + +{% block content %} + +

    Drupal Data Audit - [applying_changes={{ apply_changes }}]

    +

    User Audit

    + +

    Verified Users - {{ user_audit.verified|length }} record(s)

    + +

    Needs action - {{user_audit.needs_action|length }} record(s)

    + {% if user_audit.needs_action %} + {% render_table user_audit.get_needs_action_table %} + {% endif %} + +

    Errors - {{user_audit.errors|length }} record(s)

    + {% if user_audit.errors %} + {% render_table user_audit.get_errors_table %} + {% endif %} + +

    Site Audit

    + +

    Verified sites - {{ site_audit.verified|length }} record(s)

    + +

    Sites that need action - {{site_audit.needs_action|length }} record(s)

    + {% if site_audit.needs_action %} + {% render_table site_audit.get_needs_action_table %} + {% endif %} + +

    Sites with errors - {{site_audit.errors|length }} record(s)

    + {% if site_audit.errors %} + {% render_table site_audit.get_errors_table %} + {% endif %} + + +{% endblock content %} + +
    + diff --git a/primed/users/adapters.py b/primed/users/adapters.py index b0f6d6a3..24e6d91e 100644 --- a/primed/users/adapters.py +++ b/primed/users/adapters.py @@ -23,7 +23,7 @@ class SocialAccountAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request: HttpRequest, sociallogin: Any): return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) - def update_user_info(self, user, extra_data: Dict): + def update_user_info(self, user, extra_data: Dict, apply_update=True): drupal_username = extra_data.get("preferred_username") drupal_email = extra_data.get("email") first_name = extra_data.get("first_name") @@ -52,13 +52,15 @@ def update_user_info(self, user, extra_data: Dict): user.email = drupal_email user_changed = True - if user_changed is True: + if user_changed is True and apply_update is True: user.save() + return user_changed - def update_user_study_sites(self, user, extra_data: Dict): + def update_user_study_sites(self, user, extra_data: Dict, apply_update=True): # Get list of research centers in domain table research_center_or_site = extra_data.get("study_site_or_center") + user_sites_updated = False if research_center_or_site: if not isinstance(research_center_or_site, list): raise ImproperlyConfigured( @@ -79,19 +81,24 @@ def update_user_study_sites(self, user, extra_data: Dict): continue else: if not user.study_sites.filter(pk=rc.pk): - user.study_sites.add(rc) - logger.info( - f"[SocialAccountAdatpter:update_user_study_sites] adding user " - f"study_sites user: {user} rc: {rc}" - ) + user_sites_updated = True + if apply_update is True: + user.study_sites.add(rc) + logger.info( + f"[SocialAccountAdatpter:update_user_study_sites] adding user " + f"study_sites user: {user} rc: {rc}" + ) for existing_rc in user.study_sites.all(): if existing_rc.short_name not in research_center_or_site: - user.study_sites.remove(existing_rc) - logger.info( - "[SocialAccountAdatpter:update_user_study_sites] " - f"removing study_site {existing_rc} for user {user}" - ) + user_sites_updated = True + if apply_update: + user.study_sites.remove(existing_rc) + logger.info( + "[SocialAccountAdatpter:update_user_study_sites] " + f"removing study_site {existing_rc} for user {user}" + ) + return user_sites_updated def update_user_groups(self, user, extra_data: Dict): managed_scope_status = extra_data.get("managed_scope_status") diff --git a/primed/users/audit.py b/primed/users/audit.py new file mode 100644 index 00000000..371d7760 --- /dev/null +++ b/primed/users/audit.py @@ -0,0 +1,527 @@ +import logging +from dataclasses import dataclass + +import django_tables2 as tables +import jsonapi_requests +from allauth.socialaccount.models import SocialAccount +from anvil_consortium_manager.models import Account +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist +from django.db.models import Q +from django_tables2.export import TableExport +from oauthlib.oauth2 import BackendApplicationClient +from requests_oauthlib import OAuth2, OAuth2Session + +from primed.drupal_oauth_provider.provider import CustomProvider +from primed.primed_anvil.audit import PRIMEDAudit, PRIMEDAuditResult +from primed.primed_anvil.models import StudySite + +logger = logging.getLogger(__name__) + + +class TextTable(object): + def render_to_text(self): + return TableExport(export_format=TableExport.CSV, table=self).export() + + +class UserAuditResultsTable(tables.Table, TextTable): + """A table to show results from a UserAudit instance.""" + + result_type = tables.Column() + local_user_id = tables.Column() + local_username = tables.Column() + remote_user_id = tables.Column() + remote_username = tables.Column() + changes = tables.Column() + note = tables.Column() + anvil_groups = tables.Column() + + class Meta: + orderable = False + + +@dataclass +class UserAuditResult(PRIMEDAuditResult): + local_user: SocialAccount = None + anvil_account: Account = None + remote_user_data: jsonapi_requests.JsonApiObject = None + note: str = None + changes: dict = None + anvil_groups: list = None + + def get_table_dictionary(self): + """Return a dictionary that can be used to populate an instance of `SiteAuditResultsTable`.""" + + row = { + "changes": self.changes, + "anvil_groups": self.anvil_groups, + "note": self.note, + "result_type": type(self).__name__, + } + if self.local_user: + row.update( + { + "local_user_id": self.local_user.user.id, + "local_username": self.local_user.user.username, + } + ) + if self.remote_user_data: + row.update( + { + "remote_user_id": self.remote_user_data.attributes.get( + "drupal_internal__uid" + ), + "remote_username": self.remote_user_data.attributes.get("name"), + } + ) + if self.anvil_account: + row.update( + { + "anvil_account": self.anvil_account, + "local_user_id": self.anvil_account.user.id, + } + ) + return row + + +@dataclass +class VerifiedUser(UserAuditResult): + pass + + +@dataclass +class NewUser(UserAuditResult): + pass + + +@dataclass +class RemoveUser(UserAuditResult): + pass + + +@dataclass +class InactiveAnvilUser(UserAuditResult): + pass + + +@dataclass +class UpdateUser(UserAuditResult): + pass + + +@dataclass +class OverDeactivateThresholdUser(UserAuditResult): + pass + + +class UserAudit(PRIMEDAudit): + ISSUE_TYPE_USER_INACTIVE = "User is inactive in drupal" + ISSUE_TYPE_USER_REMOVED_FROM_SITE = "User removed from site" + USER_DEACTIVATE_THRESHOLD = 3 + results_table_class = UserAuditResultsTable + + def __init__(self, apply_changes=False, ignore_deactivate_threshold=False): + """Initialize the audit. + + Args: + apply_changes: Whether to make changes to align the audit + """ + super().__init__() + self.apply_changes = apply_changes + self.ignore_deactivate_threshold = ignore_deactivate_threshold + + def _run_audit(self): + """Run the audit on local and remote users.""" + user_endpoint_url = "user/user" + drupal_uids = set() + json_api = get_drupal_json_api() + study_sites = get_study_sites(json_api) + + user_count = 0 + while user_endpoint_url is not None: + + users_endpoint = json_api.endpoint(user_endpoint_url) + users_endpoint_response = users_endpoint.get() + + # If there are more, there will be a 'next' link + + user_endpoint_url = users_endpoint_response.content.links.get( + "next", {} + ).get("href") + + for user in users_endpoint_response.data: + drupal_uid = user.attributes.get("drupal_internal__uid") + drupal_username = user.attributes.get("name") + drupal_email = user.attributes.get("mail") + drupal_firstname = user.attributes.get("field_given_first_name_s_") + drupal_lastname = user.attributes.get( + "field_examples_family_last_name_" + ) + drupal_full_name = " ".join( + part for part in (drupal_firstname, drupal_lastname) if part + ) + drupal_study_sites_rel = user.relationships.get( + "field_study_site_or_center" + ) + drupal_user_study_site_shortnames = [] + if drupal_study_sites_rel: + for dss in drupal_study_sites_rel.data: + study_site_uuid = dss.id + study_site_info = study_sites[study_site_uuid] + + drupal_user_study_site_shortnames.append( + study_site_info["short_name"] + ) + new_user_sites = StudySite.objects.filter( + short_name__in=drupal_user_study_site_shortnames + ) + # no uid is blocked or anonymous + if not drupal_uid: + # potential blocked user, but will no longer have a drupal uid + # so we cover these below + continue + sa = None + try: + sa = SocialAccount.objects.get( + uid=user.attributes["drupal_internal__uid"], + provider=CustomProvider.id, + ) + except ObjectDoesNotExist: + drupal_user = get_user_model()() + drupal_user.username = drupal_username + drupal_user.name = drupal_full_name + drupal_user.email = drupal_email + if self.apply_changes is True: + drupal_user.save() + drupal_user.study_sites.set(new_user_sites) + if self.apply_changes is True: + sa = SocialAccount.objects.create( + user=drupal_user, + uid=user.attributes["drupal_internal__uid"], + provider=CustomProvider.id, + ) + self.needs_action.append( + NewUser(local_user=sa, remote_user_data=user) + ) + + if sa: + user_updates = {} + if sa.user.name != drupal_full_name: + user_updates.update( + {"name": {"old": sa.user.name, "new": drupal_full_name}} + ) + sa.user.name = drupal_full_name + if sa.user.username != drupal_username: + user_updates.update( + { + "username": { + "old": sa.user.username, + "new": drupal_username, + } + } + ) + sa.user.username = drupal_username + if sa.user.email != drupal_email: + user_updates.update( + {"email": {"old": sa.user.email, "new": drupal_email}} + ) + sa.user.email = drupal_email + + if sa.user.is_active is False: + user_updates.update({"is_active": {"old": False, "new": True}}) + sa.user.is_active = True + + prev_user_site_names = set( + sa.user.study_sites.all().values_list("short_name", flat=True) + ) + new_user_site_names = set(drupal_user_study_site_shortnames) + if prev_user_site_names != new_user_site_names: + + user_updates.update( + { + "sites": { + "old": prev_user_site_names, + "new": new_user_site_names, + } + } + ) + # do not remove from sites by default + removed_sites = prev_user_site_names.difference( + new_user_site_names + ) + new_sites = new_user_site_names.difference(prev_user_site_names) + + if settings.DRUPAL_DATA_AUDIT_REMOVE_USER_SITES is True: + if self.apply_changes is True: + sa.user.study_sites.set(new_user_sites) + else: + if removed_sites: + self.errors.append( + UpdateUser( + local_user=sa, + remote_user_data=user, + changes=user_updates, + ) + ) + if new_sites: + for new_site in new_user_sites: + if new_site.short_name in new_user_site_names: + if self.apply_changes is True: + sa.user.study_sites.add(new_site) + + if user_updates: + if self.apply_changes is True: + sa.user.save() + + self.needs_action.append( + UpdateUser( + local_user=sa, + remote_user_data=user, + changes=user_updates, + ) + ) + else: + self.verified.append( + VerifiedUser(local_user=sa, remote_user_data=user) + ) + + drupal_uids.add(drupal_uid) + user_count += 1 + + # find active django accounts that are drupal based + # users that we did not get from drupal + # these may include blocked users + + unaudited_drupal_accounts = SocialAccount.objects.filter( + provider=CustomProvider.id, user__is_active=True + ).exclude(uid__in=drupal_uids) + user_ids_to_check = [] + count_inactive = unaudited_drupal_accounts.count() + over_threshold = False + if self.ignore_deactivate_threshold is False: + if count_inactive > self.USER_DEACTIVATE_THRESHOLD: + over_threshold = True + + for uda in unaudited_drupal_accounts: + user_ids_to_check.append(uda.user.id) + handled = False + if settings.DRUPAL_DATA_AUDIT_DEACTIVATE_USERS is True: + uda.user.is_active = False + if over_threshold is False: + if self.apply_changes is True: + uda.user.save() + handled = True + self.needs_action.append(RemoveUser(local_user=uda)) + if handled is False: + self.errors.append( + RemoveUser(local_user=uda, note=f"Over Threshold {over_threshold}") + ) + + inactive_anvil_users = Account.objects.filter( + Q(user__is_active=False) | Q(user__id__in=user_ids_to_check), + groupaccountmembership__isnull=False, + ) + for inactive_anvil_user in inactive_anvil_users: + self.errors.append( + InactiveAnvilUser( + anvil_account=inactive_anvil_user, + anvil_groups=list( + inactive_anvil_user.groupaccountmembership_set.all().values_list( + "group__name", flat=True + ) + ), + ) + ) + + +class SiteAuditResultsTable(tables.Table, TextTable): + """A table to show results from a SiteAudit instance.""" + + result_type = tables.Column() + local_site_name = tables.Column() + remote_site_name = tables.Column() + changes = tables.Column() + note = tables.Column() + + def value_local_site_name(self, value): + return value + + class Meta: + orderable = False + + +@dataclass +class SiteAuditResult(PRIMEDAuditResult): + local_site: StudySite + remote_site_data: jsonapi_requests.JsonApiObject = None + changes: dict = None + note: str = None + + def get_table_dictionary(self): + """Return a dictionary that can be used to populate an instance of `SiteAuditResultsTable`.""" + row = { + "changes": self.changes, + "note": self.note, + "result_type": type(self).__name__, + } + if self.local_site: + row.update( + { + "local_site_name": self.local_site.short_name, + } + ) + if self.remote_site_data: + row.update( + { + "remote_site_name": self.remote_site_data.get("short_name"), + } + ) + return row + + +@dataclass +class VerifiedSite(SiteAuditResult): + pass + + +@dataclass +class NewSite(SiteAuditResult): + pass + + +@dataclass +class RemoveSite(SiteAuditResult): + pass + + +@dataclass +class UpdateSite(SiteAuditResult): + changes: dict + + +class SiteAudit(PRIMEDAudit): + ISSUE_TYPE_LOCAL_SITE_INVALID = "Local site is invalid" + results_table_class = SiteAuditResultsTable + + def __init__(self, apply_changes=False): + """Initialize the audit. + + Args: + apply_changes: Whether to make changes to align the audit + """ + super().__init__() + self.apply_changes = apply_changes + + def _run_audit(self): + """Run the audit on local and remote users.""" + valid_nodes = set() + json_api = get_drupal_json_api() + study_sites = get_study_sites(json_api=json_api) + for study_site_info in study_sites.values(): + + short_name = study_site_info["short_name"] + full_name = study_site_info["full_name"] + node_id = study_site_info["node_id"] + valid_nodes.add(node_id) + + try: + study_site = StudySite.objects.get(drupal_node_id=node_id) + except ObjectDoesNotExist: + study_site = None + if self.apply_changes is True: + study_site = StudySite.objects.create( + drupal_node_id=node_id, + short_name=short_name, + full_name=full_name, + ) + self.needs_action.append( + NewSite(remote_site_data=study_site_info, local_site=study_site) + ) + else: + study_site_updates = {} + + if study_site.full_name != full_name: + study_site_updates.update( + {"full_name": {"old": study_site.full_name, "new": full_name}} + ) + study_site.full_name = full_name + + if study_site.short_name != short_name: + study_site_updates.update( + { + "short_name": { + "old": study_site.short_name, + "new": short_name, + } + } + ) + study_site.short_name = short_name + + if study_site_updates: + if self.apply_changes is True: + study_site.save() + self.needs_action.append( + UpdateSite( + local_site=study_site, + remote_site_data=study_site_info, + changes=study_site_updates, + ) + ) + else: + self.verified.append( + VerifiedSite( + local_site=study_site, remote_site_data=study_site_info + ) + ) + + invalid_study_sites = StudySite.objects.exclude(drupal_node_id__in=valid_nodes) + + for iss in invalid_study_sites: + self.errors.append( + RemoveSite(local_site=iss, note=self.ISSUE_TYPE_LOCAL_SITE_INVALID) + ) + + +def get_drupal_json_api(): + + json_api_client_id = settings.DRUPAL_API_CLIENT_ID + json_api_client_secret = settings.DRUPAL_API_CLIENT_SECRET + + token_url = f"{settings.DRUPAL_SITE_URL}/oauth/token" + client = BackendApplicationClient(client_id=json_api_client_id) + oauth = OAuth2Session(client=client) + api_root = f"{settings.DRUPAL_SITE_URL}/{settings.DRUPAL_API_REL_PATH}" + + token = oauth.fetch_token( + token_url=token_url, + client_id=json_api_client_id, + client_secret=json_api_client_secret, + ) + + drupal_api = jsonapi_requests.Api.config( + { + "API_ROOT": api_root, + "AUTH": OAuth2(client=client, client_id=json_api_client_id, token=token), + "VALIDATE_SSL": True, + } + ) + return drupal_api + + +def get_study_sites(json_api): + study_sites_endpoint = json_api.endpoint("node/study_site_or_center") + study_sites_response = study_sites_endpoint.get() + study_sites_info = dict() + + for ss in study_sites_response.data: + short_name = ss.attributes["title"] + full_name = ss.attributes["field_long_name"] + node_id = ss.attributes["drupal_internal__nid"] + + study_sites_info[ss.id] = { + "node_id": node_id, + "short_name": short_name, + "full_name": full_name, + } + return study_sites_info diff --git a/primed/users/management/__init__.py b/primed/users/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/primed/users/management/commands/__init__.py b/primed/users/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/primed/users/management/commands/sync-drupal-data.py b/primed/users/management/commands/sync-drupal-data.py new file mode 100644 index 00000000..d58113cc --- /dev/null +++ b/primed/users/management/commands/sync-drupal-data.py @@ -0,0 +1,105 @@ +import logging + +from django.core.mail import send_mail +from django.core.management.base import BaseCommand +from django.http import HttpRequest +from django.template.loader import render_to_string +from django.utils.timezone import localtime + +from primed.users import audit + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Sync drupal user and domain data" + + def add_arguments(self, parser): + parser.add_argument( + "--update", + action="store_true", + dest="update", + default=False, + help="Make updates to sync local data with remote. If not set, will just report.", + ) + parser.add_argument( + "--ignore-threshold", + action="store_true", + dest="ignore_threshold", + default=False, + help="Ignore user deactivation threshold", + ) + + parser.add_argument( + "--email", + help="""Email to which to send audit result details that need action or have errors.""", + ) + + def _send_email(self, user_audit, site_audit): + # Send email if requested and there are problems. + if user_audit.ok() is False or site_audit.ok() is False: + # django-tables2 requires request context, so we create an empty one + # if we wanted to linkify any of our data we would need to do more here + request = HttpRequest() + subject = "[command:sync-drupal-data] report" + html_body = render_to_string( + "users/drupal_data_audit_email.html", + context={ + "user_audit": user_audit, + "site_audit": site_audit, + "request": request, + "apply_changes": self.apply_changes, + }, + ) + send_mail( + subject, + "Drupal data audit problems or changes found. Please see attached report.", + None, + [self.email], + fail_silently=False, + html_message=html_body, + ) + + def handle(self, *args, **options): + self.apply_changes = options.get("update") + self.email = options["email"] + self.ignore_threshold = options["ignore_threshold"] + + notification_content = ( + f"[sync-drupal-data] start: Applying Changes: {self.apply_changes} " + f"Ignoring Threshold: {self.ignore_threshold} Start time: {localtime()}\n" + ) + site_audit = audit.SiteAudit(apply_changes=self.apply_changes) + site_audit.run_audit() + + notification_content += ( + f"SiteAudit summary: status ok: {site_audit.ok()} verified: {len(site_audit.verified)} " + f"needs_changes: {len(site_audit.needs_action)} errors: {len(site_audit.errors)}\n" + ) + if site_audit.needs_action: + notification_content += "Sites that need syncing:\n" + notification_content += site_audit.get_needs_action_table().render_to_text() + if site_audit.errors: + notification_content += "Sites requiring intervention:\n" + notification_content += site_audit.get_errors_table().render_to_text() + + user_audit = audit.UserAudit( + apply_changes=self.apply_changes, + ignore_deactivate_threshold=self.ignore_threshold, + ) + user_audit.run_audit() + notification_content += ( + "--------------------------------------\n" + f"UserAudit summary: status ok: {user_audit.ok()} verified: {len(user_audit.verified)} " + f"needs_changes: {len(user_audit.needs_action)} errors: {len(user_audit.errors)}\n" + ) + if user_audit.needs_action: + notification_content += "Users that need syncing:\n" + notification_content += user_audit.get_needs_action_table().render_to_text() + if user_audit.errors: + notification_content += "Users that need intervention:\n" + notification_content += user_audit.get_errors_table().render_to_text() + + self.stdout.write(notification_content) + if self.email: + self._send_email(user_audit, site_audit) diff --git a/primed/users/tests/test_audit.py b/primed/users/tests/test_audit.py new file mode 100644 index 00000000..155d114e --- /dev/null +++ b/primed/users/tests/test_audit.py @@ -0,0 +1,612 @@ +import json +import time +from io import StringIO + +import responses +from allauth.socialaccount.models import SocialAccount +from anvil_consortium_manager.models import ( + Account, + GroupAccountMembership, + ManagedGroup, +) +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core import mail +from django.core.management import call_command +from django.test import TestCase +from marshmallow_jsonapi import Schema, fields + +from primed.drupal_oauth_provider.provider import CustomProvider +from primed.users import audit +from primed.users.models import StudySite + + +class StudySiteMockObject: + def __init__(self, id, title, field_long_name, drupal_internal__nid) -> None: + self.id = id + self.title = title + self.field_long_name = field_long_name + self.drupal_internal__nid = drupal_internal__nid + + +class UserMockObject: + def __init__( + self, + id, + display_name, + drupal_internal__uid, + name, + mail, + field_given_first_name_s_, + field_examples_family_last_name_, + field_study_site_or_center, + ) -> None: + self.id = id + self.display_name = display_name + self.drupal_internal__uid = drupal_internal__uid + self.name = name + self.mail = mail + self.field_given_first_name_s_ = field_given_first_name_s_ + self.field_examples_family_last_name_ = field_examples_family_last_name_ + self.field_study_site_or_center = field_study_site_or_center + + +class StudySiteSchema(Schema): + id = fields.Str(dump_only=True) + title = fields.Str() + field_long_name = fields.Str() + drupal_internal__nid = fields.Str() + # document_meta = fields.DocumentMeta() + + class Meta: + type_ = "node--study_site_or_center" + + +class UserSchema(Schema): + id = fields.Str(dump_only=True) + display_name = fields.Str() + drupal_internal__uid = fields.Str() + name = fields.Str() + mail = fields.Str() + field_given_first_name_s_ = fields.Str() + field_examples_family_last_name_ = fields.Str() + field_study_site_or_center = fields.Relationship( + many=True, schema="StudySiteSchema", type_="node--study_site_or_center" + ) + + class Meta: + type_ = "users" + + +# def debug_requests_on(): +# """Switches on logging of the requests module.""" +# HTTPConnection.debuglevel = 1 + +# logging.basicConfig() +# logging.getLogger().setLevel(logging.DEBUG) +# requests_log = logging.getLogger("requests.packages.urllib3") +# requests_log.setLevel(logging.DEBUG) +# requests_log.propagate = True + + +TEST_STUDY_SITE_DATA = [ + StudySiteMockObject( + **{ + "id": "1", + "drupal_internal__nid": "1", + "title": "SS1", + "field_long_name": "S S 1", + # "document_meta": {"page": {"offset": 10}}, + } + ), + StudySiteMockObject( + **{ + "id": "2", + "drupal_internal__nid": "2", + "title": "SS2", + "field_long_name": "S S 2", + # "document_meta": {"page": {"offset": 10}}, + } + ), +] + +TEST_USER_DATA = [ + UserMockObject( + **{ + "id": "usr1", + "display_name": "dnusr1", + "drupal_internal__uid": "usr1", + "name": "testuser1", + "mail": "testuser1@test.com", + "field_given_first_name_s_": "test1", + "field_examples_family_last_name_": "user1", + "field_study_site_or_center": [], + } + ), + # second mock object is deactivated user (no drupal uid) + UserMockObject( + **{ + "id": "usr2", + "display_name": "dnusr2", + "drupal_internal__uid": "", + "name": "testuser2", + "mail": "testuser2@test.com", + "field_given_first_name_s_": "test2", + "field_examples_family_last_name_": "user2", + "field_study_site_or_center": [], + } + ), +] + + +class TestUserDataAudit(TestCase): + """General tests of the user audit""" + + def setUp(self): + # debug_requests_on() + super().setUp() + fake_time = time.time() + self.token = { + "token_type": "Bearer", + "access_token": "asdfoiw37850234lkjsdfsdfTEST", # gitleaks:allow + "refresh_token": "sldvafkjw34509s8dfsdfTEST", # gitleaks:allow + "expires_in": 3600, + "expires_at": fake_time + 3600, + } + + def add_fake_study_sites_response(self): + url_path = f"{settings.DRUPAL_SITE_URL}/{settings.DRUPAL_API_REL_PATH}/node/study_site_or_center/" + responses.get( + url=url_path, + body=json.dumps(StudySiteSchema(many=True).dump(TEST_STUDY_SITE_DATA)), + ) + + def add_fake_users_response(self): + url_path = ( + f"{settings.DRUPAL_SITE_URL}/{settings.DRUPAL_API_REL_PATH}/user/user/" + ) + TEST_USER_DATA[0].field_study_site_or_center = [TEST_STUDY_SITE_DATA[0]] + user_data = UserSchema( + include_data=("field_study_site_or_center",), many=True + ).dump(TEST_USER_DATA) + + responses.get( + url=url_path, + body=json.dumps(user_data), + ) + + def add_fake_token_response(self): + token_url = f"{settings.DRUPAL_SITE_URL}/oauth/token" + responses.post(url=token_url, body=json.dumps(self.token)) + + def get_fake_json_api(self): + self.add_fake_token_response() + return audit.get_drupal_json_api() + + @responses.activate + def test_get_json_api(self): + json_api = self.get_fake_json_api() + assert ( + json_api.requests.config.AUTH._client.token["access_token"] + == self.token["access_token"] + ) + + @responses.activate + def test_get_study_sites(self): + json_api = self.get_fake_json_api() + self.add_fake_study_sites_response() + study_sites = audit.get_study_sites(json_api=json_api) + + for test_study_site in TEST_STUDY_SITE_DATA: + + assert ( + test_study_site.field_long_name + == study_sites[test_study_site.drupal_internal__nid]["full_name"] + ) + assert ( + test_study_site.title + == study_sites[test_study_site.drupal_internal__nid]["short_name"] + ) + assert ( + test_study_site.drupal_internal__nid + == study_sites[test_study_site.drupal_internal__nid]["node_id"] + ) + + @responses.activate + def test_audit_study_sites_no_update(self): + self.get_fake_json_api() + self.add_fake_study_sites_response() + site_audit = audit.SiteAudit(apply_changes=False) + site_audit.run_audit() + self.assertFalse(site_audit.ok()) + self.assertEqual(len(site_audit.errors), 0) + self.assertEqual(len(site_audit.needs_action), 2) + self.assertEqual(StudySite.objects.all().count(), 0) + + @responses.activate + def test_audit_study_sites_with_new_sites(self): + self.get_fake_json_api() + self.add_fake_study_sites_response() + site_audit = audit.SiteAudit(apply_changes=True) + site_audit.run_audit() + self.assertFalse(site_audit.ok()) + self.assertEqual(len(site_audit.needs_action), 2) + self.assertEqual(StudySite.objects.all().count(), 2) + + assert ( + StudySite.objects.filter( + short_name__in=[ + TEST_STUDY_SITE_DATA[0].title, + TEST_STUDY_SITE_DATA[1].title, + ] + ).count() + == 2 + ) + assert len(site_audit.get_needs_action_table().rows) == 2 + + @responses.activate + def test_audit_study_sites_with_site_update(self): + StudySite.objects.create( + drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid, + short_name="WrongShortName", + full_name="WrongTitle", + ) + StudySite.objects.create( + drupal_node_id=TEST_STUDY_SITE_DATA[1].drupal_internal__nid, + short_name=TEST_STUDY_SITE_DATA[1].title, + full_name=TEST_STUDY_SITE_DATA[1].field_long_name, + ) + self.get_fake_json_api() + self.add_fake_study_sites_response() + site_audit = audit.SiteAudit(apply_changes=True) + site_audit.run_audit() + self.assertFalse(site_audit.ok()) + self.assertEqual(len(site_audit.needs_action), 1) + self.assertEqual(len(site_audit.verified), 1) + self.assertEqual(len(site_audit.errors), 0) + self.assertEqual(StudySite.objects.all().count(), 2) + + first_test_ss = StudySite.objects.get(short_name=TEST_STUDY_SITE_DATA[0].title) + # did we update the long name + assert first_test_ss.full_name == TEST_STUDY_SITE_DATA[0].field_long_name + assert first_test_ss.short_name == TEST_STUDY_SITE_DATA[0].title + + @responses.activate + def test_audit_study_sites_with_extra_site(self): + StudySite.objects.create( + drupal_node_id=99, short_name="ExtraSite", full_name="ExtraSiteLong" + ) + self.get_fake_json_api() + self.add_fake_study_sites_response() + site_audit = audit.SiteAudit(apply_changes=True) + site_audit.run_audit() + self.assertFalse(site_audit.ok()) + self.assertEqual(len(site_audit.errors), 1) + self.assertEqual(StudySite.objects.all().count(), 3) + assert len(site_audit.get_errors_table().rows) == 1 + + @responses.activate + def test_full_user_audit(self): + self.add_fake_token_response() + self.add_fake_study_sites_response() + self.add_fake_users_response() + StudySite.objects.create( + drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid, + short_name=TEST_STUDY_SITE_DATA[0].title, + full_name=TEST_STUDY_SITE_DATA[0].field_long_name, + ) + user_audit = audit.UserAudit(apply_changes=True) + user_audit.run_audit() + + self.assertFalse(user_audit.ok()) + self.assertEqual(len(user_audit.needs_action), 1) + + users = get_user_model().objects.all() + assert users.count() == 1 + + assert users.first().email == TEST_USER_DATA[0].mail + assert users.first().username == TEST_USER_DATA[0].name + assert users.first().study_sites.count() == 1 + assert ( + users.first().study_sites.first().short_name + == TEST_STUDY_SITE_DATA[0].title + ) + + @responses.activate + def test_full_user_audit_check_only(self): + self.add_fake_token_response() + self.add_fake_study_sites_response() + self.add_fake_users_response() + StudySite.objects.create( + drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid, + short_name=TEST_STUDY_SITE_DATA[0].title, + full_name=TEST_STUDY_SITE_DATA[0].field_long_name, + ) + user_audit = audit.UserAudit(apply_changes=False) + user_audit.run_audit() + self.assertFalse(user_audit.ok()) + self.assertEqual(len(user_audit.needs_action), 1) + + # verify we did not actually create a user + users = get_user_model().objects.all() + assert users.count() == 0 + + @responses.activate + def test_user_audit_remove_site_inform(self): + self.add_fake_token_response() + self.add_fake_study_sites_response() + self.add_fake_users_response() + ss1 = StudySite.objects.create( + drupal_node_id=TEST_STUDY_SITE_DATA[1].drupal_internal__nid, + short_name=TEST_STUDY_SITE_DATA[1].title, + full_name=TEST_STUDY_SITE_DATA[1].field_long_name, + ) + drupal_fullname = "{} {}".format( + TEST_USER_DATA[0].field_given_first_name_s_, + TEST_USER_DATA[0].field_examples_family_last_name_, + ) + drupal_username = TEST_USER_DATA[0].name + drupal_email = TEST_USER_DATA[0].mail + new_user = get_user_model().objects.create( + username=drupal_username + "UPDATE", + email=drupal_email + "UPDATE", + name=drupal_fullname + "UPDATE", + ) + new_user.study_sites.add(ss1) + SocialAccount.objects.create( + user=new_user, + uid=TEST_USER_DATA[0].drupal_internal__uid, + provider=CustomProvider.id, + ) + user_audit = audit.UserAudit(apply_changes=False) + user_audit.run_audit() + self.assertFalse(user_audit.ok()) + self.assertEqual(len(user_audit.errors), 1) + + new_user.refresh_from_db() + # assert we did not remove the site + assert ss1 in new_user.study_sites.all() + + @responses.activate + def test_user_audit_remove_site_act(self): + self.add_fake_token_response() + self.add_fake_study_sites_response() + self.add_fake_users_response() + ss1 = StudySite.objects.create( + drupal_node_id=TEST_STUDY_SITE_DATA[1].drupal_internal__nid, + short_name=TEST_STUDY_SITE_DATA[1].title, + full_name=TEST_STUDY_SITE_DATA[1].field_long_name, + ) + drupal_fullname = "{} {}".format( + TEST_USER_DATA[0].field_given_first_name_s_, + TEST_USER_DATA[0].field_examples_family_last_name_, + ) + drupal_username = TEST_USER_DATA[0].name + drupal_email = TEST_USER_DATA[0].mail + new_user = get_user_model().objects.create( + username=drupal_username + "UPDATE", + email=drupal_email + "UPDATE", + name=drupal_fullname + "UPDATE", + ) + new_user.study_sites.add(ss1) + SocialAccount.objects.create( + user=new_user, + uid=TEST_USER_DATA[0].drupal_internal__uid, + provider=CustomProvider.id, + ) + with self.settings(DRUPAL_DATA_AUDIT_REMOVE_USER_SITES=True): + user_audit = audit.UserAudit(apply_changes=True) + user_audit.run_audit() + self.assertFalse(user_audit.ok()) + new_user.refresh_from_db() + # assert we did remove the site + assert ss1 not in new_user.study_sites.all() + + @responses.activate + def test_user_audit_change_user(self): + self.add_fake_token_response() + self.add_fake_study_sites_response() + self.add_fake_users_response() + StudySite.objects.create( + drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid, + short_name=TEST_STUDY_SITE_DATA[0].title, + full_name=TEST_STUDY_SITE_DATA[0].field_long_name, + ) + drupal_fullname = "{} {}".format( + TEST_USER_DATA[0].field_given_first_name_s_, + TEST_USER_DATA[0].field_examples_family_last_name_, + ) + drupal_username = TEST_USER_DATA[0].name + drupal_email = TEST_USER_DATA[0].mail + new_user = get_user_model().objects.create( + username=drupal_username + "UPDATE", + email=drupal_email + "UPDATE", + name=drupal_fullname + "UPDATE", + is_active=False, + ) + SocialAccount.objects.create( + user=new_user, + uid=TEST_USER_DATA[0].drupal_internal__uid, + provider=CustomProvider.id, + ) + user_audit = audit.UserAudit(apply_changes=True) + user_audit.run_audit() + self.assertFalse(user_audit.ok()) + new_user.refresh_from_db() + + self.assertEqual(new_user.name, drupal_fullname) + self.assertEqual(len(user_audit.needs_action), 1) + + # test user removal + @responses.activate + def test_user_audit_remove_user_only_inform(self): + self.add_fake_token_response() + self.add_fake_study_sites_response() + self.add_fake_users_response() + StudySite.objects.create( + drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid, + short_name=TEST_STUDY_SITE_DATA[0].title, + full_name=TEST_STUDY_SITE_DATA[0].field_long_name, + ) + + new_user = get_user_model().objects.create( + username="username2", email="useremail2", name="user fullname2" + ) + SocialAccount.objects.create( + user=new_user, + uid=999, + provider=CustomProvider.id, + ) + user_audit = audit.UserAudit(apply_changes=True) + user_audit.run_audit() + self.assertFalse(user_audit.ok()) + + new_user.refresh_from_db() + self.assertTrue(new_user.is_active) + + # test user removal + @responses.activate + def test_user_audit_remove_user(self): + self.add_fake_token_response() + self.add_fake_study_sites_response() + self.add_fake_users_response() + StudySite.objects.create( + drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid, + short_name=TEST_STUDY_SITE_DATA[0].title, + full_name=TEST_STUDY_SITE_DATA[0].field_long_name, + ) + + new_user = get_user_model().objects.create( + username="username2", email="useremail2", name="user fullname2" + ) + SocialAccount.objects.create( + user=new_user, + uid=999, + provider=CustomProvider.id, + ) + new_anvil_account = Account.objects.create( + user=new_user, + is_service_account=False, + ) + new_anvil_managed_group = ManagedGroup.objects.create( + name="testgroup", + email="testgroup@testgroup.org", + ) + GroupAccountMembership.objects.create( + group=new_anvil_managed_group, + account=new_anvil_account, + role=GroupAccountMembership.MEMBER, + ) + + with self.settings(DRUPAL_DATA_AUDIT_DEACTIVATE_USERS=True): + user_audit = audit.UserAudit(apply_changes=True) + user_audit.run_audit() + self.assertFalse(user_audit.ok()) + self.assertEqual(len(user_audit.errors), 1) + self.assertEqual(user_audit.errors[0].anvil_account, new_anvil_account) + self.assertIn( + "InactiveAnvilUser", user_audit.get_errors_table().render_to_text() + ) + self.assertEqual(len(user_audit.needs_action), 2) + new_user.refresh_from_db() + self.assertFalse(new_user.is_active) + + # test user removal + @responses.activate + def test_user_audit_remove_user_threshold(self): + self.add_fake_token_response() + self.add_fake_study_sites_response() + self.add_fake_users_response() + StudySite.objects.create( + drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid, + short_name=TEST_STUDY_SITE_DATA[0].title, + full_name=TEST_STUDY_SITE_DATA[0].field_long_name, + ) + + SocialAccount.objects.create( + user=get_user_model().objects.create( + username="username2", email="useremail2", name="user fullname2" + ), + uid=996, + provider=CustomProvider.id, + ) + + SocialAccount.objects.create( + user=get_user_model().objects.create( + username="username3", email="useremail3", name="user fullname3" + ), + uid=997, + provider=CustomProvider.id, + ) + + SocialAccount.objects.create( + user=get_user_model().objects.create( + username="username4", email="useremail4", name="user fullname4" + ), + uid=998, + provider=CustomProvider.id, + ) + SocialAccount.objects.create( + user=get_user_model().objects.create( + username="username5", email="useremail5", name="user fullname5" + ), + uid=999, + provider=CustomProvider.id, + ) + with self.settings(DRUPAL_DATA_AUDIT_DEACTIVATE_USERS=True): + user_audit = audit.UserAudit(apply_changes=False) + user_audit.run_audit() + self.assertFalse(user_audit.ok()) + self.assertEqual(len(user_audit.errors), 4) + self.assertEqual(len(user_audit.needs_action), 1) + self.assertEqual(user_audit.errors[0].note, "Over Threshold True") + # Run again with ignore threshold, should move from error to needs action + user_audit = audit.UserAudit( + apply_changes=False, ignore_deactivate_threshold=True + ) + user_audit.run_audit() + self.assertFalse(user_audit.ok()) + self.assertEqual(len(user_audit.errors), 0) + self.assertEqual(len(user_audit.needs_action), 5) + + @responses.activate + def test_sync_drupal_data_command(self): + self.add_fake_token_response() + self.add_fake_study_sites_response() + self.add_fake_users_response() + out = StringIO() + call_command("sync-drupal-data", stdout=out) + self.assertIn( + "SiteAudit summary: status ok: False verified: 0 needs_changes: 2", + out.getvalue(), + ) + + @responses.activate + def test_sync_drupal_data_command_with_issues(self): + + StudySite.objects.create( + drupal_node_id="999999", + short_name=TEST_STUDY_SITE_DATA[0].title, + full_name=TEST_STUDY_SITE_DATA[0].field_long_name, + ) + + new_user = get_user_model().objects.create( + username="username2", email="useremail2", name="user fullname2" + ) + SocialAccount.objects.create( + user=new_user, + uid=999, + provider=CustomProvider.id, + ) + self.add_fake_token_response() + self.add_fake_study_sites_response() + self.add_fake_users_response() + out = StringIO() + call_command("sync-drupal-data", "--email=test@example.com", stdout=out) + self.assertIn("SiteAudit summary: status ok: False", out.getvalue()) + self.assertIn("UserAudit summary: status ok: False", out.getvalue()) + + self.assertEqual(len(mail.outbox), 1) + email = mail.outbox[0] + self.assertEqual(email.to, ["test@example.com"]) + self.assertEqual(email.subject, "[command:sync-drupal-data] report") diff --git a/requirements/dev-requirements.txt b/requirements/dev-requirements.txt index 814e7b09..f2991d16 100644 --- a/requirements/dev-requirements.txt +++ b/requirements/dev-requirements.txt @@ -233,7 +233,7 @@ types-pytz==2024.1.0.20240203 # via django-stubs types-pyyaml==6.0.12.12 # via django-stubs -types-requests==2.31.0.20240310 +types-requests==2.31.0.20240406 # via -r requirements/dev-requirements.in typing-extensions==4.8.0 # via @@ -257,7 +257,7 @@ virtualenv==20.25.1 # via pre-commit wcwidth==0.2.13 # via prompt-toolkit -werkzeug==3.0.1 +werkzeug==3.0.2 # via -r requirements/dev-requirements.in zipp==3.17.0 # via diff --git a/requirements/requirements.in b/requirements/requirements.in index dfc31fc7..b977ac59 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -44,6 +44,9 @@ requests # For json schema validation. jsonschema +# For interacting with drupal json api +jsonapi-requests + # For tree structures django-tree-queries @@ -66,3 +69,7 @@ django-htmx certifi>=2023.7.22 urllib3>=1.26.18 sqlparse>=0.4.4 + +# Dynamic settings +django-constance +django-picklefield # Required by django-constance for database backend diff --git a/requirements/requirements.txt b/requirements/requirements.txt index bf4c6ad2..54ee4636 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -58,6 +58,7 @@ django==4.2.11 # django-filter # django-htmx # django-model-utils + # django-picklefield # django-tables2 django-allauth==0.61.1 # via -r requirements/requirements.in @@ -65,6 +66,8 @@ django-anvil-consortium-manager @ git+https://github.com/UW-GAC/django-anvil-con # via -r requirements/requirements.in django-autocomplete-light==3.11.0 # via django-anvil-consortium-manager +django-constance==3.1.0 + # via -r requirements/requirements.in django-crispy-forms==2.1 # via # -r requirements/requirements.in @@ -86,15 +89,19 @@ django-login-required-middleware==0.9.0 # via -r requirements/requirements.in django-maintenance-mode==0.21.1 # via -r requirements/requirements.in -django-model-utils==4.4.0 +django-model-utils==4.5.0 # via -r requirements/requirements.in +django-picklefield==3.2 + # via + # -r requirements/requirements.in + # django-constance django-simple-history==3.5.0 # via # -r requirements/requirements.in # django-anvil-consortium-manager django-tables2==2.7.0 # via django-anvil-consortium-manager -django-tree-queries==0.16.1 +django-tree-queries==0.18.0 # via -r requirements/requirements.in fastobo==0.12.3 # via pronto @@ -110,6 +117,8 @@ importlib-resources==6.1.1 # via # jsonschema # jsonschema-specifications +jsonapi-requests==0.7.0 + # via -r requirements/requirements.in jsonschema==4.21.1 # via -r requirements/requirements.in jsonschema-specifications==2023.12.1 @@ -151,9 +160,7 @@ pyasn1-modules==0.3.0 pycparser==2.21 # via cffi pyjwt[crypto]==2.4.0 - # via - # django-allauth - # pyjwt + # via django-allauth pyparsing==3.1.1 # via packaging pyproject-hooks==1.0.0 @@ -182,6 +189,7 @@ requests==2.31.0 # -r requirements/requirements.in # django-allauth # django-anvil-consortium-manager + # jsonapi-requests # requests-oauthlib requests-oauthlib==1.3.1 # via django-allauth @@ -197,10 +205,12 @@ sqlparse==0.4.4 # via # -r requirements/requirements.in # django -tablib==3.5.0 +tablib==3.6.1 # via -r requirements/requirements.in tenacity==8.2.3 - # via plotly + # via + # jsonapi-requests + # plotly tomli==2.0.1 # via # build diff --git a/requirements/test-requirements.in b/requirements/test-requirements.in index df760727..c5e95951 100644 --- a/requirements/test-requirements.in +++ b/requirements/test-requirements.in @@ -18,3 +18,5 @@ freezegun # https://github.com/spulec/freezegun django-coverage-plugin # https://github.com/nedbat/django_coverage_plugin # Test coverage. coverage +# Mock json api data +marshmallow-jsonapi diff --git a/requirements/test-requirements.txt b/requirements/test-requirements.txt index e69f69b4..f58f6830 100644 --- a/requirements/test-requirements.txt +++ b/requirements/test-requirements.txt @@ -12,7 +12,7 @@ charset-normalizer==3.3.2 # via # -c requirements/requirements.txt # requests -coverage==7.4.3 +coverage==7.4.4 # via # -r requirements/test-requirements.in # django-coverage-plugin @@ -34,9 +34,14 @@ idna==3.3 # requests iniconfig==2.0.0 # via pytest +marshmallow==3.20.1 + # via marshmallow-jsonapi +marshmallow-jsonapi==0.24.0 + # via -r requirements/test-requirements.in packaging==21.3 # via # -c requirements/requirements.txt + # marshmallow # pytest # pytest-sugar pluggy==1.4.0