From 7574ae877b24fd6eece988582b30f8c8366c930b Mon Sep 17 00:00:00 2001 From: Jonas Carson Date: Thu, 7 Dec 2023 12:32:04 -0800 Subject: [PATCH 001/102] Initial work to support drupal user and user data auditing - Add drupal node ID to study sites, admin view - Update user adapters so code can be shared for user data updates - Add management command to sync data from drupal - Add user audit file and test_audit coverage for file More to do for tests, audit results display --- .env.dist | 4 + config/settings/base.py | 3 + primed/primed_anvil/admin.py | 15 +- .../0006_studysite_drupal_node_id.py | 18 ++ primed/primed_anvil/models.py | 10 +- primed/users/adapters.py | 33 ++- primed/users/audit.py | 262 ++++++++++++++++++ primed/users/management/__init__.py | 0 primed/users/management/commands/__init__.py | 0 .../management/commands/sync-drupal-data.py | 25 ++ primed/users/tests/test_audit.py | 186 +++++++++++++ 11 files changed, 528 insertions(+), 28 deletions(-) create mode 100644 primed/primed_anvil/migrations/0006_studysite_drupal_node_id.py create mode 100644 primed/users/audit.py create mode 100644 primed/users/management/__init__.py create mode 100644 primed/users/management/commands/__init__.py create mode 100644 primed/users/management/commands/sync-drupal-data.py create mode 100644 primed/users/tests/test_audit.py diff --git a/.env.dist b/.env.dist index 518ad98a..28c2dafa 100644 --- a/.env.dist +++ b/.env.dist @@ -23,3 +23,7 @@ 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= diff --git a/config/settings/base.py b/config/settings/base.py index eb563344..d7d54957 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -386,3 +386,6 @@ # 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="") 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/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/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..6198313c --- /dev/null +++ b/primed/users/audit.py @@ -0,0 +1,262 @@ +import jsonapi_requests +from allauth.socialaccount.models import SocialAccount +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist +from oauthlib.oauth2 import BackendApplicationClient +from requests_oauthlib import OAuth2, OAuth2Session + +from primed.drupal_oauth_provider.provider import CustomProvider +from primed.primed_anvil.models import StudySite +from primed.users.adapters import SocialAccountAdapter + + +class UserDataAuditResults: + def __init__(self, results): + self.results = results + + ISSUE_RESULT_TYPE = "issue" + NEW_RESULT_TYPE = "new" + UPDATE_RESULT_TYPE = "update" + + def encountered_issues(self): + for row in self.results: + if row["result_type"] == self.ISSUE_RESULT_TYPE: + return True + return False + + def count_new_rows(self): + new_count = 0 + for row in self.results: + if row["result_type"] == self.NEW_RESULT_TYPE: + new_count += 1 + return new_count + + def count_update_rows(self): + update_count = 0 + for row in self.results: + if row["result_type"] == self.UPDATE_RESULT_TYPE: + update_count += 1 + return update_count + + +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) + 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": f"{settings.DRUPAL_SITE_URL}/jsonapi", + "AUTH": OAuth2(client=client, client_id=json_api_client_id, token=token), + "VALIDATE_SSL": True, + } + ) + return drupal_api + + +def drupal_data_study_site_audit(should_update=False): + json_api = get_drupal_json_api() + study_sites = get_study_sites(json_api) + status = audit_drupal_study_sites( + study_sites=study_sites, should_update=should_update + ) + # audit_drupal_users(study_sites=study_sites, should_update=should_update) + return status + + +def drupal_data_user_audit(should_update=False): + json_api = get_drupal_json_api() + study_sites = get_study_sites(json_api=json_api) + status = audit_drupal_users( + study_sites=study_sites, should_update=should_update, json_api=json_api + ) + return status + + +def audit_drupal_users(study_sites, json_api, should_update=False): + + issues = [] + + user_endpoint_url = "user/user" + drupal_uids = set() + + drupal_adapter = SocialAccountAdapter() + max_users = 3 + user_count = 0 + while user_endpoint_url is not None: + print(f"GETTING {user_endpoint_url}") + users_endpoint = json_api.endpoint(user_endpoint_url) + users_endpoint_response = users_endpoint.get() + + # If there are more, there will be a 'next' link + next_user_endpoint = users_endpoint_response.content.links.get("next") + if next_user_endpoint: + user_endpoint_url = next_user_endpoint["href"] + else: + user_endpoint_url = None + + 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_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"] + ) + else: + print(f"No study sites for user {user.attributes['display_name']}") + + # no uid is blocked or anonymous + if not drupal_uid: + print( + f"Skipping blocked or anonymous user {user.attributes['display_name']} {user}" + ) + # FIXME DEACTIVATE if exists in our system + continue + + try: + sa = SocialAccount.objects.get( + uid=user.attributes["drupal_internal__uid"], + provider=CustomProvider.id, + ) + except ObjectDoesNotExist: + print( + f"NO SA found for user {user.attributes['drupal_internal__uid']} {user}" + ) + drupal_user = get_user_model()() + drupal_user.username = drupal_username + drupal_user.email = drupal_email + drupal_user.save() + sa = SocialAccount.objects.create( + user=drupal_user, + uid=user.attributes["drupal_internal__uid"], + provider=CustomProvider.id, + ) + else: + print(f"Found {sa} for {user}") + user_changed = drupal_adapter.update_user_info( + user=sa.user, + extra_data={ + "preferred_username": drupal_username, + "first_name": drupal_firstname, + "last_name": drupal_lastname, + "email": drupal_email, + }, + apply_update=should_update, + ) + if user_changed: + pass + user_sites_changed = drupal_adapter.update_user_study_sites( + user=sa.user, + extra_data={ + "study_site_or_center": drupal_user_study_site_shortnames + }, + ) + if user_sites_changed: + pass + + drupal_uids.add(sa.user.id) + user_count += 1 + if user_count > max_users: + break + if user_count > max_users: + break + + # find active drupal users that we did not account before + # unaudited_drupal_accounts = SocialAccount.objects.filter( + # provider=CustomProvider.id, user__is_active=True + # ).exclude(uid__in=drupal_uids) + return issues + + +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 + + +def audit_drupal_study_sites(study_sites, should_update=False): + + valid_nodes = set() + results = [] + + 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: + if should_update is True: + study_site = StudySite.objects.create( + drupal_node_id=node_id, short_name=short_name, full_name=full_name + ) + results.append( + { + "result_type": "new", + "data_type": "study_site", + "data": study_site_info, + } + ) + else: + if study_site.full_name != full_name or study_site.short_name != short_name: + study_site.full_name = full_name + study_site.short_name = short_name + if should_update is True: + study_site.save() + results.append( + { + "result_type": "update", + "data_type": "study_site", + "data": study_site_info, + } + ) + + invalid_study_sites = StudySite.objects.exclude(drupal_node_id__in=valid_nodes) + + for iss in invalid_study_sites: + results.append( + { + "result_type": "issue", + "issue_type": "invalid_site", + "data_type": "study_site", + "data": iss, + } + ) + + return UserDataAuditResults(results) 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..af25f5a5 --- /dev/null +++ b/primed/users/management/commands/sync-drupal-data.py @@ -0,0 +1,25 @@ +import logging + +from django.core.management.base import BaseCommand + +from primed.users.audit import drupal_data_user_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, + ) + + def handle(self, *args, **options): + should_update = options.get("update") + + status = drupal_data_user_audit(should_update=should_update) + print(f"Issues {status}") diff --git a/primed/users/tests/test_audit.py b/primed/users/tests/test_audit.py new file mode 100644 index 00000000..b8a8a4dd --- /dev/null +++ b/primed/users/tests/test_audit.py @@ -0,0 +1,186 @@ +import json +import time + +import responses +from django.conf import settings +from django.test import TestCase +from marshmallow_jsonapi import Schema, fields + +from primed.users.audit import ( + audit_drupal_study_sites, + get_drupal_json_api, + get_study_sites, +) +from primed.users.models import StudySite + + +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): + 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(schema="StudySiteSchema") + + 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 = [ + { + "id": "1", + "drupal_internal__nid": "1", + "title": "SS1", + "field_long_name": "S S 1", + # "document_meta": {"page": {"offset": 10}}, + }, + { + "id": "2", + "drupal_internal__nid": "2", + "title": "SS2", + "field_long_name": "S S 2", + # "document_meta": {"page": {"offset": 10}}, + }, +] + + +class TestStudySiteAudit(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", + "refresh_token": "sldvafkjw34509s8dfsdfTEST", + "expires_in": 3600, + "expires_at": fake_time + 3600, + } + + def add_fake_study_sites_response(self): + url_path = f"{settings.DRUPAL_SITE_URL}/jsonapi/node/study_site_or_center/" + responses.get( + url=url_path, + body=json.dumps(StudySiteSchema(many=True).dump(TEST_STUDY_SITE_DATA)), + ) + + def get_fake_json_api(self): + token_url = f"{settings.DRUPAL_SITE_URL}/oauth/token" + responses.post(url=token_url, body=json.dumps(self.token)) + return get_drupal_json_api() + + @responses.activate + def test_get_json_api(self): + json_api = self.get_fake_json_api() + # print(f"JSONAPI: {json_api.requests.config.AUTH._client.token}") + 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 = 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): + json_api = self.get_fake_json_api() + self.add_fake_study_sites_response() + study_sites = get_study_sites(json_api=json_api) + audit_results = audit_drupal_study_sites( + study_sites=study_sites, should_update=False + ) + assert audit_results.encountered_issues() is False + assert StudySite.objects.all().count() == 0 + + @responses.activate + def test_audit_study_sites_with_new_sites(self): + json_api = self.get_fake_json_api() + self.add_fake_study_sites_response() + study_sites = get_study_sites(json_api=json_api) + audit_results = audit_drupal_study_sites( + study_sites=study_sites, should_update=True + ) + assert audit_results.encountered_issues() is False + assert audit_results.count_new_rows() == 2 + assert StudySite.objects.all().count() == 2 + assert StudySite.objects.filter( + short_name=TEST_STUDY_SITE_DATA[0]["title"] + ).exists() + + @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=TEST_STUDY_SITE_DATA[0]["title"], + full_name="WrongTitle", + ) + json_api = self.get_fake_json_api() + self.add_fake_study_sites_response() + study_sites = get_study_sites(json_api=json_api) + audit_results = audit_drupal_study_sites( + study_sites=study_sites, should_update=True + ) + assert audit_results.encountered_issues() is False + assert audit_results.count_new_rows() == 1 + assert audit_results.count_update_rows() == 1 + assert 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"] + + @responses.activate + def test_audit_study_sites_with_extra_site(self): + StudySite.objects.create( + drupal_node_id=99, short_name="ExtraSite", full_name="ExtraSiteLong" + ) + json_api = self.get_fake_json_api() + self.add_fake_study_sites_response() + study_sites = get_study_sites(json_api=json_api) + audit_results = audit_drupal_study_sites( + study_sites=study_sites, should_update=True + ) + assert audit_results.encountered_issues() is True From ff2fd462b6d51642a803fb6a1fad545703a55a79 Mon Sep 17 00:00:00 2001 From: Jonas Carson Date: Fri, 8 Dec 2023 12:02:08 -0800 Subject: [PATCH 002/102] Update dependencies needed for json api interactions and mocking --- requirements/requirements.in | 3 +++ requirements/requirements.txt | 8 +++++++- requirements/test-requirements.in | 3 +++ requirements/test-requirements.txt | 5 +++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/requirements/requirements.in b/requirements/requirements.in index 26cbc527..f1d3a324 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 diff --git a/requirements/requirements.txt b/requirements/requirements.txt index eadb904b..53e5d183 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -100,10 +100,13 @@ idna==3.3 # via requests importlib-metadata==7.0.0 # via build +importlib-resources==6.1.1 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 @@ -174,6 +177,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 @@ -194,7 +198,9 @@ sqlparse==0.4.4 tablib==3.5.0 # via -r requirements/requirements.in tenacity==8.2.1 - # 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..0c212980 100644 --- a/requirements/test-requirements.in +++ b/requirements/test-requirements.in @@ -18,3 +18,6 @@ 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 368064f8..f6ac1f1b 100644 --- a/requirements/test-requirements.txt +++ b/requirements/test-requirements.txt @@ -34,9 +34,14 @@ idna==3.3 # requests iniconfig==1.1.1 # 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.3.0 From d88a6763d361de30f93f817b27ec69205ce13923 Mon Sep 17 00:00:00 2001 From: Jonas Carson Date: Fri, 12 Jan 2024 10:25:35 -0800 Subject: [PATCH 003/102] Add jsonapi relative path to settings and env --- .env.dist | 1 + config/settings/base.py | 1 + primed/users/tests/test_audit.py | 42 ++++++++++++++++++++++++++++++- requirements/test-requirements.in | 1 - 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.env.dist b/.env.dist index 28c2dafa..b9e98292 100644 --- a/.env.dist +++ b/.env.dist @@ -27,3 +27,4 @@ DJANGO_EMAIL_USE_TLS= # drupal api DRUPAL_API_CLIENT_ID= DRUPAL_API_CLIENT_SECRET= +DRUPAL_API_REL_PATH= diff --git a/config/settings/base.py b/config/settings/base.py index d7d54957..561a4913 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -389,3 +389,4 @@ 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="") diff --git a/primed/users/tests/test_audit.py b/primed/users/tests/test_audit.py index b8a8a4dd..69f4fd34 100644 --- a/primed/users/tests/test_audit.py +++ b/primed/users/tests/test_audit.py @@ -10,6 +10,8 @@ audit_drupal_study_sites, get_drupal_json_api, get_study_sites, + drupal_data_study_site_audit, + drupal_data_user_audit, ) from primed.users.models import StudySite @@ -26,6 +28,8 @@ class Meta: class UserSchema(Schema): + id = fields.Str(dump_only=True) + display_name = fields.Str() drupal_internal__uid = fields.Str() name = fields.Str() mail = fields.Str() @@ -65,6 +69,19 @@ class Meta: }, ] +TEST_USER_DATA = [ + { + "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": [TEST_STUDY_SITE_DATA[0]], + } +] + class TestStudySiteAudit(TestCase): """General tests of the user audit""" @@ -88,9 +105,19 @@ def add_fake_study_sites_response(self): body=json.dumps(StudySiteSchema(many=True).dump(TEST_STUDY_SITE_DATA)), ) - def get_fake_json_api(self): + def add_fake_users_response(self): + url_path = f"{settings.DRUPAL_SITE_URL}/jsonapi/user/user/" + responses.get( + url=url_path, + body=json.dumps(UserSchema(many=True).dump(TEST_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 get_drupal_json_api() @responses.activate @@ -134,6 +161,12 @@ def test_audit_study_sites_no_update(self): assert audit_results.encountered_issues() is False assert StudySite.objects.all().count() == 0 + @responses.activate + def test_full_site_audit(self): + self.add_fake_token_response() + self.add_fake_study_sites_response() + results = drupal_data_study_site_audit() + @responses.activate def test_audit_study_sites_with_new_sites(self): json_api = self.get_fake_json_api() @@ -184,3 +217,10 @@ def test_audit_study_sites_with_extra_site(self): study_sites=study_sites, should_update=True ) assert audit_results.encountered_issues() is True + + @responses.activate + def test_full_user_audit(self): + self.add_fake_token_response() + self.add_fake_study_sites_response() + self.add_fake_users_response() + results = drupal_data_user_audit() diff --git a/requirements/test-requirements.in b/requirements/test-requirements.in index 0c212980..c5e95951 100644 --- a/requirements/test-requirements.in +++ b/requirements/test-requirements.in @@ -20,4 +20,3 @@ django-coverage-plugin # https://github.com/nedbat/django_coverage_plugin coverage # Mock json api data marshmallow-jsonapi - From c82c5df786233c7554386f70c0cfd6b2d5ef8cb7 Mon Sep 17 00:00:00 2001 From: Jonas Carson Date: Fri, 12 Jan 2024 10:26:29 -0800 Subject: [PATCH 004/102] Iterative refining of the audit results and user data audit tests --- primed/users/audit.py | 231 +++++++++++------- .../management/commands/sync-drupal-data.py | 9 +- primed/users/tests/test_audit.py | 34 ++- 3 files changed, 166 insertions(+), 108 deletions(-) diff --git a/primed/users/audit.py b/primed/users/audit.py index 6198313c..262630fc 100644 --- a/primed/users/audit.py +++ b/primed/users/audit.py @@ -11,33 +11,101 @@ from primed.users.adapters import SocialAccountAdapter -class UserDataAuditResults: - def __init__(self, results): - self.results = results +class AuditResults: + def __init__(self): + self.results = [] + self.data_type = None + + # Data from api was not able to be handled + RESULT_TYPE_ISSUE = "issue" + # A new record was created during audit + RESULT_TYPE_NEW = "new" + # An existing record was updated + RESULT_TYPE_UPDATE = "update" + # A record was removed or deactivated + RESULT_TYPE_REMOVAL = "removed" + + def add_new(self, data): + self.results.append( + { + "data_type": self.data_type, + "result_type": self.RESULT_TYPE_NEW, + "data": data, + } + ) + + def add_update(self, data): + self.results.append( + { + "data_type": self.data_type, + "result_type": self.RESULT_TYPE_UPDATE, + "data": data, + } + ) - ISSUE_RESULT_TYPE = "issue" - NEW_RESULT_TYPE = "new" - UPDATE_RESULT_TYPE = "update" + def add_issue(self, data): + self.results.append( + { + "data_type": self.data_type, + "result_type": self.RESULT_TYPE_ISSUE, + "data": data, + } + ) - def encountered_issues(self): + def add_removal(self, data): + self.results.append( + { + "data_type": self.data_type, + "result_type": self.RESULT_TYPE_REMOVAL, + "data": data, + } + ) + + def rows_by_result_type(self, result_type): + found = [] for row in self.results: - if row["result_type"] == self.ISSUE_RESULT_TYPE: - return True - return False + if row["result_type"] == result_type: + found.append(row) + return found + + def row_count_by_result_type(self, result_type): + return len(self.rows_by_result_type(result_type)) def count_new_rows(self): - new_count = 0 - for row in self.results: - if row["result_type"] == self.NEW_RESULT_TYPE: - new_count += 1 - return new_count + return self.row_count_by_result_type(self.RESULT_TYPE_NEW) def count_update_rows(self): - update_count = 0 - for row in self.results: - if row["result_type"] == self.UPDATE_RESULT_TYPE: - update_count += 1 - return update_count + return self.row_count_by_result_type(self.RESULT_TYPE_UPDATE) + + def count_removal_rows(self): + return self.row_count_by_result_type(self.RESULT_TYPE_REMOVAL) + + def count_issue_rows(self): + return self.row_count_by_result_type(self.RESULT_TYPE_ISSUE) + + def encountered_issues(self): + return self.count_issue_rows() > 0 + + def __str__(self) -> str: + return ( + f"Audit for {self.data_type} " + f"Issues: {self.count_issue_rows()} " + f"New: {self.count_new_rows()} " + f"Updates: {self.count_update_rows()} " + f"Removals: {self.count_removal_rows()}" + ) + + +class UserAuditResults(AuditResults): + def __init__(self): + super().__init__() + self.data_type = "user" + + +class SiteAuditResults(AuditResults): + def __init__(self): + super().__init__() + self.data_type = "site" def get_drupal_json_api(): @@ -48,6 +116,8 @@ def get_drupal_json_api(): 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, @@ -56,7 +126,7 @@ def get_drupal_json_api(): drupal_api = jsonapi_requests.Api.config( { - "API_ROOT": f"{settings.DRUPAL_SITE_URL}/jsonapi", + "API_ROOT": api_root, "AUTH": OAuth2(client=client, client_id=json_api_client_id, token=token), "VALIDATE_SSL": True, } @@ -64,37 +134,37 @@ def get_drupal_json_api(): return drupal_api -def drupal_data_study_site_audit(should_update=False): +def drupal_data_study_site_audit(apply_changes=False): json_api = get_drupal_json_api() study_sites = get_study_sites(json_api) status = audit_drupal_study_sites( - study_sites=study_sites, should_update=should_update + study_sites=study_sites, apply_changes=apply_changes ) - # audit_drupal_users(study_sites=study_sites, should_update=should_update) + return status -def drupal_data_user_audit(should_update=False): +def drupal_data_user_audit(apply_changes=False): json_api = get_drupal_json_api() study_sites = get_study_sites(json_api=json_api) status = audit_drupal_users( - study_sites=study_sites, should_update=should_update, json_api=json_api + study_sites=study_sites, apply_changes=apply_changes, json_api=json_api ) return status -def audit_drupal_users(study_sites, json_api, should_update=False): +def audit_drupal_users(study_sites, json_api, apply_changes=False): - issues = [] + audit_results = UserAuditResults() user_endpoint_url = "user/user" drupal_uids = set() drupal_adapter = SocialAccountAdapter() - max_users = 3 + user_count = 0 while user_endpoint_url is not None: - print(f"GETTING {user_endpoint_url}") + users_endpoint = json_api.endpoint(user_endpoint_url) users_endpoint_response = users_endpoint.get() @@ -111,6 +181,9 @@ def audit_drupal_users(study_sites, json_api, should_update=False): 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" ) @@ -123,15 +196,10 @@ def audit_drupal_users(study_sites, json_api, should_update=False): drupal_user_study_site_shortnames.append( study_site_info["short_name"] ) - else: - print(f"No study sites for user {user.attributes['display_name']}") - + is_new_user = False # no uid is blocked or anonymous if not drupal_uid: - print( - f"Skipping blocked or anonymous user {user.attributes['display_name']} {user}" - ) - # FIXME DEACTIVATE if exists in our system + # FIXME - deactivate if not anonymous and present locally continue try: @@ -140,53 +208,45 @@ def audit_drupal_users(study_sites, json_api, should_update=False): provider=CustomProvider.id, ) except ObjectDoesNotExist: - print( - f"NO SA found for user {user.attributes['drupal_internal__uid']} {user}" - ) drupal_user = get_user_model()() drupal_user.username = drupal_username + drupal_user.name = drupal_full_name drupal_user.email = drupal_email drupal_user.save() + is_new_user = True sa = SocialAccount.objects.create( user=drupal_user, uid=user.attributes["drupal_internal__uid"], provider=CustomProvider.id, ) - else: - print(f"Found {sa} for {user}") - user_changed = drupal_adapter.update_user_info( - user=sa.user, - extra_data={ - "preferred_username": drupal_username, - "first_name": drupal_firstname, - "last_name": drupal_lastname, - "email": drupal_email, - }, - apply_update=should_update, - ) - if user_changed: - pass - user_sites_changed = drupal_adapter.update_user_study_sites( - user=sa.user, - extra_data={ - "study_site_or_center": drupal_user_study_site_shortnames - }, - ) - if user_sites_changed: - pass + audit_results.add_new(data=user) + + user_changed = drupal_adapter.update_user_info( + user=sa.user, + extra_data={ + "preferred_username": drupal_username, + "first_name": drupal_firstname, + "last_name": drupal_lastname, + "email": drupal_email, + }, + apply_update=apply_changes, + ) + + user_sites_changed = drupal_adapter.update_user_study_sites( + user=sa.user, + extra_data={"study_site_or_center": drupal_user_study_site_shortnames}, + ) + if user_changed or user_sites_changed and not is_new_user: + audit_results.add_update(data=user) - drupal_uids.add(sa.user.id) + drupal_uids.add(sa.user.id) user_count += 1 - if user_count > max_users: - break - if user_count > max_users: - break # find active drupal users that we did not account before # unaudited_drupal_accounts = SocialAccount.objects.filter( # provider=CustomProvider.id, user__is_active=True # ).exclude(uid__in=drupal_uids) - return issues + return audit_results def get_study_sites(json_api): @@ -207,10 +267,10 @@ def get_study_sites(json_api): return study_sites_info -def audit_drupal_study_sites(study_sites, should_update=False): +def audit_drupal_study_sites(study_sites, apply_changes=False): valid_nodes = set() - results = [] + audit_results = SiteAuditResults() for study_site_info in study_sites.values(): @@ -222,41 +282,22 @@ def audit_drupal_study_sites(study_sites, should_update=False): try: study_site = StudySite.objects.get(drupal_node_id=node_id) except ObjectDoesNotExist: - if should_update is True: + if apply_changes is True: study_site = StudySite.objects.create( drupal_node_id=node_id, short_name=short_name, full_name=full_name ) - results.append( - { - "result_type": "new", - "data_type": "study_site", - "data": study_site_info, - } - ) + audit_results.add_new(data=study_site_info) else: if study_site.full_name != full_name or study_site.short_name != short_name: study_site.full_name = full_name study_site.short_name = short_name - if should_update is True: + if apply_changes is True: study_site.save() - results.append( - { - "result_type": "update", - "data_type": "study_site", - "data": study_site_info, - } - ) + audit_results.add_update(data=study_site_info) invalid_study_sites = StudySite.objects.exclude(drupal_node_id__in=valid_nodes) for iss in invalid_study_sites: - results.append( - { - "result_type": "issue", - "issue_type": "invalid_site", - "data_type": "study_site", - "data": iss, - } - ) + audit_results.add_issue(data=iss) - return UserDataAuditResults(results) + return audit_results diff --git a/primed/users/management/commands/sync-drupal-data.py b/primed/users/management/commands/sync-drupal-data.py index af25f5a5..b409775d 100644 --- a/primed/users/management/commands/sync-drupal-data.py +++ b/primed/users/management/commands/sync-drupal-data.py @@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand -from primed.users.audit import drupal_data_user_audit +from primed.users.audit import drupal_data_study_site_audit, drupal_data_user_audit logger = logging.getLogger(__name__) @@ -21,5 +21,8 @@ def add_arguments(self, parser): def handle(self, *args, **options): should_update = options.get("update") - status = drupal_data_user_audit(should_update=should_update) - print(f"Issues {status}") + user_audit_results = drupal_data_user_audit(apply_changes=should_update) + print(f"User Audit Results {user_audit_results}") + + site_audit_results = drupal_data_study_site_audit(apply_changes=should_update) + print(f"Site Audit Results {site_audit_results}") diff --git a/primed/users/tests/test_audit.py b/primed/users/tests/test_audit.py index 69f4fd34..a5146b33 100644 --- a/primed/users/tests/test_audit.py +++ b/primed/users/tests/test_audit.py @@ -3,15 +3,16 @@ import responses from django.conf import settings +from django.contrib.auth import get_user_model from django.test import TestCase from marshmallow_jsonapi import Schema, fields from primed.users.audit import ( audit_drupal_study_sites, - get_drupal_json_api, - get_study_sites, drupal_data_study_site_audit, drupal_data_user_audit, + get_drupal_json_api, + get_study_sites, ) from primed.users.models import StudySite @@ -78,12 +79,13 @@ class Meta: "mail": "testuser1@test.com", "field_given_first_name_s_": "test1", "field_examples_family_last_name_": "user1", + "full_name": "test1 user1", "field_study_site_or_center": [TEST_STUDY_SITE_DATA[0]], } ] -class TestStudySiteAudit(TestCase): +class TestUserDataAudit(TestCase): """General tests of the user audit""" def setUp(self): @@ -99,14 +101,16 @@ def setUp(self): } def add_fake_study_sites_response(self): - url_path = f"{settings.DRUPAL_SITE_URL}/jsonapi/node/study_site_or_center/" + 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}/jsonapi/user/user/" + url_path = ( + f"{settings.DRUPAL_SITE_URL}/{settings.DRUPAL_API_REL_PATH}/user/user/" + ) responses.get( url=url_path, body=json.dumps(UserSchema(many=True).dump(TEST_USER_DATA)), @@ -123,7 +127,6 @@ def get_fake_json_api(self): @responses.activate def test_get_json_api(self): json_api = self.get_fake_json_api() - # print(f"JSONAPI: {json_api.requests.config.AUTH._client.token}") assert ( json_api.requests.config.AUTH._client.token["access_token"] == self.token["access_token"] @@ -156,7 +159,7 @@ def test_audit_study_sites_no_update(self): self.add_fake_study_sites_response() study_sites = get_study_sites(json_api=json_api) audit_results = audit_drupal_study_sites( - study_sites=study_sites, should_update=False + study_sites=study_sites, apply_changes=False ) assert audit_results.encountered_issues() is False assert StudySite.objects.all().count() == 0 @@ -166,6 +169,7 @@ def test_full_site_audit(self): self.add_fake_token_response() self.add_fake_study_sites_response() results = drupal_data_study_site_audit() + assert results.encountered_issues() is False @responses.activate def test_audit_study_sites_with_new_sites(self): @@ -173,7 +177,7 @@ def test_audit_study_sites_with_new_sites(self): self.add_fake_study_sites_response() study_sites = get_study_sites(json_api=json_api) audit_results = audit_drupal_study_sites( - study_sites=study_sites, should_update=True + study_sites=study_sites, apply_changes=True ) assert audit_results.encountered_issues() is False assert audit_results.count_new_rows() == 2 @@ -193,7 +197,7 @@ def test_audit_study_sites_with_site_update(self): self.add_fake_study_sites_response() study_sites = get_study_sites(json_api=json_api) audit_results = audit_drupal_study_sites( - study_sites=study_sites, should_update=True + study_sites=study_sites, apply_changes=True ) assert audit_results.encountered_issues() is False assert audit_results.count_new_rows() == 1 @@ -214,7 +218,7 @@ def test_audit_study_sites_with_extra_site(self): self.add_fake_study_sites_response() study_sites = get_study_sites(json_api=json_api) audit_results = audit_drupal_study_sites( - study_sites=study_sites, should_update=True + study_sites=study_sites, apply_changes=True ) assert audit_results.encountered_issues() is True @@ -224,3 +228,13 @@ def test_full_user_audit(self): self.add_fake_study_sites_response() self.add_fake_users_response() results = drupal_data_user_audit() + assert results.encountered_issues() is False + assert results.count_new_rows() == 1 + assert results.count_update_rows() == 0 + assert results.count_removal_rows() == 0 + + users = get_user_model().objects.all() + assert users.count() == 1 + assert users.first().name == TEST_USER_DATA[0]["full_name"] + assert users.first().email == TEST_USER_DATA[0]["mail"] + assert users.first().username == TEST_USER_DATA[0]["name"] From be1eb4152edd061a9a3ffb8710f747ef12b17525 Mon Sep 17 00:00:00 2001 From: Jonas Carson Date: Thu, 25 Jan 2024 09:54:45 -0800 Subject: [PATCH 005/102] Rework test audit structure to support relationships users->sites. --- config/settings/base.py | 2 +- primed/users/audit.py | 7 ++ primed/users/tests/test_audit.py | 136 +++++++++++++++++++++---------- 3 files changed, 101 insertions(+), 44 deletions(-) diff --git a/config/settings/base.py b/config/settings/base.py index 561a4913..27898fdc 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -389,4 +389,4 @@ 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="") +DRUPAL_API_REL_PATH = env("DRUPAL_API_REL_PATH", default="mockapi") diff --git a/primed/users/audit.py b/primed/users/audit.py index 262630fc..c83accf3 100644 --- a/primed/users/audit.py +++ b/primed/users/audit.py @@ -1,3 +1,5 @@ +import logging + import jsonapi_requests from allauth.socialaccount.models import SocialAccount from django.conf import settings @@ -10,6 +12,8 @@ from primed.primed_anvil.models import StudySite from primed.users.adapters import SocialAccountAdapter +logger = logging.getLogger(__name__) + class AuditResults: def __init__(self): @@ -231,6 +235,9 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): }, apply_update=apply_changes, ) + logger.info( + f"for user {user} ss_short_names {drupal_user_study_site_shortnames}" + ) user_sites_changed = drupal_adapter.update_user_study_sites( user=sa.user, diff --git a/primed/users/tests/test_audit.py b/primed/users/tests/test_audit.py index a5146b33..3b3df53b 100644 --- a/primed/users/tests/test_audit.py +++ b/primed/users/tests/test_audit.py @@ -17,6 +17,36 @@ 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() @@ -36,7 +66,9 @@ class UserSchema(Schema): mail = fields.Str() field_given_first_name_s_ = fields.Str() field_examples_family_last_name_ = fields.Str() - field_study_site_or_center = fields.Relationship(schema="StudySiteSchema") + field_study_site_or_center = fields.Relationship( + many=True, schema="StudySiteSchema", type_="node--study_site_or_center" + ) class Meta: type_ = "users" @@ -54,34 +86,39 @@ class Meta: TEST_STUDY_SITE_DATA = [ - { - "id": "1", - "drupal_internal__nid": "1", - "title": "SS1", - "field_long_name": "S S 1", - # "document_meta": {"page": {"offset": 10}}, - }, - { - "id": "2", - "drupal_internal__nid": "2", - "title": "SS2", - "field_long_name": "S S 2", - # "document_meta": {"page": {"offset": 10}}, - }, + 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 = [ - { - "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", - "full_name": "test1 user1", - "field_study_site_or_center": [TEST_STUDY_SITE_DATA[0]], - } + 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": [], + } + ) ] @@ -111,9 +148,14 @@ 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) + print(f"USER DATA: {user_data}") responses.get( url=url_path, - body=json.dumps(UserSchema(many=True).dump(TEST_USER_DATA)), + body=json.dumps(user_data), ) def add_fake_token_response(self): @@ -141,16 +183,16 @@ def test_get_study_sites(self): 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"] + 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"] + 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"] + test_study_site.drupal_internal__nid + == study_sites[test_study_site.drupal_internal__nid]["node_id"] ) @responses.activate @@ -183,14 +225,14 @@ def test_audit_study_sites_with_new_sites(self): assert audit_results.count_new_rows() == 2 assert StudySite.objects.all().count() == 2 assert StudySite.objects.filter( - short_name=TEST_STUDY_SITE_DATA[0]["title"] + short_name=TEST_STUDY_SITE_DATA[0].title ).exists() @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=TEST_STUDY_SITE_DATA[0]["title"], + drupal_node_id=TEST_STUDY_SITE_DATA[0].drupal_internal__nid, + short_name=TEST_STUDY_SITE_DATA[0].title, full_name="WrongTitle", ) json_api = self.get_fake_json_api() @@ -203,11 +245,9 @@ def test_audit_study_sites_with_site_update(self): assert audit_results.count_new_rows() == 1 assert audit_results.count_update_rows() == 1 assert StudySite.objects.all().count() == 2 - first_test_ss = StudySite.objects.get( - short_name=TEST_STUDY_SITE_DATA[0]["title"] - ) + 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.full_name == TEST_STUDY_SITE_DATA[0].field_long_name @responses.activate def test_audit_study_sites_with_extra_site(self): @@ -227,6 +267,11 @@ 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, + ) results = drupal_data_user_audit() assert results.encountered_issues() is False assert results.count_new_rows() == 1 @@ -235,6 +280,11 @@ def test_full_user_audit(self): users = get_user_model().objects.all() assert users.count() == 1 - assert users.first().name == TEST_USER_DATA[0]["full_name"] - assert users.first().email == TEST_USER_DATA[0]["mail"] - assert users.first().username == TEST_USER_DATA[0]["name"] + + 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 + ) From 7e8711a786d9ff397f15efd66af30448721e3208 Mon Sep 17 00:00:00 2001 From: Jonas Carson Date: Thu, 25 Jan 2024 11:35:10 -0800 Subject: [PATCH 006/102] Fix issue where we were not correctly capturing all drupal uids audited --- primed/users/audit.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/primed/users/audit.py b/primed/users/audit.py index c83accf3..141b69d0 100644 --- a/primed/users/audit.py +++ b/primed/users/audit.py @@ -47,12 +47,13 @@ def add_update(self, data): } ) - def add_issue(self, data): + def add_issue(self, data, issue_type): self.results.append( { "data_type": self.data_type, "result_type": self.RESULT_TYPE_ISSUE, "data": data, + "issue_type": issue_type, } ) @@ -201,11 +202,12 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): study_site_info["short_name"] ) is_new_user = False + # no uid is blocked or anonymous if not drupal_uid: - # FIXME - deactivate if not anonymous and present locally + # potential blocked user, but will no longer have a drupal uid + # so we cover these below continue - try: sa = SocialAccount.objects.get( uid=user.attributes["drupal_internal__uid"], @@ -235,9 +237,6 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): }, apply_update=apply_changes, ) - logger.info( - f"for user {user} ss_short_names {drupal_user_study_site_shortnames}" - ) user_sites_changed = drupal_adapter.update_user_study_sites( user=sa.user, @@ -246,13 +245,19 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): if user_changed or user_sites_changed and not is_new_user: audit_results.add_update(data=user) - drupal_uids.add(sa.user.id) + drupal_uids.add(drupal_uid) user_count += 1 # find active drupal users that we did not account before - # unaudited_drupal_accounts = SocialAccount.objects.filter( - # provider=CustomProvider.id, user__is_active=True - # ).exclude(uid__in=drupal_uids) + # these may include blocked users + + unaudited_drupal_accounts = SocialAccount.objects.filter( + provider=CustomProvider.id, user__is_active=True + ).exclude(uid__in=drupal_uids) + + for uda in unaudited_drupal_accounts: + audit_results.add_issue(data=uda, issue_type="Local account not in drupal") + return audit_results @@ -305,6 +310,6 @@ def audit_drupal_study_sites(study_sites, apply_changes=False): invalid_study_sites = StudySite.objects.exclude(drupal_node_id__in=valid_nodes) for iss in invalid_study_sites: - audit_results.add_issue(data=iss) + audit_results.add_issue(data=iss, issue_type="Local site not in drupal") return audit_results From 98512df06bccdf921a9149afa564256f8b9a1dd3 Mon Sep 17 00:00:00 2001 From: Jonas Carson Date: Thu, 25 Jan 2024 11:48:08 -0800 Subject: [PATCH 007/102] Make sure we apply no updates unless we are in update mode --- primed/users/audit.py | 48 ++++++++++--------- .../management/commands/sync-drupal-data.py | 18 ++++++- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/primed/users/audit.py b/primed/users/audit.py index 141b69d0..201c9c50 100644 --- a/primed/users/audit.py +++ b/primed/users/audit.py @@ -208,6 +208,7 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): # 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"], @@ -218,30 +219,33 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): drupal_user.username = drupal_username drupal_user.name = drupal_full_name drupal_user.email = drupal_email - drupal_user.save() + if apply_changes is True: + drupal_user.save() is_new_user = True - sa = SocialAccount.objects.create( - user=drupal_user, - uid=user.attributes["drupal_internal__uid"], - provider=CustomProvider.id, - ) + if apply_changes is True: + sa = SocialAccount.objects.create( + user=drupal_user, + uid=user.attributes["drupal_internal__uid"], + provider=CustomProvider.id, + ) audit_results.add_new(data=user) - - user_changed = drupal_adapter.update_user_info( - user=sa.user, - extra_data={ - "preferred_username": drupal_username, - "first_name": drupal_firstname, - "last_name": drupal_lastname, - "email": drupal_email, - }, - apply_update=apply_changes, - ) - - user_sites_changed = drupal_adapter.update_user_study_sites( - user=sa.user, - extra_data={"study_site_or_center": drupal_user_study_site_shortnames}, - ) + if sa: + user_changed = drupal_adapter.update_user_info( + user=sa.user, + extra_data={ + "preferred_username": drupal_username, + "first_name": drupal_firstname, + "last_name": drupal_lastname, + "email": drupal_email, + }, + apply_update=apply_changes, + ) + if sa: + user_sites_changed = drupal_adapter.update_user_study_sites( + user=sa.user, + extra_data={"study_site_or_center": drupal_user_study_site_shortnames}, + apply_update=apply_changes + ) if user_changed or user_sites_changed and not is_new_user: audit_results.add_update(data=user) diff --git a/primed/users/management/commands/sync-drupal-data.py b/primed/users/management/commands/sync-drupal-data.py index b409775d..4af3a6b4 100644 --- a/primed/users/management/commands/sync-drupal-data.py +++ b/primed/users/management/commands/sync-drupal-data.py @@ -2,7 +2,11 @@ from django.core.management.base import BaseCommand -from primed.users.audit import drupal_data_study_site_audit, drupal_data_user_audit +from primed.users.audit import ( + AuditResults, + drupal_data_study_site_audit, + drupal_data_user_audit, +) logger = logging.getLogger(__name__) @@ -23,6 +27,18 @@ def handle(self, *args, **options): user_audit_results = drupal_data_user_audit(apply_changes=should_update) print(f"User Audit Results {user_audit_results}") + if user_audit_results.encountered_issues(): + print( + user_audit_results.rows_by_result_type( + result_type=AuditResults.RESULT_TYPE_ISSUE + ) + ) site_audit_results = drupal_data_study_site_audit(apply_changes=should_update) print(f"Site Audit Results {site_audit_results}") + if site_audit_results.encountered_issues(): + print( + site_audit_results.rows_by_result_type( + result_type=AuditResults.RESULT_TYPE_ISSUE + ) + ) From 7f6ef98d18a85eb52e58ba885bc5cae60624355c Mon Sep 17 00:00:00 2001 From: Jonas Carson Date: Thu, 25 Jan 2024 11:49:09 -0800 Subject: [PATCH 008/102] log whether we are in update mode --- primed/users/audit.py | 6 ++++-- primed/users/management/commands/sync-drupal-data.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/primed/users/audit.py b/primed/users/audit.py index 201c9c50..1d8e27a9 100644 --- a/primed/users/audit.py +++ b/primed/users/audit.py @@ -243,8 +243,10 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): if sa: user_sites_changed = drupal_adapter.update_user_study_sites( user=sa.user, - extra_data={"study_site_or_center": drupal_user_study_site_shortnames}, - apply_update=apply_changes + extra_data={ + "study_site_or_center": drupal_user_study_site_shortnames + }, + apply_update=apply_changes, ) if user_changed or user_sites_changed and not is_new_user: audit_results.add_update(data=user) diff --git a/primed/users/management/commands/sync-drupal-data.py b/primed/users/management/commands/sync-drupal-data.py index 4af3a6b4..8d97930e 100644 --- a/primed/users/management/commands/sync-drupal-data.py +++ b/primed/users/management/commands/sync-drupal-data.py @@ -26,7 +26,7 @@ def handle(self, *args, **options): should_update = options.get("update") user_audit_results = drupal_data_user_audit(apply_changes=should_update) - print(f"User Audit Results {user_audit_results}") + print(f"User Audit (Update: {should_update}) Results {user_audit_results}") if user_audit_results.encountered_issues(): print( user_audit_results.rows_by_result_type( @@ -35,7 +35,7 @@ def handle(self, *args, **options): ) site_audit_results = drupal_data_study_site_audit(apply_changes=should_update) - print(f"Site Audit Results {site_audit_results}") + print(f"Site Audit (Update: {should_update}) Results {site_audit_results}") if site_audit_results.encountered_issues(): print( site_audit_results.rows_by_result_type( From d4d82daaf69ceb8bd4c2d467e1e0351159e1e19a Mon Sep 17 00:00:00 2001 From: Jonas Carson Date: Fri, 26 Jan 2024 09:24:27 -0800 Subject: [PATCH 009/102] Add gitleaks:allow to test secrets --- primed/users/tests/test_audit.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/primed/users/tests/test_audit.py b/primed/users/tests/test_audit.py index 3b3df53b..0f3b7c85 100644 --- a/primed/users/tests/test_audit.py +++ b/primed/users/tests/test_audit.py @@ -131,8 +131,8 @@ def setUp(self): fake_time = time.time() self.token = { "token_type": "Bearer", - "access_token": "asdfoiw37850234lkjsdfsdfTEST", - "refresh_token": "sldvafkjw34509s8dfsdfTEST", + "access_token": "asdfoiw37850234lkjsdfsdfTEST", # gitleaks:allow + "refresh_token": "sldvafkjw34509s8dfsdfTEST", # gitleaks:allow "expires_in": 3600, "expires_at": fake_time + 3600, } @@ -272,7 +272,7 @@ def test_full_user_audit(self): short_name=TEST_STUDY_SITE_DATA[0].title, full_name=TEST_STUDY_SITE_DATA[0].field_long_name, ) - results = drupal_data_user_audit() + results = drupal_data_user_audit(apply_changes=True) assert results.encountered_issues() is False assert results.count_new_rows() == 1 assert results.count_update_rows() == 0 @@ -288,3 +288,6 @@ def test_full_user_audit(self): users.first().study_sites.first().short_name == TEST_STUDY_SITE_DATA[0].title ) + + # test user removal + # test user change From 8af23068ffc217b7356da7091ced09b4621c309f Mon Sep 17 00:00:00 2001 From: Jonas Carson Date: Fri, 2 Feb 2024 11:38:16 -0800 Subject: [PATCH 010/102] More clearly capture what is updated during audit. Add flag based support for deactiving missing and blocked drupal users --- config/settings/base.py | 3 + primed/users/audit.py | 95 ++++++++++++++------- primed/users/tests/test_audit.py | 137 ++++++++++++++++++++++++++++++- 3 files changed, 201 insertions(+), 34 deletions(-) diff --git a/config/settings/base.py b/config/settings/base.py index 27898fdc..89e433f3 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -390,3 +390,6 @@ 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 +) diff --git a/primed/users/audit.py b/primed/users/audit.py index 1d8e27a9..3c4f3a92 100644 --- a/primed/users/audit.py +++ b/primed/users/audit.py @@ -10,7 +10,6 @@ from primed.drupal_oauth_provider.provider import CustomProvider from primed.primed_anvil.models import StudySite -from primed.users.adapters import SocialAccountAdapter logger = logging.getLogger(__name__) @@ -38,12 +37,13 @@ def add_new(self, data): } ) - def add_update(self, data): + def add_update(self, data, updates): self.results.append( { "data_type": self.data_type, "result_type": self.RESULT_TYPE_UPDATE, "data": data, + "updates": updates, } ) @@ -145,7 +145,6 @@ def drupal_data_study_site_audit(apply_changes=False): status = audit_drupal_study_sites( study_sites=study_sites, apply_changes=apply_changes ) - return status @@ -165,8 +164,6 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): user_endpoint_url = "user/user" drupal_uids = set() - drupal_adapter = SocialAccountAdapter() - user_count = 0 while user_endpoint_url is not None: @@ -201,8 +198,9 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): drupal_user_study_site_shortnames.append( study_site_info["short_name"] ) - is_new_user = False - + 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 @@ -221,7 +219,7 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): drupal_user.email = drupal_email if apply_changes is True: drupal_user.save() - is_new_user = True + drupal_user.study_sites.set(new_user_sites) if apply_changes is True: sa = SocialAccount.objects.create( user=drupal_user, @@ -229,32 +227,50 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): provider=CustomProvider.id, ) audit_results.add_new(data=user) + if sa: - user_changed = drupal_adapter.update_user_info( - user=sa.user, - extra_data={ - "preferred_username": drupal_username, - "first_name": drupal_firstname, - "last_name": drupal_lastname, - "email": drupal_email, - }, - apply_update=apply_changes, - ) - if sa: - user_sites_changed = drupal_adapter.update_user_study_sites( - user=sa.user, - extra_data={ - "study_site_or_center": drupal_user_study_site_shortnames - }, - apply_update=apply_changes, + 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 + + prev_user_sites = set( + sa.user.study_sites.all().values_list("short_name", flat=True) ) - if user_changed or user_sites_changed and not is_new_user: - audit_results.add_update(data=user) + if prev_user_sites != set(drupal_user_study_site_shortnames): + user_updates.update( + { + "sites": { + "old": prev_user_sites, + "new": drupal_user_study_site_shortnames, + } + } + ) + + sa.user.study_sites.set(new_user_sites) + + if user_updates: + if apply_changes is True: + sa.user.save() + audit_results.add_update(data=user, updates=user_updates) drupal_uids.add(drupal_uid) user_count += 1 - # find active drupal users that we did not account before + # 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( @@ -262,7 +278,10 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): ).exclude(uid__in=drupal_uids) for uda in unaudited_drupal_accounts: - audit_results.add_issue(data=uda, issue_type="Local account not in drupal") + if settings.DRUPAL_DATA_AUDIT_DEACTIVATE_USERS is True: + uda.user.is_active = False + uda.user.save() + audit_results.add_removal(data=uda) return audit_results @@ -306,12 +325,26 @@ def audit_drupal_study_sites(study_sites, apply_changes=False): ) audit_results.add_new(data=study_site_info) else: - if study_site.full_name != full_name or study_site.short_name != short_name: + 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 apply_changes is True: study_site.save() - audit_results.add_update(data=study_site_info) + audit_results.add_update( + data=study_site_info, updates=study_site_updates + ) invalid_study_sites = StudySite.objects.exclude(drupal_node_id__in=valid_nodes) diff --git a/primed/users/tests/test_audit.py b/primed/users/tests/test_audit.py index 0f3b7c85..c5a85738 100644 --- a/primed/users/tests/test_audit.py +++ b/primed/users/tests/test_audit.py @@ -2,11 +2,13 @@ import time import responses +from allauth.socialaccount.models import SocialAccount from django.conf import settings from django.contrib.auth import get_user_model from django.test import TestCase from marshmallow_jsonapi import Schema, fields +from primed.drupal_oauth_provider.provider import CustomProvider from primed.users.audit import ( audit_drupal_study_sites, drupal_data_study_site_audit, @@ -118,7 +120,20 @@ class Meta: "field_examples_family_last_name_": "user1", "field_study_site_or_center": [], } - ) + ), + # second mock object is deactivated user (no drupal uid) + UserMockObject( + **{ + "id": "usr1", + "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": [], + } + ), ] @@ -232,7 +247,7 @@ def test_audit_study_sites_with_new_sites(self): 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=TEST_STUDY_SITE_DATA[0].title, + short_name="WrongShortName", full_name="WrongTitle", ) json_api = self.get_fake_json_api() @@ -248,6 +263,7 @@ def test_audit_study_sites_with_site_update(self): 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): @@ -273,6 +289,7 @@ def test_full_user_audit(self): full_name=TEST_STUDY_SITE_DATA[0].field_long_name, ) results = drupal_data_user_audit(apply_changes=True) + print(f"RESULTS: {results} -- {results.results}") assert results.encountered_issues() is False assert results.count_new_rows() == 1 assert results.count_update_rows() == 0 @@ -289,5 +306,119 @@ def test_full_user_audit(self): == 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, + ) + results = drupal_data_user_audit(apply_changes=False) + + assert results.encountered_issues() is False + assert results.count_new_rows() == 1 + assert results.count_update_rows() == 0 + assert results.count_removal_rows() == 0 + + # verify we did not actually create a user + users = get_user_model().objects.all() + assert users.count() == 0 + + @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", + ) + SocialAccount.objects.create( + user=new_user, + uid=TEST_USER_DATA[0].drupal_internal__uid, + provider=CustomProvider.id, + ) + results = drupal_data_user_audit(apply_changes=True) + new_user.refresh_from_db() + + assert new_user.name == drupal_fullname + assert results.encountered_issues() is False + assert results.count_new_rows() == 0 + assert results.count_update_rows() == 1 + assert results.count_removal_rows() == 0 + + # 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, + ) + results = drupal_data_user_audit(apply_changes=True) + + new_user.refresh_from_db() + assert new_user.is_active is True + assert results.encountered_issues() is False + assert results.count_new_rows() == 1 + assert results.count_update_rows() == 0 + assert results.count_removal_rows() == 1 + + # 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, + ) + with self.settings(DRUPAL_DATA_AUDIT_DEACTIVATE_USERS=True): + results = drupal_data_user_audit(apply_changes=True) + + new_user.refresh_from_db() + assert new_user.is_active is False + assert results.encountered_issues() is False + assert results.count_new_rows() == 1 + assert results.count_update_rows() == 0 + assert results.count_removal_rows() == 1 + # test user removal - # test user change From 11d106bb052a8f9f9e5c144c55ad1093eb987ee7 Mon Sep 17 00:00:00 2001 From: Jonas Carson Date: Fri, 8 Mar 2024 10:28:34 -0800 Subject: [PATCH 011/102] Add more coverage, more clear handling or not handling of issues. Rudimentary reporting to stdout --- primed/users/audit.py | 66 ++++++++++++++-- .../management/commands/sync-drupal-data.py | 78 ++++++++++++++----- primed/users/tests/test_audit.py | 55 ++++++++++++- 3 files changed, 169 insertions(+), 30 deletions(-) diff --git a/primed/users/audit.py b/primed/users/audit.py index 3c4f3a92..34b5e007 100644 --- a/primed/users/audit.py +++ b/primed/users/audit.py @@ -28,6 +28,8 @@ def __init__(self): # A record was removed or deactivated RESULT_TYPE_REMOVAL = "removed" + ISSUE_TYPE_USER_INACTIVE = "user_inactive" + def add_new(self, data): self.results.append( { @@ -100,6 +102,55 @@ def __str__(self) -> str: f"Removals: {self.count_removal_rows()}" ) + def display_result(self, result): + result_string = "" + result_type = result["result_type"] + result_string += f"{result['result_type']} {self.data_type}" + if self.data_type == "user": + if isinstance(result["data"], SocialAccount): + result_string += "\tUSERNAME: {}\tUID: {}".format( + result["data"].user.username, result["data"].uid + ) + else: + result_string += "\tUSERNAME: {}\tUID: {}".format( + result["data"].attributes["display_name"], + result["data"].attributes["drupal_internal__uid"], + ) + elif self.data_type == "site": + if isinstance(result["data"], StudySite): + result_string += "\tShortName: {}FullName: {}\tNodeID: {}".format( + result["data"].short_name, + result["data"].full_name, + result["data"].drupal_node_id, + ) + else: + result_string += "\tShortName: {}FullName: {}\tNodeID: {}".format( + result["data"]["short_name"], + result["data"]["full_name"], + result["data"]["node_id"], + ) + if result_type == self.RESULT_TYPE_UPDATE: + result_string += "\t{result.get('updates')}" + if result_type == self.RESULT_TYPE_ISSUE: + result_string += f"\tIssue Type: {result.get('issue_type')}" + return result_string + + def detailed_issues(self): + result_string = "" + for row in self.rows_by_result_type(result_type=self.RESULT_TYPE_ISSUE): + result_string += self.display_result(row) + result_string += "\n" + + return result_string + + def detailed_results(self): + result_string = "" + for row in self.results: + result_string += self.display_result(row) + result_string += "\n" + + return result_string + class UserAuditResults(AuditResults): def __init__(self): @@ -171,11 +222,10 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): users_endpoint_response = users_endpoint.get() # If there are more, there will be a 'next' link - next_user_endpoint = users_endpoint_response.content.links.get("next") - if next_user_endpoint: - user_endpoint_url = next_user_endpoint["href"] - else: - user_endpoint_url = None + + 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") @@ -281,7 +331,11 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): if settings.DRUPAL_DATA_AUDIT_DEACTIVATE_USERS is True: uda.user.is_active = False uda.user.save() - audit_results.add_removal(data=uda) + audit_results.add_removal(data=uda) + else: + audit_results.add_issue( + data=uda, issue_type=AuditResults.ISSUE_TYPE_USER_INACTIVE + ) return audit_results diff --git a/primed/users/management/commands/sync-drupal-data.py b/primed/users/management/commands/sync-drupal-data.py index 8d97930e..ead59479 100644 --- a/primed/users/management/commands/sync-drupal-data.py +++ b/primed/users/management/commands/sync-drupal-data.py @@ -2,17 +2,16 @@ from django.core.management.base import BaseCommand -from primed.users.audit import ( - AuditResults, - drupal_data_study_site_audit, - drupal_data_user_audit, -) +from primed.users.audit import drupal_data_study_site_audit, drupal_data_user_audit logger = logging.getLogger(__name__) class Command(BaseCommand): help = "Sync drupal user and domain data" + NOTIFY_NONE = "none" + NOTIFY_ALL = "all" + NOTIFY_ISSUES = "issues" def add_arguments(self, parser): parser.add_argument( @@ -20,25 +19,64 @@ def add_arguments(self, parser): 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( + "--verbose", + action="store_true", + dest="verbose", + default=False, + help="Log verbosely", + ) + parser.add_argument( + "--notify", + dest="notify", + choices=[self.NOTIFY_NONE, self.NOTIFY_ALL, self.NOTIFY_ISSUES], + default=self.NOTIFY_ALL, + help=f"Notification level: (default: {self.NOTIFY_ALL})", ) def handle(self, *args, **options): - should_update = options.get("update") + apply_changes = options.get("update") + be_verbose = options.get("verbose") + notify_type = options.get("notify") - user_audit_results = drupal_data_user_audit(apply_changes=should_update) - print(f"User Audit (Update: {should_update}) Results {user_audit_results}") - if user_audit_results.encountered_issues(): - print( - user_audit_results.rows_by_result_type( - result_type=AuditResults.RESULT_TYPE_ISSUE - ) + site_audit_results = drupal_data_study_site_audit(apply_changes=apply_changes) + logger.info( + f"Site Audit (Update: {apply_changes}) Results summary: {site_audit_results}" + ) + detailed_site_audit_results = site_audit_results.detailed_results() + + user_audit_results = drupal_data_user_audit(apply_changes=apply_changes) + logger.info( + f"User Audit (Update: {apply_changes}) Results summary: {user_audit_results}" + ) + detailed_user_audit_results = user_audit_results.detailed_results() + + if be_verbose: + logger.debug( + f"User Audit Results:\n{user_audit_results.detailed_results()}" ) + logger.debug( + f"Study Site Audit Results:\n{site_audit_results.detailed_results()}" + ) + + notification_content = "" + if user_audit_results.encountered_issues(): + notification_content += "Encountered user audit issues:\n" + notification_content += user_audit_results.detailed_issues() + else: + notification_content += "No user audit issues.\n" - site_audit_results = drupal_data_study_site_audit(apply_changes=should_update) - print(f"Site Audit (Update: {should_update}) Results {site_audit_results}") if site_audit_results.encountered_issues(): - print( - site_audit_results.rows_by_result_type( - result_type=AuditResults.RESULT_TYPE_ISSUE - ) - ) + notification_content += "Encountered site audit issues:\n" + notification_content += site_audit_results.detailed_issues() + else: + notification_content += "No site audit issues.\n" + + if notify_type == self.NOTIFY_ALL: + notification_content += detailed_site_audit_results + notification_content += detailed_user_audit_results + notification_content += "sync-drupal-data audit complete\n" + + self.stdout.write(notification_content) diff --git a/primed/users/tests/test_audit.py b/primed/users/tests/test_audit.py index c5a85738..1a821d3a 100644 --- a/primed/users/tests/test_audit.py +++ b/primed/users/tests/test_audit.py @@ -1,10 +1,12 @@ import json import time +from io import StringIO import responses from allauth.socialaccount.models import SocialAccount from django.conf import settings from django.contrib.auth import get_user_model +from django.core.management import call_command from django.test import TestCase from marshmallow_jsonapi import Schema, fields @@ -242,6 +244,7 @@ def test_audit_study_sites_with_new_sites(self): assert StudySite.objects.filter( short_name=TEST_STUDY_SITE_DATA[0].title ).exists() + self.assertRegex(audit_results.detailed_results(), "^new site") @responses.activate def test_audit_study_sites_with_site_update(self): @@ -289,7 +292,7 @@ def test_full_user_audit(self): full_name=TEST_STUDY_SITE_DATA[0].field_long_name, ) results = drupal_data_user_audit(apply_changes=True) - print(f"RESULTS: {results} -- {results.results}") + assert results.encountered_issues() is False assert results.count_new_rows() == 1 assert results.count_update_rows() == 0 @@ -305,6 +308,7 @@ def test_full_user_audit(self): users.first().study_sites.first().short_name == TEST_STUDY_SITE_DATA[0].title ) + self.assertRegex(results.detailed_results(), "^new user") @responses.activate def test_full_user_audit_check_only(self): @@ -361,6 +365,7 @@ def test_user_audit_change_user(self): assert results.count_new_rows() == 0 assert results.count_update_rows() == 1 assert results.count_removal_rows() == 0 + self.assertRegex(results.detailed_results(), "^update user") # test user removal @responses.activate @@ -386,10 +391,17 @@ def test_user_audit_remove_user_only_inform(self): new_user.refresh_from_db() assert new_user.is_active is True - assert results.encountered_issues() is False + assert results.encountered_issues() is True assert results.count_new_rows() == 1 assert results.count_update_rows() == 0 - assert results.count_removal_rows() == 1 + assert results.count_removal_rows() == 0 + assert results.count_issue_rows() == 1 + issue_rows = results.rows_by_result_type(results.RESULT_TYPE_ISSUE) + assert len(issue_rows) == 1 + assert issue_rows[0]["issue_type"] == results.ISSUE_TYPE_USER_INACTIVE + # assert not empty + assert results.detailed_issues() + self.assertRegex(str(results), "Issues: 1") # test user removal @responses.activate @@ -421,4 +433,39 @@ def test_user_audit_remove_user(self): assert results.count_update_rows() == 0 assert results.count_removal_rows() == 1 - # test user removal + @responses.activate + def test_sync_drupal_data_command(self): + self.add_fake_token_response() + self.add_fake_study_sites_response() + 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("sync-drupal-data audit complete", 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_token_response() + self.add_fake_study_sites_response() + self.add_fake_users_response() + out = StringIO() + call_command("sync-drupal-data", "--verbose", stdout=out) + self.assertIn("sync-drupal-data audit complete", out.getvalue()) From e7d650716326908bf8822a49d34f0280f8dc6291 Mon Sep 17 00:00:00 2001 From: Jonas Carson Date: Fri, 8 Mar 2024 11:19:49 -0800 Subject: [PATCH 012/102] Add option for capturing and reporting user site removal but not making updates. --- config/settings/base.py | 3 ++ primed/users/audit.py | 32 +++++++++++--- primed/users/tests/test_audit.py | 73 +++++++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 8 deletions(-) diff --git a/config/settings/base.py b/config/settings/base.py index 23524297..5b1b7b96 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -397,3 +397,6 @@ 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/users/audit.py b/primed/users/audit.py index 34b5e007..0c35983b 100644 --- a/primed/users/audit.py +++ b/primed/users/audit.py @@ -29,6 +29,7 @@ def __init__(self): RESULT_TYPE_REMOVAL = "removed" ISSUE_TYPE_USER_INACTIVE = "user_inactive" + ISSUE_TYPE_USER_REMOVED_FROM_SITE = "user_site_removal" def add_new(self, data): self.results.append( @@ -49,13 +50,14 @@ def add_update(self, data, updates): } ) - def add_issue(self, data, issue_type): + def add_issue(self, data, issue_type, issue_extra=None): self.results.append( { "data_type": self.data_type, "result_type": self.RESULT_TYPE_ISSUE, "data": data, "issue_type": issue_type, + "issue_extra": issue_extra, } ) @@ -296,20 +298,36 @@ def audit_drupal_users(study_sites, json_api, apply_changes=False): ) sa.user.email = drupal_email - prev_user_sites = set( + prev_user_site_names = set( sa.user.study_sites.all().values_list("short_name", flat=True) ) - if prev_user_sites != set(drupal_user_study_site_shortnames): + 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_sites, - "new": drupal_user_study_site_shortnames, + "old": prev_user_site_names, + "new": new_user_site_names, } } ) - - sa.user.study_sites.set(new_user_sites) + # do not remove from sites by default + removed_sites = prev_user_site_names.difference(new_user_sites) + new_sites = new_user_site_names.difference(prev_user_site_names) + + if settings.DRUPAL_DATA_AUDIT_REMOVE_USER_SITES is True: + sa.user.study_sites.set(new_user_sites) + else: + if removed_sites: + audit_results.add_issue( + data=user, + issue_type=audit_results.ISSUE_TYPE_USER_REMOVED_FROM_SITE, + issue_extra=f"Removed Sites: {removed_sites}", + ) + if new_sites: + for new_site in new_user_sites: + if new_site.short_name in new_user_site_names: + sa.user.study_sites.add(new_site) if user_updates: if apply_changes is True: diff --git a/primed/users/tests/test_audit.py b/primed/users/tests/test_audit.py index 1a821d3a..7a2a7536 100644 --- a/primed/users/tests/test_audit.py +++ b/primed/users/tests/test_audit.py @@ -126,7 +126,7 @@ class Meta: # second mock object is deactivated user (no drupal uid) UserMockObject( **{ - "id": "usr1", + "id": "usr2", "display_name": "dnusr2", "drupal_internal__uid": "", "name": "testuser2", @@ -331,6 +331,77 @@ def test_full_user_audit_check_only(self): 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, + ) + results = drupal_data_user_audit(apply_changes=True) + assert results.encountered_issues() is True + issue_rows = results.rows_by_result_type(results.RESULT_TYPE_ISSUE) + assert len(issue_rows) == 1 + assert issue_rows[0]["issue_type"] == results.ISSUE_TYPE_USER_REMOVED_FROM_SITE + 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): + results = drupal_data_user_audit(apply_changes=True) + assert results.encountered_issues() is False + + 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() From 2580cade29e57e621280e8af5434684faf94a8db Mon Sep 17 00:00:00 2001 From: Wienwipa Kirdpoo Date: Fri, 8 Mar 2024 14:13:59 -0800 Subject: [PATCH 013/102] Add associate data prep workspace to extra context in dbGaPWorkspace adapter Add tests Add an associate data prep workspace table to the dbGaP workspace template --- primed/dbgap/adapters.py | 8 ++++++++ primed/dbgap/tests/test_views.py | 18 ++++++++++++++++++ .../dbgap/dbgapworkspace_detail.html | 19 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/primed/dbgap/adapters.py b/primed/dbgap/adapters.py index 5c567a5b..e88c1c21 100644 --- a/primed/dbgap/adapters.py +++ b/primed/dbgap/adapters.py @@ -1,5 +1,7 @@ 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 DataPrepWorkspaceTable from . import forms, models, tables @@ -16,3 +18,9 @@ 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_workspace"] = DataPrepWorkspaceTable(associated_data_prep) + return extra_context diff --git a/primed/dbgap/tests/test_views.py b/primed/dbgap/tests/test_views.py index e345cbaf..6f23299a 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 DataPrepWorkspaceTable +from primed.miscellaneous_workspaces.tests.factories import DataPrepWorkspaceFactory from primed.primed_anvil.tests.factories import ( # DataUseModifierFactory,; DataUsePermissionFactory, StudyFactory, ) @@ -951,6 +953,22 @@ def test_links_audit_access_view_permission(self): ), ) + def test_associated_data_prep_workspace_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_workspace", response.context_data) + self.assertIsInstance(response.context_data["associated_data_prep_workspace"], DataPrepWorkspaceTable) + + def test_only_show_correct_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_workspace", response.context_data) + self.assertEqual(len(response.context_data["associated_data_prep_workspace"].rows), 1) + self.assertIn(dataPrep_obj, response.context_data["associated_data_prep_workspace"].data) + class dbGaPWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): """Tests of the WorkspaceCreate view from ACM with this app's dbGaPWorkspace model.""" diff --git a/primed/templates/dbgap/dbgapworkspace_detail.html b/primed/templates/dbgap/dbgapworkspace_detail.html index 944beecd..ee502ded 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,24 @@

+
+
+
+

+ +

+
+
+ {% render_table associated_data_prep_workspace %} +
+
+
+
+
+ {{block.super}} {% endblock after_panel %} From 0d79ba20e06be9b84180818d1184cb186614708f Mon Sep 17 00:00:00 2001 From: Wienwipa Kirdpoo Date: Fri, 8 Mar 2024 14:22:20 -0800 Subject: [PATCH 014/102] Ran pre-commit --- primed/dbgap/adapters.py | 9 +++++++-- primed/dbgap/tests/test_views.py | 17 +++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/primed/dbgap/adapters.py b/primed/dbgap/adapters.py index e88c1c21..5315e764 100644 --- a/primed/dbgap/adapters.py +++ b/primed/dbgap/adapters.py @@ -1,6 +1,7 @@ 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 DataPrepWorkspaceTable from . import forms, models, tables @@ -21,6 +22,10 @@ class dbGaPWorkspaceAdapter(BaseWorkspaceAdapter): 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_workspace"] = DataPrepWorkspaceTable(associated_data_prep) + associated_data_prep = Workspace.objects.filter( + dataprepworkspace__target_workspace=workspace + ) + extra_context["associated_data_prep_workspace"] = DataPrepWorkspaceTable( + associated_data_prep + ) return extra_context diff --git a/primed/dbgap/tests/test_views.py b/primed/dbgap/tests/test_views.py index 6f23299a..676acd3d 100644 --- a/primed/dbgap/tests/test_views.py +++ b/primed/dbgap/tests/test_views.py @@ -958,16 +958,25 @@ def test_associated_data_prep_workspace_context_exists(self): self.client.force_login(self.user) response = self.client.get(obj.get_absolute_url()) self.assertIn("associated_data_prep_workspace", response.context_data) - self.assertIsInstance(response.context_data["associated_data_prep_workspace"], DataPrepWorkspaceTable) + self.assertIsInstance( + response.context_data["associated_data_prep_workspace"], + DataPrepWorkspaceTable, + ) def test_only_show_correct_associated_data_prep_workspace(self): dbGaP_obj = factories.dbGaPWorkspaceFactory.create() - dataPrep_obj = DataPrepWorkspaceFactory.create(target_workspace=dbGaP_obj.workspace) + 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_workspace", response.context_data) - self.assertEqual(len(response.context_data["associated_data_prep_workspace"].rows), 1) - self.assertIn(dataPrep_obj, response.context_data["associated_data_prep_workspace"].data) + self.assertEqual( + len(response.context_data["associated_data_prep_workspace"].rows), 1 + ) + self.assertIn( + dataPrep_obj, response.context_data["associated_data_prep_workspace"].data + ) class dbGaPWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): From 2f02d18f004eff25e91a5c957d60fe991f6e1329 Mon Sep 17 00:00:00 2001 From: Wienwipa Kirdpoo Date: Mon, 11 Mar 2024 12:58:27 -0700 Subject: [PATCH 015/102] fixed a broken test for showing correct associated data prep workspace --- primed/dbgap/tests/test_views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/primed/dbgap/tests/test_views.py b/primed/dbgap/tests/test_views.py index 676acd3d..5b880395 100644 --- a/primed/dbgap/tests/test_views.py +++ b/primed/dbgap/tests/test_views.py @@ -975,7 +975,8 @@ def test_only_show_correct_associated_data_prep_workspace(self): len(response.context_data["associated_data_prep_workspace"].rows), 1 ) self.assertIn( - dataPrep_obj, response.context_data["associated_data_prep_workspace"].data + dataPrep_obj.workspace, + response.context_data["associated_data_prep_workspace"].data, ) From 056e4e9d2bfb900e5f91daf477b5419fb829c754 Mon Sep 17 00:00:00 2001 From: Wienwipa Kirdpoo Date: Mon, 11 Mar 2024 15:03:43 -0700 Subject: [PATCH 016/102] Add associate data prep workspace to extra context in CDSA Workspace adapter Add view tests Add an associate data prep workspace table to the CDSA workspace template --- primed/cdsa/adapters.py | 13 +++++++++ primed/cdsa/tests/test_views.py | 28 +++++++++++++++++++ .../templates/cdsa/cdsaworkspace_detail.html | 19 +++++++++++++ 3 files changed, 60 insertions(+) diff --git a/primed/cdsa/adapters.py b/primed/cdsa/adapters.py index e4409ddd..dd37688e 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 DataPrepWorkspaceTable from . import forms, models, tables @@ -18,3 +21,13 @@ 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_workspace"] = DataPrepWorkspaceTable( + associated_data_prep + ) + return extra_context diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 41a2caaf..85aa7675 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -30,6 +30,8 @@ from freezegun import freeze_time from primed.duo.tests.factories import DataUseModifierFactory, DataUsePermissionFactory +from primed.miscellaneous_workspaces.tables import DataPrepWorkspaceTable +from primed.miscellaneous_workspaces.tests.factories import DataPrepWorkspaceFactory from primed.primed_anvil.tests.factories import ( AvailableDataFactory, StudyFactory, @@ -7260,6 +7262,32 @@ def test_render_duo_modifiers(self): self.assertContains(response, modifiers[0].abbreviation) self.assertContains(response, modifiers[1].abbreviation) + def test_associated_data_prep_workspace_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_workspace", response.context_data) + self.assertIsInstance( + response.context_data["associated_data_prep_workspace"], + DataPrepWorkspaceTable, + ) + + def test_only_show_correct_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_workspace", response.context_data) + self.assertEqual( + len(response.context_data["associated_data_prep_workspace"].rows), 1 + ) + self.assertIn( + dataPrep_obj.workspace, + response.context_data["associated_data_prep_workspace"].data, + ) + class CDSAWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): """Tests of the WorkspaceCreate view from ACM with this app's CDSAWorkspace model.""" diff --git a/primed/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index 76a7952c..e1fbd3bd 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_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 %} @@ -80,5 +81,23 @@

+
+
+
+

+ +

+
+
+ {% render_table associated_data_prep_workspace %} +
+
+
+
+
+ {{block.super}} {% endblock after_panel %} From 40f1270453e8f2a745fdc43845d0f654cac0b1fe Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Mon, 11 Mar 2024 15:57:46 -0700 Subject: [PATCH 017/102] Add javascript to check length of pasted text In form input fields with the maxlength attribute, add custom javascript that prevents pasting text with more than maxlength characters. This can help prevent data truncation when pasting more characters than maxlength allows, since by default they would be silently truncated. --- primed/static/js/project.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/primed/static/js/project.js b/primed/static/js/project.js index d26d23b9..b9f177b5 100644 --- a/primed/static/js/project.js +++ b/primed/static/js/project.js @@ -1 +1,15 @@ /* Project specific Javascript goes here. */ + +// Handle paste event for text inputs with maxlength. +const checkPasteLength = (e) => { + var paste = (event.clipboardData || window.clipboardData).getData("text"); + maxlength = e.target.getAttribute("maxlength"); + if (maxlength <= paste.length) { + 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); From 6aff66404cb5e610d5ea43ba4f8405c29ae0c589 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 12 Mar 2024 10:37:08 -0700 Subject: [PATCH 018/102] Change ACM version to v0.22 This version introduces the get_extra_context_data for the workspace detail page. --- requirements/requirements.in | 2 +- requirements/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/requirements.in b/requirements/requirements.in index 5690a71a..dfc31fc7 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -33,7 +33,7 @@ django-dbbackup # https://github.com/jazzband/django-dbbackup django-extensions # https://github.com/django-extensions/django-extensions # anvil_consortium_manager -django-anvil-consortium-manager @ git+https://github.com/UW-GAC/django-anvil-consortium-manager.git@v0.21 +django-anvil-consortium-manager @ git+https://github.com/UW-GAC/django-anvil-consortium-manager.git@v0.22 # Simple history - model history tracking django-simple-history diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 8bb7d464..927e8570 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -60,7 +60,7 @@ django==4.2.10 # django-tables2 django-allauth==0.54.0 # via -r requirements/requirements.in -django-anvil-consortium-manager @ git+https://github.com/UW-GAC/django-anvil-consortium-manager.git@v0.21 +django-anvil-consortium-manager @ git+https://github.com/UW-GAC/django-anvil-consortium-manager.git@v0.22 # via -r requirements/requirements.in django-autocomplete-light==3.9.7 # via django-anvil-consortium-manager From 37f781fc7bd3b6530785b457c767e14c9286e419 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 12 Mar 2024 11:04:27 -0700 Subject: [PATCH 019/102] Add script to add example Data Prep workspaces --- add_data_prep_example_data.py | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 add_data_prep_example_data.py 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", +) From 4cab1da24b7902fec4a430fa674743a29c3e813d Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 12 Mar 2024 11:47:36 -0700 Subject: [PATCH 020/102] Add additional_limitations field to DataAffiliate CDSAs Data affiliates can specify additional limitations on what can be done with the data outside of the workspace request form. Track that information in the DataAffiliate model. Also add a clean method to verify that only primary DataAffiliateAgreements can have additional limitatoins; for now, components cannot. This could be changed in the future. --- ...reement_additional_limitations_and_more.py | 29 +++++++++++++++++++ primed/cdsa/models.py | 15 ++++++++++ primed/cdsa/tests/test_models.py | 16 ++++++++++ 3 files changed, 60 insertions(+) create mode 100644 primed/cdsa/migrations/0015_dataaffiliateagreement_additional_limitations_and_more.py 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/models.py b/primed/cdsa/models.py index 4896a3ed..8043e672 100644 --- a/primed/cdsa/models.py +++ b/primed/cdsa/models.py @@ -277,6 +277,10 @@ class DataAffiliateAgreement(TimeStampedModel, AgreementTypeModel, models.Model) 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.", + ) def get_absolute_url(self): return reverse( @@ -284,6 +288,17 @@ def get_absolute_url(self): kwargs={"cc_id": self.signed_agreement.cc_id}, ) + def clean(self): + super().clean() + if ( + self.additional_limitations + and hasattr(self, "signed_agreement") + and not self.signed_agreement.is_primary + ): + raise ValidationError( + "Additional limitations are only allowed for primary agreements." + ) + def get_agreement_group(self): return self.study diff --git a/primed/cdsa/tests/test_models.py b/primed/cdsa/tests/test_models.py index 0e41a4f5..b5b3ef93 100644 --- a/primed/cdsa/tests/test_models.py +++ b/primed/cdsa/tests/test_models.py @@ -506,6 +506,22 @@ 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( + signed_agreement__is_primary=True, + additional_limitations="foo bar", + ) + instance.full_clean() + + def test_clean_additional_limitations_not_primary(self): + instance = factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=False, + additional_limitations="foo bar", + ) + with self.assertRaises(ValidationError) as e: + instance.clean() + self.assertIn("only allowed for primary agreements", e.exception.message) + def test_str_method(self): """The custom __str__ method returns the correct string.""" instance = factories.DataAffiliateAgreementFactory.create() From 82d631bc648f7bc388261076a312497c86906aa7 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 12 Mar 2024 15:42:41 -0700 Subject: [PATCH 021/102] Add additional_limitations field to the DataAffiliateAgreement form --- primed/cdsa/forms.py | 1 + primed/cdsa/tests/test_forms.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/primed/cdsa/forms.py b/primed/cdsa/forms.py index cfa854d2..7dad0855 100644 --- a/primed/cdsa/forms.py +++ b/primed/cdsa/forms.py @@ -77,6 +77,7 @@ class Meta: fields = ( "signed_agreement", "study", + "additional_limitations", ) widgets = { "study": autocomplete.ModelSelect2( diff --git a/primed/cdsa/tests/test_forms.py b/primed/cdsa/tests/test_forms.py index 7662e422..33a25644 100644 --- a/primed/cdsa/tests/test_forms.py +++ b/primed/cdsa/tests/test_forms.py @@ -1,6 +1,7 @@ """Tests for the `cdsa` app.""" from anvil_consortium_manager.tests.factories import WorkspaceFactory +from django.core.exceptions import NON_FIELD_ERRORS from django.test import TestCase from primed.duo.models import DataUseModifier @@ -416,6 +417,35 @@ 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.""" + signed_agreement = factories.SignedAgreementFactory.create( + type=models.SignedAgreement.DATA_AFFILIATE, is_primary=True + ) + form_data = { + "signed_agreement": signed_agreement, + "study": self.study, + "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.""" + signed_agreement = factories.SignedAgreementFactory.create( + type=models.SignedAgreement.DATA_AFFILIATE, is_primary=False + ) + form_data = { + "signed_agreement": signed_agreement, + "study": self.study, + "additional_limitations": "test limitations", + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn(NON_FIELD_ERRORS, form.errors) + self.assertEqual(len(form.errors[NON_FIELD_ERRORS]), 1) + self.assertIn("only allowed for primary", form.errors[NON_FIELD_ERRORS][0]) + class NonDataAffiliateAgreementFormTest(TestCase): """Tests for the NonDataAffiliateAgreementForm class.""" From ef2e028c6c5cb5ce28efb890181f8cfd4e7ddbba Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 12 Mar 2024 16:10:47 -0700 Subject: [PATCH 022/102] Include additional_limitations for some example CDSAs --- add_cdsa_example_data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/add_cdsa_example_data.py b/add_cdsa_example_data.py index 44ef9d69..8b6a1fc8 100644 --- a/add_cdsa_example_data.py +++ b/add_cdsa_example_data.py @@ -113,6 +113,7 @@ 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.", ) GroupGroupMembershipFactory.create( parent_group=cdsa_group, child_group=cdsa_1006.signed_agreement.anvil_access_group From 9aea537e7164512bb890470ad356da1a214ea310 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 12 Mar 2024 16:11:11 -0700 Subject: [PATCH 023/102] Display additional_limitations on DataAffiliateAgreementDetail page --- primed/cdsa/tests/test_views.py | 23 +++++++++++++++++++ .../cdsa/dataaffiliateagreement_detail.html | 22 ++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 41a2caaf..d9fee48b 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -4005,6 +4005,29 @@ 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( + signed_agreement__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( + signed_agreement__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") + class DataAffiliateAgreementListTest(TestCase): """Tests for the DataAffiliateAgreement view.""" diff --git a/primed/templates/cdsa/dataaffiliateagreement_detail.html b/primed/templates/cdsa/dataaffiliateagreement_detail.html index 211b3e92..0d48c8a8 100644 --- a/primed/templates/cdsa/dataaffiliateagreement_detail.html +++ b/primed/templates/cdsa/dataaffiliateagreement_detail.html @@ -45,6 +45,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 %} From 006e6ab39f6ebfdacc5af4fe686621c851026481 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 13 Mar 2024 13:58:48 -0700 Subject: [PATCH 024/102] Add a method to retrieve the primary CDSA associated with a workspace --- primed/cdsa/models.py | 9 +++++ primed/cdsa/tests/test_models.py | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/primed/cdsa/models.py b/primed/cdsa/models.py index 8043e672..65d19567 100644 --- a/primed/cdsa/models.py +++ b/primed/cdsa/models.py @@ -352,3 +352,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, + signed_agreement__is_primary=True, + signed_agreement__status=SignedAgreement.StatusChoices.ACTIVE, + ) + return cdsa diff --git a/primed/cdsa/tests/test_models.py b/primed/cdsa/tests/test_models.py index b5b3ef93..787c8934 100644 --- a/primed/cdsa/tests/test_models.py +++ b/primed/cdsa/tests/test_models.py @@ -673,3 +673,59 @@ 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_get_primary_cdsa(self): + """get_primary_cdsa returns the primary valid CDSA for the study.""" + instance = factories.CDSAWorkspaceFactory.create() + agreement = factories.DataAffiliateAgreementFactory.create( + signed_agreement__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( + signed_agreement__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( + signed_agreement__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( + signed_agreement__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( + signed_agreement__is_primary=True, + signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, + study=instance.study, + ) + factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, + study=instance.study, + ) + with self.assertRaises(models.DataAffiliateAgreement.MultipleObjectsReturned): + instance.get_primary_cdsa() From 0e162c494162f90970948251a350e7f5e4c9fc84 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 13 Mar 2024 14:12:05 -0700 Subject: [PATCH 025/102] Include the additional CDSA limitations on detail page If a CDSA workspace is has a corresponding data affiliate CDSA with limitations, show those limitations on the detail page. --- primed/cdsa/adapters.py | 9 ++++ primed/cdsa/tests/test_views.py | 42 +++++++++++++++++++ .../templates/cdsa/cdsaworkspace_detail.html | 17 ++++++++ 3 files changed, 68 insertions(+) diff --git a/primed/cdsa/adapters.py b/primed/cdsa/adapters.py index e4409ddd..9e1d6481 100644 --- a/primed/cdsa/adapters.py +++ b/primed/cdsa/adapters.py @@ -18,3 +18,12 @@ 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): + # Get the primary CDSA for this study, assuming it exists. + extra_context = {} + 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/tests/test_views.py b/primed/cdsa/tests/test_views.py index d9fee48b..c62107d5 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -7283,6 +7283,48 @@ def test_render_duo_modifiers(self): self.assertContains(response, modifiers[0].abbreviation) self.assertContains(response, modifiers[1].abbreviation) + def test_response_context_primary_cdsa(self): + agreement = factories.DataAffiliateAgreementFactory.create( + signed_agreement__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.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( + signed_agreement__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, "Additional CDSA limitations") + self.assertContains( + response, "Test limitations for this data affiliate agreement" + ) + + def test_response_with_no_additional_limitations(self): + """Does not include DataAffiliate additional limitations if they do not exist.""" + agreement = factories.DataAffiliateAgreementFactory.create( + signed_agreement__is_primary=True, + additional_limitations="", + ) + instance = factories.CDSAWorkspaceFactory.create( + study=agreement.study, + ) + self.client.force_login(self.user) + response = self.client.get(instance.workspace.get_absolute_url()) + self.assertNotContains(response, "Additional CDSA limitations") + class CDSAWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): """Tests of the WorkspaceCreate view from ACM with this app's CDSAWorkspace model.""" diff --git a/primed/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index 76a7952c..82c9a71d 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -44,6 +44,23 @@ {% endblock workspace_data %} {% block after_panel %} +{% if primary_cdsa.additional_limitations %} +
+
+
+ + Additional CDSA limitations +
+
+

+ {{ primary_cdsa.additional_limitations }} +

+
+
+
+{% endif %} + +
From dbcf7c44e233e21b7b3e76dda64360db0f7c1d53 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 13 Mar 2024 14:27:50 -0700 Subject: [PATCH 026/102] Add method to DUO models for returning display definition This text is a shorter form of the definition that can be concatenated to create a full data use limitations statement. --- primed/duo/models.py | 5 +++++ primed/duo/tests/test_models.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/primed/duo/models.py b/primed/duo/models.py index 8a79e9b8..392efebe 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,9 @@ def get_ols_url(self): self.identifier.replace("DUO:", "DUO_") ) + def get_short_definition(self): + return re.sub(r"This .+? indicates that use", "Use", self.definition) + 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..c9e48b3c 100644 --- a/primed/duo/tests/test_models.py +++ b/primed/duo/tests/test_models.py @@ -87,6 +87,18 @@ 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 use is allowed." + ) + self.assertEqual(instance.get_short_definition(), "Use is allowed.") + class DataUseModifierTest(TestCase): """Tests for the DataUseModifier model.""" @@ -157,6 +169,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.""" From bf78c50ca16a5a42e989adf20d6f97d8831aff3b Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 13 Mar 2024 15:40:38 -0700 Subject: [PATCH 027/102] Show all relevant DULs on the CDSAWorkspaceDetail page Pull the DUO DULs from the description, and any additional limitations specified in the associated primary CDSA or the workspace. --- primed/cdsa/adapters.py | 10 +++ primed/cdsa/tests/test_views.py | 63 ++++++++++++++++--- .../templates/cdsa/cdsaworkspace_detail.html | 30 ++++----- 3 files changed, 75 insertions(+), 28 deletions(-) diff --git a/primed/cdsa/adapters.py b/primed/cdsa/adapters.py index 9e1d6481..27eaf031 100644 --- a/primed/cdsa/adapters.py +++ b/primed/cdsa/adapters.py @@ -22,8 +22,18 @@ class CDSAWorkspaceAdapter(BaseWorkspaceAdapter): def get_extra_detail_context_data(self, workspace, request): # Get the primary CDSA for this study, assuming it exists. extra_context = {} + # Data use limitations from CDSA try: extra_context["primary_cdsa"] = workspace.cdsaworkspace.get_primary_cdsa() except models.DataAffiliateAgreement.DoesNotExist: extra_context["primary_cdsa"] = None + # Data use limitations from DUOs + extra_context[ + "duo_permission_text" + ] = workspace.cdsaworkspace.data_use_permission.get_short_definition() + extra_context["duo_modifiers_text"] = [ + x.get_short_definition() + for x in workspace.cdsaworkspace.data_use_modifiers.all() + ] + return extra_context diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index c62107d5..083a9870 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -7286,7 +7286,6 @@ def test_render_duo_modifiers(self): def test_response_context_primary_cdsa(self): agreement = factories.DataAffiliateAgreementFactory.create( signed_agreement__is_primary=True, - additional_limitations="Test limitations for this data affiliate agreement", ) instance = factories.CDSAWorkspaceFactory.create( study=agreement.study, @@ -7307,23 +7306,67 @@ def test_response_includes_additional_limitations(self): ) self.client.force_login(self.user) response = self.client.get(instance.get_absolute_url()) - self.assertContains(response, "Additional CDSA limitations") self.assertContains( response, "Test limitations for this data affiliate agreement" ) - def test_response_with_no_additional_limitations(self): - """Does not include DataAffiliate additional limitations if they do not exist.""" - agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True, - additional_limitations="", + def test_response_context_duo_permission_text(self): + instance = factories.CDSAWorkspaceFactory.create( + data_use_permission__definition="Test permission.", ) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("duo_permission_text", response.context) + self.assertEqual(response.context["duo_permission_text"], "Test permission.") + + def test_response_context_duo_modifier_text_one_modifier(self): + instance = factories.CDSAWorkspaceFactory.create() + modifier = DataUseModifierFactory.create(definition="Test modifier.") + instance.data_use_modifiers.add(modifier) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("duo_modifiers_text", response.context) + self.assertEqual(len(response.context["duo_modifiers_text"]), 1) + self.assertIn("Test modifier.", response.context["duo_modifiers_text"]) + + def test_response_context_duo_modifier_text_two_modifiers(self): + instance = factories.CDSAWorkspaceFactory.create() + modifier_1 = DataUseModifierFactory.create(definition="Test modifier 1.") + modifier_2 = DataUseModifierFactory.create(definition="Test modifier 2.") + instance.data_use_modifiers.add(modifier_1, modifier_2) + self.client.force_login(self.user) + response = self.client.get(instance.get_absolute_url()) + self.assertIn("duo_modifiers_text", response.context) + self.assertEqual(len(response.context["duo_modifiers_text"]), 2) + + def test_response_data_use_limitations(self): + """All data use limitations appear in the response content.""" instance = factories.CDSAWorkspaceFactory.create( - study=agreement.study, + data_use_permission__definition="Test permission.", + data_use_limitations="Test additional limitations for workspace", + ) + modifier_1 = DataUseModifierFactory.create(definition="Test modifier 1.") + modifier_2 = DataUseModifierFactory.create(definition="Test modifier 2.") + instance.data_use_modifiers.add(modifier_1, modifier_2) + # Create an agreement with data use limitations. + factories.DataAffiliateAgreementFactory.create( + signed_agreement__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.workspace.get_absolute_url()) - self.assertNotContains(response, "Additional CDSA limitations") + response = self.client.get(instance.get_absolute_url()) + self.assertContains(response, "
  • Test permission.
  • ") + self.assertContains(response, "
  • Test modifier 1.
  • ") + self.assertContains(response, "
  • Test modifier 2.
  • ") + self.assertContains( + response, + "
  • Additional CDSA limitations: Test limitations for this data affiliate agreement
  • ", + ) + self.assertContains( + response, + "
  • Additional workspace limitations: Test additional limitations for workspace
  • ", + ) class CDSAWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): diff --git a/primed/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index 82c9a71d..d62ecbbc 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -44,23 +44,6 @@ {% endblock workspace_data %} {% block after_panel %} -{% if primary_cdsa.additional_limitations %} -
    -
    -
    - - Additional CDSA limitations -
    -
    -

    - {{ primary_cdsa.additional_limitations }} -

    -
    -
    -
    -{% endif %} - -
    @@ -72,7 +55,18 @@

    - {{ object.cdsaworkspace.data_use_limitations }} +
      +
    • {{ duo_permission_text }}
    • + {% for x in duo_modifiers_text %} +
    • {{ x }}
    • + {% endfor %} + {% if primary_cdsa.additional_limitations %} +
    • Additional CDSA limitations: {{ primary_cdsa.additional_limitations }}
    • + {% endif %} + {% if workspace_data_object.data_use_limitations %} +
    • Additional workspace limitations: {{ workspace_data_object.data_use_limitations }}
    • + {% endif %} +
    From fb5d9ef07fd7f745411ad2c78154d762d8d07446 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 13 Mar 2024 16:44:27 -0700 Subject: [PATCH 028/102] Rename field to additional_limitations For CDSA workspaces, we are only storing additional limitations on data use in this field; DUO captures most of it. Modify the field to make it reflect that. Also make it not required. --- primed/cdsa/forms.py | 2 +- ...workspace_rename_additional_limitations.py | 37 +++++++++++++++++++ primed/cdsa/models.py | 5 ++- primed/cdsa/tests/factories.py | 1 - primed/cdsa/tests/test_forms.py | 23 ++---------- primed/cdsa/tests/test_models.py | 6 ++- primed/cdsa/tests/test_views.py | 6 +-- .../templates/cdsa/cdsaworkspace_detail.html | 4 +- 8 files changed, 53 insertions(+), 31 deletions(-) create mode 100644 primed/cdsa/migrations/0016_cdsaworkspace_rename_additional_limitations.py diff --git a/primed/cdsa/forms.py b/primed/cdsa/forms.py index 7dad0855..5fbf7dab 100644 --- a/primed/cdsa/forms.py +++ b/primed/cdsa/forms.py @@ -115,7 +115,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/migrations/0016_cdsaworkspace_rename_additional_limitations.py b/primed/cdsa/migrations/0016_cdsaworkspace_rename_additional_limitations.py new file mode 100644 index 00000000..e5d21757 --- /dev/null +++ b/primed/cdsa/migrations/0016_cdsaworkspace_rename_additional_limitations.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.10 on 2024-03-13 23:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("cdsa", "0015_dataaffiliateagreement_additional_limitations_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="cdsaworkspace", + name="data_use_limitations", + ), + migrations.RemoveField( + model_name="historicalcdsaworkspace", + name="data_use_limitations", + ), + migrations.AddField( + model_name="cdsaworkspace", + name="additional_limitations", + field=models.TextField( + blank=True, + help_text="Additional data use limitations that cannot be captured by DUO.", + ), + ), + migrations.AddField( + 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/models.py b/primed/cdsa/models.py index 65d19567..0e223f55 100644 --- a/primed/cdsa/models.py +++ b/primed/cdsa/models.py @@ -333,8 +333,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." diff --git a/primed/cdsa/tests/factories.py b/primed/cdsa/tests/factories.py index 610b63b4..855a3ed4 100644 --- a/primed/cdsa/tests/factories.py +++ b/primed/cdsa/tests/factories.py @@ -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_forms.py b/primed/cdsa/tests/test_forms.py index 33a25644..3ab4573f 100644 --- a/primed/cdsa/tests/test_forms.py +++ b/primed/cdsa/tests/test_forms.py @@ -541,7 +541,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, } @@ -556,7 +555,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, } @@ -571,7 +569,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, } @@ -585,7 +582,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, } @@ -603,7 +599,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, } @@ -621,7 +616,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, } @@ -639,7 +633,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, } @@ -650,23 +643,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.""" @@ -675,7 +665,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, } @@ -693,7 +682,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, } @@ -712,7 +700,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, } @@ -731,7 +718,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, @@ -747,7 +733,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_models.py b/primed/cdsa/tests/test_models.py index 787c8934..98b9858f 100644 --- a/primed/cdsa/tests/test_models.py +++ b/primed/cdsa/tests/test_models.py @@ -632,7 +632,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, @@ -674,6 +673,11 @@ def test_available_data(self): 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() diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 083a9870..28ccb3fd 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -7343,7 +7343,7 @@ 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_limitations="Test additional limitations for workspace", + additional_limitations="Test additional limitations for workspace", ) modifier_1 = DataUseModifierFactory.create(definition="Test modifier 1.") modifier_2 = DataUseModifierFactory.create(definition="Test modifier 2.") @@ -7429,7 +7429,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, @@ -7444,7 +7443,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) @@ -7481,7 +7479,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": [ @@ -7530,7 +7527,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/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index d62ecbbc..96139435 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -63,8 +63,8 @@

    {% if primary_cdsa.additional_limitations %}
  • Additional CDSA limitations: {{ primary_cdsa.additional_limitations }}
  • {% endif %} - {% if workspace_data_object.data_use_limitations %} -
  • Additional workspace limitations: {{ workspace_data_object.data_use_limitations }}
  • + {% if workspace_data_object.additional_limitations %} +
  • Additional workspace limitations: {{ workspace_data_object.additional_limitations }}
  • {% endif %}

    From 6391f228a83e82cea2d432ac4949e43b27fc9756 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 13 Mar 2024 16:54:18 -0700 Subject: [PATCH 029/102] Use a definition list for CDSA full data use limitations This makes it easier to understand where each piece is coming from. --- primed/cdsa/tests/test_views.py | 12 ++++++++-- .../templates/cdsa/cdsaworkspace_detail.html | 23 +++++++++++++------ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 28ccb3fd..28b553c5 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -7361,11 +7361,19 @@ def test_response_data_use_limitations(self): self.assertContains(response, "
  • Test modifier 2.
  • ") self.assertContains( response, - "
  • Additional CDSA limitations: Test limitations for this data affiliate agreement
  • ", + "
    Additional CDSA limitations
    ", ) self.assertContains( response, - "
  • Additional workspace limitations: Test additional limitations for workspace
  • ", + "
  • Test limitations for this data affiliate agreement
  • ", + ) + self.assertContains( + response, + "
    Additional workspace limitations
    ", + ) + self.assertContains( + response, + "
  • Test additional limitations for workspace
  • ", ) diff --git a/primed/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index 96139435..ecb53501 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -55,16 +55,25 @@

    -
      -
    • {{ duo_permission_text }}
    • - {% for x in duo_modifiers_text %} -
    • {{ x }}
    • - {% endfor %} +
      +
      DUO consent description
      +
      +
    • {{ duo_permission_text }}
    • + {% for x in duo_modifiers_text %} +
    • {{ x }}
    • + {% endfor %} +
      {% if primary_cdsa.additional_limitations %} -
    • Additional CDSA limitations: {{ primary_cdsa.additional_limitations }}
    • +
      Additional CDSA limitations
      +
      +
    • {{ primary_cdsa.additional_limitations }}
    • +
      {% endif %} {% if workspace_data_object.additional_limitations %} -
    • Additional workspace limitations: {{ workspace_data_object.additional_limitations }}
    • +
      Additional workspace limitations
      +
      +
    • {{ workspace_data_object.additional_limitations }}
    • +
      {% endif %}
    From e3cd7322f31c2f724f3076d4a84ad36aa8a077f1 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 13 Mar 2024 17:01:47 -0700 Subject: [PATCH 030/102] Use actual DUO values for example data --- add_cdsa_example_data.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/add_cdsa_example_data.py b/add_cdsa_example_data.py index 8b6a1fc8..cccbbb89 100644 --- a/add_cdsa_example_data.py +++ b/add_cdsa_example_data.py @@ -8,14 +8,18 @@ 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 major versions major_version = factories.AgreementMajorVersionFactory.create(version=1) @@ -28,8 +32,8 @@ ) # Create a couple signed CDSAs. -dup = DataUsePermissionFactory.create(abbreviation="GRU") -dum = DataUseModifierFactory.create(abbreviation="NPU") +dup = DataUsePermission.objects.get(abbreviation="GRU") +dum = DataUseModifier.objects.get(abbreviation="NPU") # create the CDSA auth group cdsa_group = ManagedGroupFactory.create(name=settings.ANVIL_CDSA_GROUP_NAME) From 41ef2e9f832b989ba6b79e40286822df7a43fe19 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 13 Mar 2024 17:06:31 -0700 Subject: [PATCH 031/102] Just use the DUO objects directly in CDSAWorkspaceDetail template Instead of adding text to the context data, use the DUO objects to construct the text. This makes it a little more flexible. --- primed/cdsa/adapters.py | 8 ---- primed/cdsa/tests/test_views.py | 44 +++++-------------- .../templates/cdsa/cdsaworkspace_detail.html | 6 +-- 3 files changed, 13 insertions(+), 45 deletions(-) diff --git a/primed/cdsa/adapters.py b/primed/cdsa/adapters.py index 27eaf031..daf1e3b8 100644 --- a/primed/cdsa/adapters.py +++ b/primed/cdsa/adapters.py @@ -27,13 +27,5 @@ def get_extra_detail_context_data(self, workspace, request): extra_context["primary_cdsa"] = workspace.cdsaworkspace.get_primary_cdsa() except models.DataAffiliateAgreement.DoesNotExist: extra_context["primary_cdsa"] = None - # Data use limitations from DUOs - extra_context[ - "duo_permission_text" - ] = workspace.cdsaworkspace.data_use_permission.get_short_definition() - extra_context["duo_modifiers_text"] = [ - x.get_short_definition() - for x in workspace.cdsaworkspace.data_use_modifiers.all() - ] return extra_context diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 28b553c5..11e0f6fd 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -7310,43 +7310,19 @@ def test_response_includes_additional_limitations(self): response, "Test limitations for this data affiliate agreement" ) - def test_response_context_duo_permission_text(self): - instance = factories.CDSAWorkspaceFactory.create( - data_use_permission__definition="Test permission.", - ) - self.client.force_login(self.user) - response = self.client.get(instance.get_absolute_url()) - self.assertIn("duo_permission_text", response.context) - self.assertEqual(response.context["duo_permission_text"], "Test permission.") - - def test_response_context_duo_modifier_text_one_modifier(self): - instance = factories.CDSAWorkspaceFactory.create() - modifier = DataUseModifierFactory.create(definition="Test modifier.") - instance.data_use_modifiers.add(modifier) - self.client.force_login(self.user) - response = self.client.get(instance.get_absolute_url()) - self.assertIn("duo_modifiers_text", response.context) - self.assertEqual(len(response.context["duo_modifiers_text"]), 1) - self.assertIn("Test modifier.", response.context["duo_modifiers_text"]) - - def test_response_context_duo_modifier_text_two_modifiers(self): - instance = factories.CDSAWorkspaceFactory.create() - modifier_1 = DataUseModifierFactory.create(definition="Test modifier 1.") - modifier_2 = DataUseModifierFactory.create(definition="Test modifier 2.") - instance.data_use_modifiers.add(modifier_1, modifier_2) - self.client.force_login(self.user) - response = self.client.get(instance.get_absolute_url()) - self.assertIn("duo_modifiers_text", response.context) - self.assertEqual(len(response.context["duo_modifiers_text"]), 2) - 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(definition="Test modifier 1.") - modifier_2 = DataUseModifierFactory.create(definition="Test modifier 2.") + 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( @@ -7356,9 +7332,9 @@ def test_response_data_use_limitations(self): ) self.client.force_login(self.user) response = self.client.get(instance.get_absolute_url()) - self.assertContains(response, "
  • Test permission.
  • ") - self.assertContains(response, "
  • Test modifier 1.
  • ") - self.assertContains(response, "
  • Test modifier 2.
  • ") + self.assertContains(response, "
  • P: Test permission.
  • ") + self.assertContains(response, "
  • M1: Test modifier 1.
  • ") + self.assertContains(response, "
  • M2: Test modifier 2.
  • ") self.assertContains( response, "
    Additional CDSA limitations
    ", diff --git a/primed/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index ecb53501..8cc2ec6d 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -58,9 +58,9 @@

    DUO consent description
    -
  • {{ duo_permission_text }}
  • - {% for x in duo_modifiers_text %} -
  • {{ x }}
  • +
  • {{ workspace_data_object.data_use_permission.abbreviation }}: {{ workspace_data_object.data_use_permission.get_short_definition }}
  • + {% for x in workspace_data_object.data_use_modifiers.all %} +
  • {{ x.abbreviation }}: {{ x.get_short_definition }}
  • {% endfor %}
    {% if primary_cdsa.additional_limitations %} From 0e8540d1b8e312ceeee13fc7bddd4e3ac24d943c Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 13 Mar 2024 17:11:29 -0700 Subject: [PATCH 032/102] Rearrange and reword full data use limitations for clarity --- primed/cdsa/tests/test_views.py | 4 ++-- primed/templates/cdsa/cdsaworkspace_detail.html | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 11e0f6fd..2795e0ce 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -7337,7 +7337,7 @@ def test_response_data_use_limitations(self): self.assertContains(response, "
  • M2: Test modifier 2.
  • ") self.assertContains( response, - "
    Additional CDSA limitations
    ", + "
    Additional limitations from CDSA
    ", ) self.assertContains( response, @@ -7345,7 +7345,7 @@ def test_response_data_use_limitations(self): ) self.assertContains( response, - "
    Additional workspace limitations
    ", + "
    Additional limitations for this consent group
    ", ) self.assertContains( response, diff --git a/primed/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index 8cc2ec6d..61e9cd07 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -63,16 +63,16 @@

  • {{ x.abbreviation }}: {{ x.get_short_definition }}
  • {% endfor %} - {% if primary_cdsa.additional_limitations %} -
    Additional CDSA limitations
    + {% if workspace_data_object.additional_limitations %} +
    Additional limitations for this consent group
    -
  • {{ primary_cdsa.additional_limitations }}
  • +
  • {{ workspace_data_object.additional_limitations }}
  • {% endif %} - {% if workspace_data_object.additional_limitations %} -
    Additional workspace limitations
    + {% if primary_cdsa.additional_limitations %} +
    Additional limitations from CDSA
    -
  • {{ workspace_data_object.additional_limitations }}
  • +
  • {{ primary_cdsa.additional_limitations }}
  • {% endif %} From 42504079707d5d4412a0188958e8c0e0f839e34d Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 13 Mar 2024 17:34:16 -0700 Subject: [PATCH 033/102] More flexible approach to DUO get_short_description Remove the first bit but do not assume that everything should start with Use. Capitalize the first letter only while keeping the rest of the capitalization intact. --- primed/duo/models.py | 5 ++++- primed/duo/tests/test_models.py | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/primed/duo/models.py b/primed/duo/models.py index 392efebe..7c6efb25 100644 --- a/primed/duo/models.py +++ b/primed/duo/models.py @@ -44,7 +44,10 @@ def get_ols_url(self): ) def get_short_definition(self): - return re.sub(r"This .+? indicates that use", "Use", self.definition) + 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): diff --git a/primed/duo/tests/test_models.py b/primed/duo/tests/test_models.py index c9e48b3c..baeb1416 100644 --- a/primed/duo/tests/test_models.py +++ b/primed/duo/tests/test_models.py @@ -95,9 +95,15 @@ def test_get_short_definition(self): def test_get_short_definition_re_sub(self): instance = factories.DataUsePermissionFactory.create( - definition="This XXX indicates that use is allowed." + definition="This XXX indicates that everything is fine." ) - self.assertEqual(instance.get_short_definition(), "Use is allowed.") + 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): From f725a44a355bbeb523cf161b9cd9e6f83d860580 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 14 Mar 2024 11:37:55 -0700 Subject: [PATCH 034/102] Add additional limitations for one CDSA workspace --- add_cdsa_example_data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/add_cdsa_example_data.py b/add_cdsa_example_data.py index cccbbb89..b091fe94 100644 --- a/add_cdsa_example_data.py +++ b/add_cdsa_example_data.py @@ -224,5 +224,6 @@ 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) From 704c525ff1582aad4918da3d9dc15239851d50c7 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 14 Mar 2024 11:38:08 -0700 Subject: [PATCH 035/102] Redo migrations to rename data_use_limitations field The previous migration dropped the old field and created a new one, instead of renaming the field as intended. Redo the migrations so the field is renamed instead of dropped and re-created. --- ...rkspace_additional_limitations_and_more.py | 23 +++++++++++++++++++ ...kspace_additional_limitations_and_more.py} | 19 ++++++--------- 2 files changed, 30 insertions(+), 12 deletions(-) create mode 100644 primed/cdsa/migrations/0016_rename_data_use_limitations_cdsaworkspace_additional_limitations_and_more.py rename primed/cdsa/migrations/{0016_cdsaworkspace_rename_additional_limitations.py => 0017_alter_cdsaworkspace_additional_limitations_and_more.py} (61%) 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/0016_cdsaworkspace_rename_additional_limitations.py b/primed/cdsa/migrations/0017_alter_cdsaworkspace_additional_limitations_and_more.py similarity index 61% rename from primed/cdsa/migrations/0016_cdsaworkspace_rename_additional_limitations.py rename to primed/cdsa/migrations/0017_alter_cdsaworkspace_additional_limitations_and_more.py index e5d21757..79b56330 100644 --- a/primed/cdsa/migrations/0016_cdsaworkspace_rename_additional_limitations.py +++ b/primed/cdsa/migrations/0017_alter_cdsaworkspace_additional_limitations_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-03-13 23:02 +# Generated by Django 4.2.10 on 2024-03-14 18:36 from django.db import migrations, models @@ -6,19 +6,14 @@ class Migration(migrations.Migration): dependencies = [ - ("cdsa", "0015_dataaffiliateagreement_additional_limitations_and_more"), + ( + "cdsa", + "0016_rename_data_use_limitations_cdsaworkspace_additional_limitations_and_more", + ), ] operations = [ - migrations.RemoveField( - model_name="cdsaworkspace", - name="data_use_limitations", - ), - migrations.RemoveField( - model_name="historicalcdsaworkspace", - name="data_use_limitations", - ), - migrations.AddField( + migrations.AlterField( model_name="cdsaworkspace", name="additional_limitations", field=models.TextField( @@ -26,7 +21,7 @@ class Migration(migrations.Migration): help_text="Additional data use limitations that cannot be captured by DUO.", ), ), - migrations.AddField( + migrations.AlterField( model_name="historicalcdsaworkspace", name="additional_limitations", field=models.TextField( From a04e85d6f9f435238fddbe418fc910d27b7a7d26 Mon Sep 17 00:00:00 2001 From: Wienwipa Kirdpoo Date: Thu, 14 Mar 2024 14:08:02 -0700 Subject: [PATCH 036/102] Change 'workspace' to 'workspaces' --- primed/cdsa/adapters.py | 2 +- primed/cdsa/tests/test_views.py | 14 +++++++------- primed/dbgap/adapters.py | 2 +- primed/dbgap/tests/test_views.py | 14 +++++++------- primed/templates/cdsa/cdsaworkspace_detail.html | 4 ++-- primed/templates/dbgap/dbgapworkspace_detail.html | 4 ++-- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/primed/cdsa/adapters.py b/primed/cdsa/adapters.py index dd37688e..51305761 100644 --- a/primed/cdsa/adapters.py +++ b/primed/cdsa/adapters.py @@ -27,7 +27,7 @@ def get_extra_detail_context_data(self, workspace, request): associated_data_prep = Workspace.objects.filter( dataprepworkspace__target_workspace=workspace ) - extra_context["associated_data_prep_workspace"] = DataPrepWorkspaceTable( + extra_context["associated_data_prep_workspaces"] = DataPrepWorkspaceTable( associated_data_prep ) return extra_context diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 85aa7675..fcbfe564 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -7262,30 +7262,30 @@ def test_render_duo_modifiers(self): self.assertContains(response, modifiers[0].abbreviation) self.assertContains(response, modifiers[1].abbreviation) - def test_associated_data_prep_workspace_context_exists(self): + 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_workspace", response.context_data) + self.assertIn("associated_data_prep_workspaces", response.context_data) self.assertIsInstance( - response.context_data["associated_data_prep_workspace"], + response.context_data["associated_data_prep_workspaces"], DataPrepWorkspaceTable, ) - def test_only_show_correct_associated_data_prep_workspace(self): + 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_workspace", response.context_data) + self.assertIn("associated_data_prep_workspaces", response.context_data) self.assertEqual( - len(response.context_data["associated_data_prep_workspace"].rows), 1 + len(response.context_data["associated_data_prep_workspaces"].rows), 1 ) self.assertIn( dataPrep_obj.workspace, - response.context_data["associated_data_prep_workspace"].data, + response.context_data["associated_data_prep_workspaces"].data, ) diff --git a/primed/dbgap/adapters.py b/primed/dbgap/adapters.py index 5315e764..485fb507 100644 --- a/primed/dbgap/adapters.py +++ b/primed/dbgap/adapters.py @@ -25,7 +25,7 @@ def get_extra_detail_context_data(self, workspace, request): associated_data_prep = Workspace.objects.filter( dataprepworkspace__target_workspace=workspace ) - extra_context["associated_data_prep_workspace"] = DataPrepWorkspaceTable( + extra_context["associated_data_prep_workspaces"] = DataPrepWorkspaceTable( associated_data_prep ) return extra_context diff --git a/primed/dbgap/tests/test_views.py b/primed/dbgap/tests/test_views.py index 5b880395..7e603c37 100644 --- a/primed/dbgap/tests/test_views.py +++ b/primed/dbgap/tests/test_views.py @@ -953,30 +953,30 @@ def test_links_audit_access_view_permission(self): ), ) - def test_associated_data_prep_workspace_context_exists(self): + 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_workspace", response.context_data) + self.assertIn("associated_data_prep_workspaces", response.context_data) self.assertIsInstance( - response.context_data["associated_data_prep_workspace"], + response.context_data["associated_data_prep_workspaces"], DataPrepWorkspaceTable, ) - def test_only_show_correct_associated_data_prep_workspace(self): + 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_workspace", response.context_data) + self.assertIn("associated_data_prep_workspaces", response.context_data) self.assertEqual( - len(response.context_data["associated_data_prep_workspace"].rows), 1 + len(response.context_data["associated_data_prep_workspaces"].rows), 1 ) self.assertIn( dataPrep_obj.workspace, - response.context_data["associated_data_prep_workspace"].data, + response.context_data["associated_data_prep_workspaces"].data, ) diff --git a/primed/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index e1fbd3bd..d949ade9 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -87,12 +87,12 @@

    - {% render_table associated_data_prep_workspace %} + {% render_table associated_data_prep_workspaces %}

    diff --git a/primed/templates/dbgap/dbgapworkspace_detail.html b/primed/templates/dbgap/dbgapworkspace_detail.html index ee502ded..4bde3586 100644 --- a/primed/templates/dbgap/dbgapworkspace_detail.html +++ b/primed/templates/dbgap/dbgapworkspace_detail.html @@ -100,12 +100,12 @@

    - {% render_table associated_data_prep_workspace %} + {% render_table associated_data_prep_workspaces %}
    From d327c4f1b4368aa9967b91fb74d842c5659ea287 Mon Sep 17 00:00:00 2001 From: Wienwipa Kirdpoo Date: Thu, 14 Mar 2024 14:13:43 -0700 Subject: [PATCH 037/102] Add a badge indicating the number of associated data prep workspaces --- primed/templates/cdsa/cdsaworkspace_detail.html | 1 + primed/templates/dbgap/dbgapworkspace_detail.html | 1 + 2 files changed, 2 insertions(+) diff --git a/primed/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index d949ade9..f2dba7ca 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -88,6 +88,7 @@

    diff --git a/primed/templates/dbgap/dbgapworkspace_detail.html b/primed/templates/dbgap/dbgapworkspace_detail.html index 4bde3586..c183c7e0 100644 --- a/primed/templates/dbgap/dbgapworkspace_detail.html +++ b/primed/templates/dbgap/dbgapworkspace_detail.html @@ -101,6 +101,7 @@

    From 6d8fd7d1032fa45fe2a11e36c48e842ac4aa580f Mon Sep 17 00:00:00 2001 From: Wienwipa Kirdpoo Date: Thu, 14 Mar 2024 14:15:04 -0700 Subject: [PATCH 038/102] Add a test for two data prep workspaces associated with the workspace. --- primed/cdsa/tests/test_views.py | 23 +++++++++++++++++++++++ primed/dbgap/tests/test_views.py | 23 +++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index fcbfe564..edd1771b 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -7288,6 +7288,29 @@ def test_only_show_one_associated_data_prep_workspace(self): 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, + ) + class CDSAWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): """Tests of the WorkspaceCreate view from ACM with this app's CDSAWorkspace model.""" diff --git a/primed/dbgap/tests/test_views.py b/primed/dbgap/tests/test_views.py index 7e603c37..c009ae37 100644 --- a/primed/dbgap/tests/test_views.py +++ b/primed/dbgap/tests/test_views.py @@ -979,6 +979,29 @@ def test_only_show_one_associated_data_prep_workspace(self): 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, + ) + class dbGaPWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): """Tests of the WorkspaceCreate view from ACM with this app's dbGaPWorkspace model.""" From bf13f82d20cc2173363715e8d98fcde29e614329 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 14 Mar 2024 15:22:10 -0700 Subject: [PATCH 039/102] Move data prep workspace tables into their own template snippet This way, we only have to change the code only in place. --- .../templates/cdsa/cdsaworkspace_detail.html | 19 +---------------- .../dbgap/dbgapworkspace_detail.html | 21 ++----------------- .../snippets/data_prep_workspace_table.html | 20 ++++++++++++++++++ 3 files changed, 23 insertions(+), 37 deletions(-) create mode 100644 primed/templates/snippets/data_prep_workspace_table.html diff --git a/primed/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index f2dba7ca..046c6277 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -81,24 +81,7 @@

    -
    -
    -
    -

    - -

    -
    -
    - {% render_table associated_data_prep_workspaces %} -
    -
    -
    -
    -
    +{% include "snippets/data_prep_workspace_table.html" with table=associated_data_prep_workspaces %} {{block.super}} {% endblock after_panel %} diff --git a/primed/templates/dbgap/dbgapworkspace_detail.html b/primed/templates/dbgap/dbgapworkspace_detail.html index c183c7e0..f5c02a2b 100644 --- a/primed/templates/dbgap/dbgapworkspace_detail.html +++ b/primed/templates/dbgap/dbgapworkspace_detail.html @@ -1,5 +1,5 @@ {% extends "anvil_consortium_manager/workspace_detail.html" %} -{% load render_table from django_tables2 %} +{% load render_table from django_tables2%} {% block pills %} {% if workspace_data_object.gsr_restricted %} @@ -94,24 +94,7 @@

    -
    -
    -
    -

    - -

    -
    -
    - {% render_table associated_data_prep_workspaces %} -
    -
    -
    -
    -
    +{% include "snippets/data_prep_workspace_table.html" with table=associated_data_prep_workspaces %} {{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..8fb638d9 --- /dev/null +++ b/primed/templates/snippets/data_prep_workspace_table.html @@ -0,0 +1,20 @@ +{% load render_table from django_tables2 %} + +
    +
    +
    +

    + +

    +
    +
    + {% render_table table %} +
    +
    +
    +
    +
    From 4ed89f5aab148989c509b2e6743eb8c423185b34 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 14 Mar 2024 15:32:45 -0700 Subject: [PATCH 040/102] Color badge differently if an active data prep workspace exists If an active data prep workspace exists, color the badge green on the workspace detail pages; otherwise, color it grey. --- primed/cdsa/adapters.py | 3 ++ primed/cdsa/tests/test_views.py | 43 +++++++++++++++++++ primed/dbgap/adapters.py | 3 ++ primed/dbgap/tests/test_views.py | 40 +++++++++++++++++ .../templates/cdsa/cdsaworkspace_detail.html | 2 +- .../dbgap/dbgapworkspace_detail.html | 2 +- .../snippets/data_prep_workspace_table.html | 2 +- 7 files changed, 92 insertions(+), 3 deletions(-) diff --git a/primed/cdsa/adapters.py b/primed/cdsa/adapters.py index 51305761..c72c975e 100644 --- a/primed/cdsa/adapters.py +++ b/primed/cdsa/adapters.py @@ -30,4 +30,7 @@ def get_extra_detail_context_data(self, workspace, request): extra_context["associated_data_prep_workspaces"] = DataPrepWorkspaceTable( associated_data_prep ) + extra_context["data_prep_active"] = associated_data_prep.filter( + dataprepworkspace__is_active=True + ).exists() return extra_context diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index edd1771b..130c10af 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -7267,6 +7267,9 @@ def test_associated_data_prep_workspaces_context_exists(self): self.client.force_login(self.user) response = self.client.get(obj.get_absolute_url()) self.assertIn("associated_data_prep_workspaces", response.context_data) + import ipdb + + ipdb.set_trace() self.assertIsInstance( response.context_data["associated_data_prep_workspaces"], DataPrepWorkspaceTable, @@ -7311,6 +7314,46 @@ def test_show_two_associated_data_prep_workspaces(self): 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"]) + class CDSAWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): """Tests of the WorkspaceCreate view from ACM with this app's CDSAWorkspace model.""" diff --git a/primed/dbgap/adapters.py b/primed/dbgap/adapters.py index 485fb507..626a3c10 100644 --- a/primed/dbgap/adapters.py +++ b/primed/dbgap/adapters.py @@ -28,4 +28,7 @@ def get_extra_detail_context_data(self, workspace, request): extra_context["associated_data_prep_workspaces"] = DataPrepWorkspaceTable( 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/tests/test_views.py b/primed/dbgap/tests/test_views.py index c009ae37..c33fb131 100644 --- a/primed/dbgap/tests/test_views.py +++ b/primed/dbgap/tests/test_views.py @@ -1002,6 +1002,46 @@ def test_show_two_associated_data_prep_workspaces(self): 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/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index 046c6277..b0d2da12 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -81,7 +81,7 @@

    -{% include "snippets/data_prep_workspace_table.html" with table=associated_data_prep_workspaces %} +{% 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/dbgap/dbgapworkspace_detail.html b/primed/templates/dbgap/dbgapworkspace_detail.html index f5c02a2b..c71d751f 100644 --- a/primed/templates/dbgap/dbgapworkspace_detail.html +++ b/primed/templates/dbgap/dbgapworkspace_detail.html @@ -94,7 +94,7 @@

    -{% include "snippets/data_prep_workspace_table.html" with table=associated_data_prep_workspaces %} +{% 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 index 8fb638d9..cb9c9b66 100644 --- a/primed/templates/snippets/data_prep_workspace_table.html +++ b/primed/templates/snippets/data_prep_workspace_table.html @@ -7,7 +7,7 @@

    From 4a8281d04cf9549357efa55bf42fe9898e1684b9 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Fri, 15 Mar 2024 13:53:11 -0700 Subject: [PATCH 041/102] Add requires_study_review field to DataAffiliateAgreements --- ...greement_requires_study_review_and_more.py | 29 +++++++++++++++++++ primed/cdsa/models.py | 8 +++++ primed/cdsa/tests/test_models.py | 21 ++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 primed/cdsa/migrations/0018_dataaffiliateagreement_requires_study_review_and_more.py 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/models.py b/primed/cdsa/models.py index 0e223f55..b8f897bf 100644 --- a/primed/cdsa/models.py +++ b/primed/cdsa/models.py @@ -281,6 +281,14 @@ class DataAffiliateAgreement(TimeStampedModel, AgreementTypeModel, models.Model) 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( diff --git a/primed/cdsa/tests/test_models.py b/primed/cdsa/tests/test_models.py index 98b9858f..dfb2589b 100644 --- a/primed/cdsa/tests/test_models.py +++ b/primed/cdsa/tests/test_models.py @@ -471,6 +471,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() @@ -557,6 +571,13 @@ def test_get_agreement_group(self): instance = factories.DataAffiliateAgreementFactory.create() self.assertEqual(instance.get_agreement_group(), instance.study) + def test_requires_study_review(self): + """Can set requires_study_review""" + instance = factories.DataAffiliateAgreementFactory.create( + requires_study_review=True + ) + self.assertTrue(instance.requires_study_review) + class NonDataAffiliateAgreementTest(TestCase): """Tests for the NonDataAffiliateAgreement model.""" From cc40b38d3e5eebe475adf9143785459b40c3414f Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Fri, 15 Mar 2024 13:59:44 -0700 Subject: [PATCH 042/102] Only allow requires_study_review=True for primary agreements --- primed/cdsa/models.py | 18 ++++++++++-------- primed/cdsa/tests/test_models.py | 12 +++++++++++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/primed/cdsa/models.py b/primed/cdsa/models.py index b8f897bf..8336da2f 100644 --- a/primed/cdsa/models.py +++ b/primed/cdsa/models.py @@ -298,14 +298,16 @@ def get_absolute_url(self): def clean(self): super().clean() - if ( - self.additional_limitations - and hasattr(self, "signed_agreement") - and not self.signed_agreement.is_primary - ): - raise ValidationError( - "Additional limitations are only allowed for primary agreements." - ) + # Checks for fields only allowed for primary agreements. + if hasattr(self, "signed_agreement") and not self.signed_agreement.is_primary: + if self.additional_limitations: + raise ValidationError( + "Additional limitations are only allowed for primary agreements." + ) + if self.requires_study_review: + raise ValidationError( + "requires_study_review can only be True for primary agreements." + ) def get_agreement_group(self): return self.study diff --git a/primed/cdsa/tests/test_models.py b/primed/cdsa/tests/test_models.py index dfb2589b..f772ca74 100644 --- a/primed/cdsa/tests/test_models.py +++ b/primed/cdsa/tests/test_models.py @@ -571,13 +571,23 @@ def test_get_agreement_group(self): instance = factories.DataAffiliateAgreementFactory.create() self.assertEqual(instance.get_agreement_group(), instance.study) - def test_requires_study_review(self): + 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( + signed_agreement__is_primary=False, + requires_study_review=True, + ) + with self.assertRaises(ValidationError) as e: + instance.clean() + self.assertIn("can only be True for primary", e.exception.message) + class NonDataAffiliateAgreementTest(TestCase): """Tests for the NonDataAffiliateAgreement model.""" From b26b237c985547482575d2da8dedea52700aee18 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Fri, 15 Mar 2024 14:03:13 -0700 Subject: [PATCH 043/102] Add requires_study_review to DataAffiliateAgreementForm --- primed/cdsa/forms.py | 1 + primed/cdsa/tests/test_forms.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/primed/cdsa/forms.py b/primed/cdsa/forms.py index 5fbf7dab..993e2250 100644 --- a/primed/cdsa/forms.py +++ b/primed/cdsa/forms.py @@ -78,6 +78,7 @@ class Meta: "signed_agreement", "study", "additional_limitations", + "requires_study_review", ) widgets = { "study": autocomplete.ModelSelect2( diff --git a/primed/cdsa/tests/test_forms.py b/primed/cdsa/tests/test_forms.py index 3ab4573f..d326207c 100644 --- a/primed/cdsa/tests/test_forms.py +++ b/primed/cdsa/tests/test_forms.py @@ -446,6 +446,35 @@ def test_invalid_component_with_additional_limitations(self): self.assertEqual(len(form.errors[NON_FIELD_ERRORS]), 1) self.assertIn("only allowed for primary", form.errors[NON_FIELD_ERRORS][0]) + def test_valid_primary_with_requires_study_review_true(self): + """Form is valid with necessary input.""" + signed_agreement = factories.SignedAgreementFactory.create( + type=models.SignedAgreement.DATA_AFFILIATE, is_primary=True + ) + form_data = { + "signed_agreement": signed_agreement, + "study": self.study, + "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.""" + signed_agreement = factories.SignedAgreementFactory.create( + type=models.SignedAgreement.DATA_AFFILIATE, is_primary=False + ) + form_data = { + "signed_agreement": signed_agreement, + "study": self.study, + "requires_study_review": True, + } + form = self.form_class(data=form_data) + self.assertFalse(form.is_valid()) + self.assertIn(NON_FIELD_ERRORS, form.errors) + self.assertEqual(len(form.errors[NON_FIELD_ERRORS]), 1) + self.assertIn("can only be True for primary", form.errors[NON_FIELD_ERRORS][0]) + class NonDataAffiliateAgreementFormTest(TestCase): """Tests for the NonDataAffiliateAgreementForm class.""" From ba7599fde3c84d49f625493f8a8306b17d9de903 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Fri, 15 Mar 2024 14:42:00 -0700 Subject: [PATCH 044/102] Add view tests for DataAffiliate primary-only fields Add tests in the DataAffiliateCreate view to verify that you can't create a DataAffiliate component with the additional_limitations or requires_study_review=True. These tests are failing - the clean method isn't working because it is essentially cleaning two models together, and the DataAffiliateAgreement form instance doesn't have the signed_agreement field set before the form/formset are saved. --- primed/cdsa/tests/test_views.py | 188 ++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 77116569..2b16442b 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -2862,6 +2862,194 @@ 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", + "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", + "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) + self.assertFalse(response.context["form"].is_valid()) + form = response.context["form"] + self.assertEqual(len(form.errors), 1) + self.assertIn(NON_FIELD_ERRORS, form.errors) + self.assertEqual(len(form.errors[NON_FIELD_ERRORS]), 1) + self.assertIn( + "can only be True for primary", + form.errors[NON_FIELD_ERRORS][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", + "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", + "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) + self.assertFalse(response.context["form"].is_valid()) + form = response.context["form"] + self.assertEqual(len(form.errors), 1) + self.assertIn(NON_FIELD_ERRORS, form.errors) + self.assertEqual(len(form.errors[NON_FIELD_ERRORS]), 1) + self.assertIn( + "only allowed for primary", + form.errors[NON_FIELD_ERRORS][0], + ) + def test_error_missing_cc_id(self): """Form shows an error when cc_id is missing.""" self.client.force_login(self.user) From c71d006186f8457dd78418a81a36b5a6444335a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 17 Mar 2024 03:24:45 +0000 Subject: [PATCH 045/102] Bump types-requests from 2.31.0.20240310 to 2.31.0.20240311 Bumps [types-requests](https://github.com/python/typeshed) from 2.31.0.20240310 to 2.31.0.20240311. - [Commits](https://github.com/python/typeshed/commits) --- updated-dependencies: - dependency-name: types-requests dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements/dev-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev-requirements.txt b/requirements/dev-requirements.txt index 814e7b09..d5c4d978 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.20240311 # via -r requirements/dev-requirements.in typing-extensions==4.8.0 # via From 025ff1f77e120c4e0830334f436fa2e8aa152f93 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 17 Mar 2024 03:26:17 +0000 Subject: [PATCH 046/102] Bump coverage from 7.4.3 to 7.4.4 Bumps [coverage](https://github.com/nedbat/coveragepy) from 7.4.3 to 7.4.4. - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.4.3...7.4.4) --- updated-dependencies: - dependency-name: coverage dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- requirements/test-requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/test-requirements.txt b/requirements/test-requirements.txt index e69f69b4..25c305e9 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 From 024ce552bfe04f69f680f60a5d89e5de6a198735 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Mon, 18 Mar 2024 10:50:48 -0700 Subject: [PATCH 047/102] Move the is_primary field to agreement type models This will make the forms easier to process, and also makes some sense because the NonDataAffiliate type does not differentiate between primary and components - they are all primary. --- primed/cdsa/admin.py | 10 +- .../0019_alter_signedagreement_is_primary.py | 29 +++ ...aaffiliateagreement_is_primary_and_more.py | 49 ++++ .../migrations/0021_populate_is_primary.py | 60 +++++ .../0022_remove_signedagreement_is_primary.py | 21 ++ primed/cdsa/models.py | 9 +- primed/cdsa/tests/test_migrations.py | 225 +++++++++++++++++- 7 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 primed/cdsa/migrations/0019_alter_signedagreement_is_primary.py create mode 100644 primed/cdsa/migrations/0020_dataaffiliateagreement_is_primary_and_more.py create mode 100644 primed/cdsa/migrations/0021_populate_is_primary.py create mode 100644 primed/cdsa/migrations/0022_remove_signedagreement_is_primary.py 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/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 8336da2f..0c0f82bd 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.", @@ -250,6 +247,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,6 +271,9 @@ 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, 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) From 3b75d343dd9e1b0c7038f4aca5ad13f91672d4aa Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Mon, 18 Mar 2024 10:54:34 -0700 Subject: [PATCH 048/102] Move is_primary field in factories --- primed/cdsa/tests/factories.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/primed/cdsa/tests/factories.py b/primed/cdsa/tests/factories.py index 855a3ed4..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( From 2fdc1fee4aabb97459a7259e0db64d46ea38c76a Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 19 Mar 2024 09:10:42 -0700 Subject: [PATCH 049/102] Updating remaining code for moving is_primary to agreement types --- add_cdsa_example_data.py | 12 +- primed/cdsa/audit/signed_agreement_audit.py | 7 +- primed/cdsa/audit/workspace_audit.py | 2 +- primed/cdsa/forms.py | 23 +- primed/cdsa/helpers.py | 2 +- primed/cdsa/models.py | 17 +- primed/cdsa/tables.py | 16 +- primed/cdsa/tests/test_audit.py | 112 ++----- primed/cdsa/tests/test_commands.py | 16 +- primed/cdsa/tests/test_forms.py | 90 +++--- primed/cdsa/tests/test_models.py | 68 ++-- primed/cdsa/tests/test_tables.py | 8 +- primed/cdsa/tests/test_views.py | 333 +++++++------------- 13 files changed, 270 insertions(+), 436 deletions(-) diff --git a/add_cdsa_example_data.py b/add_cdsa_example_data.py index b091fe94..c8e5eb26 100644 --- a/add_cdsa_example_data.py +++ b/add_cdsa_example_data.py @@ -51,7 +51,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"), ) @@ -64,7 +64,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"), ) @@ -77,7 +77,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"), ) @@ -90,7 +90,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"), ) @@ -128,7 +128,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, ) @@ -141,7 +141,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, ) diff --git a/primed/cdsa/audit/signed_agreement_audit.py b/primed/cdsa/audit/signed_agreement_audit.py index 929783c4..0eeee1f4 100644 --- a/primed/cdsa/audit/signed_agreement_audit.py +++ b/primed/cdsa/audit/signed_agreement_audit.py @@ -218,13 +218,13 @@ 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"): @@ -320,7 +320,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 993e2250..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,36 @@ 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", ) 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/models.py b/primed/cdsa/models.py index 0c0f82bd..72c8274b 100644 --- a/primed/cdsa/models.py +++ b/primed/cdsa/models.py @@ -175,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 @@ -302,7 +301,7 @@ def get_absolute_url(self): def clean(self): super().clean() # Checks for fields only allowed for primary agreements. - if hasattr(self, "signed_agreement") and not self.signed_agreement.is_primary: + if not self.is_primary: if self.additional_limitations: raise ValidationError( "Additional limitations are only allowed for primary agreements." @@ -371,7 +370,7 @@ def get_primary_cdsa(self): """Return the primary, valid CDSA associated with this workspace.""" cdsa = DataAffiliateAgreement.objects.get( study=self.study, - signed_agreement__is_primary=True, + 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..d7e3c1a6 100644 --- a/primed/cdsa/tables.py +++ b/primed/cdsa/tables.py @@ -43,9 +43,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 +77,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 +94,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 +111,7 @@ 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?") signed_agreement__representative__name = tables.Column( linkify=lambda record: record.signed_agreement.representative.get_absolute_url(), verbose_name="Representative", @@ -130,7 +128,7 @@ class Meta: fields = ( "signed_agreement__cc_id", "study", - "signed_agreement__is_primary", + "is_primary", "signed_agreement__representative__name", "signed_agreement__representative_role", "signed_agreement__signing_institution", @@ -178,9 +176,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 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 d326207c..3ea774f2 100644 --- a/primed/cdsa/tests/test_forms.py +++ b/primed/cdsa/tests/test_forms.py @@ -36,7 +36,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()) @@ -50,7 +49,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()) @@ -68,7 +66,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()) @@ -86,7 +83,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()) @@ -104,7 +100,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()) @@ -122,7 +117,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()) @@ -140,7 +134,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()) @@ -149,24 +142,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 = { @@ -176,7 +151,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()) @@ -195,7 +169,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()) @@ -214,7 +187,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()) @@ -282,6 +254,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) @@ -291,6 +264,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) @@ -300,10 +274,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) @@ -318,6 +307,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) @@ -333,6 +323,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) @@ -358,6 +349,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) @@ -367,6 +359,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) @@ -376,10 +369,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) @@ -394,6 +402,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) @@ -409,6 +418,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) @@ -419,12 +429,10 @@ def test_invalid_signed_agreement_wrong_type(self): def test_valid_primary_with_additional_limitations(self): """Form is valid with necessary input.""" - signed_agreement = factories.SignedAgreementFactory.create( - type=models.SignedAgreement.DATA_AFFILIATE, is_primary=True - ) form_data = { - "signed_agreement": signed_agreement, + "signed_agreement": self.signed_agreement, "study": self.study, + "is_primary": True, "additional_limitations": "test limitations", } form = self.form_class(data=form_data) @@ -432,12 +440,10 @@ def test_valid_primary_with_additional_limitations(self): def test_invalid_component_with_additional_limitations(self): """Form is valid with necessary input.""" - signed_agreement = factories.SignedAgreementFactory.create( - type=models.SignedAgreement.DATA_AFFILIATE, is_primary=False - ) form_data = { - "signed_agreement": signed_agreement, + "signed_agreement": self.signed_agreement, "study": self.study, + "is_primary": False, "additional_limitations": "test limitations", } form = self.form_class(data=form_data) @@ -448,12 +454,10 @@ def test_invalid_component_with_additional_limitations(self): def test_valid_primary_with_requires_study_review_true(self): """Form is valid with necessary input.""" - signed_agreement = factories.SignedAgreementFactory.create( - type=models.SignedAgreement.DATA_AFFILIATE, is_primary=True - ) form_data = { - "signed_agreement": signed_agreement, + "signed_agreement": self.signed_agreement, "study": self.study, + "is_primary": True, "requires_study_review": True, } form = self.form_class(data=form_data) @@ -461,12 +465,10 @@ def test_valid_primary_with_requires_study_review_true(self): def test_invalid_component_with_requires_study_review_true(self): """Form is valid with necessary input.""" - signed_agreement = factories.SignedAgreementFactory.create( - type=models.SignedAgreement.DATA_AFFILIATE, is_primary=False - ) form_data = { - "signed_agreement": signed_agreement, + "signed_agreement": self.signed_agreement, "study": self.study, + "is_primary": False, "requires_study_review": True, } form = self.form_class(data=form_data) diff --git a/primed/cdsa/tests/test_models.py b/primed/cdsa/tests/test_models.py index f772ca74..6b7dbf78 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 @@ -496,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 @@ -522,14 +510,14 @@ def test_clean_incorrect_type(self): def test_clean_additional_limitations_primary(self): instance = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True, + is_primary=True, additional_limitations="foo bar", ) instance.full_clean() def test_clean_additional_limitations_not_primary(self): instance = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, additional_limitations="foo bar", ) with self.assertRaises(ValidationError) as e: @@ -581,7 +569,7 @@ def test_requires_study_review_primary(self): def test_requires_study_review_not_primary(self): """ValidationError when trying to set requires_study_review=True for components.""" instance = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, requires_study_review=True, ) with self.assertRaises(ValidationError) as e: @@ -713,7 +701,7 @@ 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( - signed_agreement__is_primary=True, + is_primary=True, signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, study=instance.study, ) @@ -722,7 +710,7 @@ def test_get_primary_cdsa(self): def test_get_primary_cdsa_not_primary(self): instance = factories.CDSAWorkspaceFactory.create() factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=False, + is_primary=False, signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, study=instance.study, ) @@ -732,7 +720,7 @@ def test_get_primary_cdsa_not_primary(self): def test_get_primary_cdsa_not_active(self): instance = factories.CDSAWorkspaceFactory.create() factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True, + is_primary=True, signed_agreement__status=models.SignedAgreement.StatusChoices.LAPSED, study=instance.study, ) @@ -742,7 +730,7 @@ def test_get_primary_cdsa_not_active(self): def test_get_primary_cdsa_different_study(self): instance = factories.CDSAWorkspaceFactory.create() factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True, + is_primary=True, signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, # study=instance.study, ) @@ -753,12 +741,12 @@ 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( - signed_agreement__is_primary=True, + is_primary=True, signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, study=instance.study, ) factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True, + is_primary=True, signed_agreement__status=models.SignedAgreement.StatusChoices.ACTIVE, study=instance.study, ) diff --git a/primed/cdsa/tests/test_tables.py b/primed/cdsa/tests/test_tables.py index c7c8c9de..f239a383 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 ) diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 2b16442b..2699ed64 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -1494,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, @@ -1511,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. @@ -1527,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.""" @@ -1558,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, @@ -1599,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, @@ -1626,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, @@ -1662,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, @@ -1696,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, @@ -1730,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, @@ -1766,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, @@ -1802,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, @@ -1837,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, @@ -1872,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, @@ -1908,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, @@ -1944,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, @@ -1959,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.""" @@ -1979,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, @@ -1991,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()) @@ -2017,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, @@ -2055,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, @@ -2116,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, @@ -2167,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, @@ -2215,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, @@ -2253,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, @@ -2293,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, @@ -2343,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, @@ -2713,7 +2716,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, @@ -2730,7 +2733,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. @@ -2745,6 +2747,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( @@ -2794,7 +2797,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, @@ -2848,7 +2851,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, @@ -2905,7 +2908,7 @@ def test_can_create_primary_with_requires_study_review(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, @@ -2935,7 +2938,7 @@ def test_cannot_create_component_with_requires_study_review(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": False, + "agreementtype-0-is_primary": False, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -2946,14 +2949,18 @@ def test_cannot_create_component_with_requires_study_review(self): ) self.assertEqual(response.status_code, 200) self.assertIn("form", response.context) - self.assertFalse(response.context["form"].is_valid()) - form = response.context["form"] - self.assertEqual(len(form.errors), 1) - self.assertIn(NON_FIELD_ERRORS, form.errors) - self.assertEqual(len(form.errors[NON_FIELD_ERRORS]), 1) + 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(NON_FIELD_ERRORS, formset.forms[0].errors) + self.assertEqual(len(formset.forms[0].errors[NON_FIELD_ERRORS]), 1) self.assertIn( "can only be True for primary", - form.errors[NON_FIELD_ERRORS][0], + formset.forms[0].errors[NON_FIELD_ERRORS][0], ) def test_can_create_primary_with_additional_limitations(self): @@ -2999,7 +3006,7 @@ def test_can_create_primary_with_additional_limitations(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, @@ -3029,7 +3036,7 @@ def test_cannot_create_component_with_additional_limitations(self): "signing_institution": "Test institution", "version": agreement_version.pk, "date_signed": "2023-01-01", - "is_primary": False, + "agreementtype-0-is_primary": False, "agreementtype-TOTAL_FORMS": 1, "agreementtype-INITIAL_FORMS": 0, "agreementtype-MIN_NUM_FORMS": 1, @@ -3040,14 +3047,18 @@ def test_cannot_create_component_with_additional_limitations(self): ) self.assertEqual(response.status_code, 200) self.assertIn("form", response.context) - self.assertFalse(response.context["form"].is_valid()) - form = response.context["form"] - self.assertEqual(len(form.errors), 1) - self.assertIn(NON_FIELD_ERRORS, form.errors) - self.assertEqual(len(form.errors[NON_FIELD_ERRORS]), 1) + 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(NON_FIELD_ERRORS, formset.forms[0].errors) + self.assertEqual(len(formset.forms[0].errors[NON_FIELD_ERRORS]), 1) self.assertIn( "only allowed for primary", - form.errors[NON_FIELD_ERRORS][0], + formset.forms[0].errors[NON_FIELD_ERRORS][0], ) def test_error_missing_cc_id(self): @@ -3065,7 +3076,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, @@ -3101,7 +3112,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, @@ -3135,7 +3146,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, @@ -3169,7 +3180,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, @@ -3205,7 +3216,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, @@ -3241,7 +3252,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, @@ -3276,7 +3287,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, @@ -3311,7 +3322,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, @@ -3347,7 +3358,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, @@ -3383,7 +3394,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, @@ -3398,11 +3409,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.""" @@ -3418,7 +3432,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, @@ -3456,7 +3470,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, @@ -3494,7 +3508,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, @@ -3567,7 +3581,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, @@ -3642,7 +3656,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, @@ -3709,7 +3723,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, @@ -3753,7 +3767,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, @@ -3813,7 +3827,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, @@ -3853,7 +3867,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, @@ -3890,7 +3904,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, @@ -3954,7 +3968,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, @@ -4020,7 +4034,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, @@ -4198,7 +4212,7 @@ 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( - signed_agreement__is_primary=True, + is_primary=True, additional_limitations="Test limitations for this data affiliate agreement", ) self.client.force_login(self.user) @@ -4211,7 +4225,7 @@ def test_response_includes_additional_limitations(self): def test_response_with_no_additional_limitations(self): """Response includes a link to the study detail page.""" instance = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True, + is_primary=True, additional_limitations="", ) self.client.force_login(self.user) @@ -4406,7 +4420,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, @@ -4423,7 +4436,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. @@ -4469,7 +4481,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, @@ -4509,7 +4520,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, @@ -4537,7 +4547,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, @@ -4572,7 +4581,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, @@ -4605,7 +4613,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, @@ -4638,7 +4645,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, @@ -4673,7 +4679,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, @@ -4708,7 +4713,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, @@ -4742,7 +4746,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, @@ -4776,7 +4779,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, @@ -4811,7 +4813,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, @@ -4832,41 +4833,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) @@ -4881,7 +4847,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, @@ -4920,7 +4885,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, @@ -4944,40 +4908,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) @@ -5014,7 +4944,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, @@ -5064,7 +4993,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, @@ -5111,7 +5039,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, @@ -5148,7 +5075,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, @@ -5187,7 +5113,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, @@ -5670,9 +5595,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, @@ -7069,25 +6992,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()) @@ -7099,10 +7016,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()) @@ -7176,7 +7093,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) @@ -7184,9 +7101,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 ) @@ -7197,9 +7112,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 ) @@ -7210,15 +7123,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 ) @@ -7228,21 +7137,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 ) @@ -7252,28 +7155,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() @@ -7564,7 +7457,7 @@ def test_context_data_prep_active_with_one_active_one_inactive_prep_workspace(se def test_response_context_primary_cdsa(self): agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True, + is_primary=True, ) instance = factories.CDSAWorkspaceFactory.create( study=agreement.study, @@ -7577,7 +7470,7 @@ def test_response_context_primary_cdsa(self): def test_response_includes_additional_limitations(self): """Response includes DataAffiliate additional limitations if they exist.""" agreement = factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True, + is_primary=True, additional_limitations="Test limitations for this data affiliate agreement", ) instance = factories.CDSAWorkspaceFactory.create( @@ -7605,7 +7498,7 @@ def test_response_data_use_limitations(self): instance.data_use_modifiers.add(modifier_1, modifier_2) # Create an agreement with data use limitations. factories.DataAffiliateAgreementFactory.create( - signed_agreement__is_primary=True, + is_primary=True, study=instance.study, additional_limitations="Test limitations for this data affiliate agreement", ) From 483e15ccc7c390997ff18f2fed9b525c60cad8d8 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 19 Mar 2024 09:20:14 -0700 Subject: [PATCH 050/102] Create CC admins group in example script --- add_cdsa_example_data.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/add_cdsa_example_data.py b/add_cdsa_example_data.py index c8e5eb26..a2343eef 100644 --- a/add_cdsa_example_data.py +++ b/add_cdsa_example_data.py @@ -20,6 +20,11 @@ # 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) @@ -35,9 +40,6 @@ dup = DataUsePermission.objects.get(abbreviation="GRU") dum = DataUseModifier.objects.get(abbreviation="NPU") -# create the CDSA auth group -cdsa_group = ManagedGroupFactory.create(name=settings.ANVIL_CDSA_GROUP_NAME) - # Create some study sites. StudySiteFactory.create(short_name="CC", full_name="Coordinating Center") StudySiteFactory.create(short_name="CARDINAL", full_name="CARDINAL") From 9a262acd9fcc8956bff07b49d3b7423df7d03f94 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 19 Mar 2024 09:41:40 -0700 Subject: [PATCH 051/102] Move custom clean errors to specific fields Move the additional_limitation and rqeuires_study_review Validation errors to the specific fields, not the NON_FIELD_ERRORS. This provides better behavior on the forms. --- primed/cdsa/models.py | 7 +++++-- primed/cdsa/tests/test_forms.py | 17 ++++++++++------- primed/cdsa/tests/test_models.py | 16 ++++++++++++++-- primed/cdsa/tests/test_views.py | 14 +++++++------- 4 files changed, 36 insertions(+), 18 deletions(-) diff --git a/primed/cdsa/models.py b/primed/cdsa/models.py index 72c8274b..4ba3cdec 100644 --- a/primed/cdsa/models.py +++ b/primed/cdsa/models.py @@ -301,15 +301,18 @@ def get_absolute_url(self): def clean(self): super().clean() # Checks for fields only allowed for primary agreements. + errors = {} if not self.is_primary: if self.additional_limitations: - raise ValidationError( + errors["additional_limitations"] = ValidationError( "Additional limitations are only allowed for primary agreements." ) if self.requires_study_review: - raise ValidationError( + 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 diff --git a/primed/cdsa/tests/test_forms.py b/primed/cdsa/tests/test_forms.py index 3ea774f2..1dcea0af 100644 --- a/primed/cdsa/tests/test_forms.py +++ b/primed/cdsa/tests/test_forms.py @@ -1,7 +1,6 @@ """Tests for the `cdsa` app.""" from anvil_consortium_manager.tests.factories import WorkspaceFactory -from django.core.exceptions import NON_FIELD_ERRORS from django.test import TestCase from primed.duo.models import DataUseModifier @@ -448,9 +447,11 @@ def test_invalid_component_with_additional_limitations(self): } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn(NON_FIELD_ERRORS, form.errors) - self.assertEqual(len(form.errors[NON_FIELD_ERRORS]), 1) - self.assertIn("only allowed for primary", form.errors[NON_FIELD_ERRORS][0]) + 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.""" @@ -473,9 +474,11 @@ def test_invalid_component_with_requires_study_review_true(self): } form = self.form_class(data=form_data) self.assertFalse(form.is_valid()) - self.assertIn(NON_FIELD_ERRORS, form.errors) - self.assertEqual(len(form.errors[NON_FIELD_ERRORS]), 1) - self.assertIn("can only be True for primary", form.errors[NON_FIELD_ERRORS][0]) + 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): diff --git a/primed/cdsa/tests/test_models.py b/primed/cdsa/tests/test_models.py index 6b7dbf78..eb9fbe66 100644 --- a/primed/cdsa/tests/test_models.py +++ b/primed/cdsa/tests/test_models.py @@ -522,7 +522,13 @@ def test_clean_additional_limitations_not_primary(self): ) with self.assertRaises(ValidationError) as e: instance.clean() - self.assertIn("only allowed for primary agreements", e.exception.message) + 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.""" @@ -574,7 +580,13 @@ def test_requires_study_review_not_primary(self): ) with self.assertRaises(ValidationError) as e: instance.clean() - self.assertIn("can only be True for primary", e.exception.message) + 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): diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 2699ed64..82062681 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 @@ -2956,11 +2956,11 @@ def test_cannot_create_component_with_requires_study_review(self): self.assertFalse(formset.is_valid()) self.assertFalse(formset.forms[0].is_valid()) self.assertEqual(len(formset.forms[0].errors), 1) - self.assertIn(NON_FIELD_ERRORS, formset.forms[0].errors) - self.assertEqual(len(formset.forms[0].errors[NON_FIELD_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[NON_FIELD_ERRORS][0], + formset.forms[0].errors["requires_study_review"][0], ) def test_can_create_primary_with_additional_limitations(self): @@ -3054,11 +3054,11 @@ def test_cannot_create_component_with_additional_limitations(self): self.assertFalse(formset.is_valid()) self.assertFalse(formset.forms[0].is_valid()) self.assertEqual(len(formset.forms[0].errors), 1) - self.assertIn(NON_FIELD_ERRORS, formset.forms[0].errors) - self.assertEqual(len(formset.forms[0].errors[NON_FIELD_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[NON_FIELD_ERRORS][0], + formset.forms[0].errors["additional_limitations"][0], ) def test_error_missing_cc_id(self): From 87198cc3b88e85570b1faaccb4de7edafde183e3 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 19 Mar 2024 13:41:01 -0700 Subject: [PATCH 052/102] Show requires_study_review indicator on detail pages Include an indicator of whether requires_study_review is true on the CDSAWorkspace and DataAFfiliateAgreement detail pages. --- primed/cdsa/tests/test_views.py | 26 +++++++++++++++++++ .../templates/cdsa/cdsaworkspace_detail.html | 12 +++++++++ .../cdsa/dataaffiliateagreement_detail.html | 7 +++++ 3 files changed, 45 insertions(+) diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 82062681..9b6b546c 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -7524,6 +7524,32 @@ def test_response_data_use_limitations(self): "
  • 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") + class CDSAWorkspaceCreateTest(AnVILAPIMockTestMixin, TestCase): """Tests of the WorkspaceCreate view from ACM with this app's CDSAWorkspace model.""" diff --git a/primed/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index 9c2d8ee7..d8ea0341 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -6,6 +6,18 @@ {% include "snippets/gsr_restricted_badge.html" %} {% endif %} + {% if primary_cdsa.requires_study_review %} + + + Study review required + + + {% endif %} + {{ block.super }} {% endblock pills %} diff --git a/primed/templates/cdsa/dataaffiliateagreement_detail.html b/primed/templates/cdsa/dataaffiliateagreement_detail.html index 0d48c8a8..d13430ce 100644 --- a/primed/templates/cdsa/dataaffiliateagreement_detail.html +++ b/primed/templates/cdsa/dataaffiliateagreement_detail.html @@ -21,6 +21,13 @@
    Representative role
    {{ object.signed_agreement.representative_role }}
    Signing institution
    {{ object.signed_agreement.signing_institution }}
    Primary?
    {{ object.signed_agreement.is_primary }}
    +
    Study review required?
    + {% if object.requires_study_review %} + Yes + {% else %} + No + {% endif %} +
    Agreement version
    {{ object.signed_agreement.version }}
    From fe086506b627047d5c0ba6f3c08386522295c7f5 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 19 Mar 2024 13:50:25 -0700 Subject: [PATCH 053/102] Fix is_primary indicator on detail pages With is_primary being moved to the agreement type classes, the detail pages needed to be fixed to show it properly. --- primed/templates/cdsa/dataaffiliateagreement_detail.html | 8 +++++++- primed/templates/cdsa/memberagreement_detail.html | 8 +++++++- .../templates/cdsa/nondataaffiliateagreement_detail.html | 1 - 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/primed/templates/cdsa/dataaffiliateagreement_detail.html b/primed/templates/cdsa/dataaffiliateagreement_detail.html index d13430ce..ac650255 100644 --- a/primed/templates/cdsa/dataaffiliateagreement_detail.html +++ b/primed/templates/cdsa/dataaffiliateagreement_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 %} +
    Study review required?
    {% if object.requires_study_review %} Yes 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 }}
    From 4670cc241e0bffe0286733d97eb99245a212db8d Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Tue, 19 Mar 2024 14:45:19 -0700 Subject: [PATCH 054/102] Add requires_study_review fields to CDSA tables Add a field to the DataAffiliateAgreementTable and the CDSAWorkspace Table to indicate whether study review is required. --- add_cdsa_example_data.py | 12 ++++++++ primed/cdsa/tables.py | 46 +++++++++++++++++++++++++++++ primed/cdsa/tests/test_tables.py | 50 ++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/add_cdsa_example_data.py b/add_cdsa_example_data.py index a2343eef..da5c0058 100644 --- a/add_cdsa_example_data.py +++ b/add_cdsa_example_data.py @@ -120,6 +120,7 @@ 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 @@ -229,3 +230,14 @@ 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/primed/cdsa/tables.py b/primed/cdsa/tables.py index d7e3c1a6..69512990 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, @@ -112,6 +113,12 @@ class DataAffiliateAgreementTable(tables.Table): signed_agreement__cc_id = tables.Column(linkify=True) study = tables.Column(linkify=True) 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", @@ -129,6 +136,7 @@ class Meta: "signed_agreement__cc_id", "study", "is_primary", + "requires_study_review", "signed_agreement__representative__name", "signed_agreement__representative_role", "signed_agreement__signing_institution", @@ -306,6 +314,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,10 +333,23 @@ class Meta: "cdsaworkspace__study", "cdsaworkspace__data_use_permission__abbreviation", "cdsaworkspace__data_use_modifiers", + "cdsaworkspace_requires_study_review", "cdsaworkspace__gsr_restricted", ) order_by = ("name",) + def render_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 CDSAWorkspaceUserTable(tables.Table): """A table for the CDSAWorkspace model.""" @@ -337,6 +364,12 @@ class CDSAWorkspaceUserTable(tables.Table): transform=lambda x: x.abbreviation, verbose_name="DUO modifiers", ) + 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) is_shared = WorkspaceSharedWithConsortiumColumn() @@ -348,6 +381,19 @@ class Meta: "cdsaworkspace__study", "cdsaworkspace__data_use_permission__abbreviation", "cdsaworkspace__data_use_modifiers", + "cdsaworkspace_requires_study_review", "cdsaworkspace__gsr_restricted", ) order_by = ("name",) + + def render_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'') diff --git a/primed/cdsa/tests/test_tables.py b/primed/cdsa/tests/test_tables.py index f239a383..dbde4fd2 100644 --- a/primed/cdsa/tests/test_tables.py +++ b/primed/cdsa/tests/test_tables.py @@ -472,6 +472,31 @@ 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_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_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_requires_study_review(cdsa_workspace.workspace), + ) + class CDSAWorkspaceUserTableTest(TestCase): """Tests for the CDSAWorkspaceUserTable class.""" @@ -501,3 +526,28 @@ 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_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_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_requires_study_review(cdsa_workspace.workspace), + ) From 959b3a64eedc7fa4e8451a1fee0820311d0abaaf Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 20 Mar 2024 16:13:52 -0700 Subject: [PATCH 055/102] Test detail page with and without requires_study_review --- primed/cdsa/tests/test_views.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 9b6b546c..4df993bb 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -4232,6 +4232,29 @@ def test_response_with_no_additional_limitations(self): response = self.client.get(self.get_url(instance.signed_agreement.cc_id)) self.assertNotContains(response, "Additional limitations") + 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?") + self.assertContains( + response, + """
    Yes
    """, + 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, + """
    No
    """, + html=True, + ) + class DataAffiliateAgreementListTest(TestCase): """Tests for the DataAffiliateAgreement view.""" From a43b236863fa488272510b940240cff19d843e12 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 20 Mar 2024 16:24:35 -0700 Subject: [PATCH 056/102] Remove audit error for component NonDataAffiliateAgreements This concept no longer exists, since NonDataAffiliateAgreement does not have an is_primary field. They are all primary. --- primed/cdsa/audit/signed_agreement_audit.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/primed/cdsa/audit/signed_agreement_audit.py b/primed/cdsa/audit/signed_agreement_audit.py index 0eeee1f4..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 @@ -227,14 +224,6 @@ def _audit_component_agreement(self, signed_agreement): 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, From 5498162d7264686e581fe332cb6b8cd8d97bed10 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Wed, 20 Mar 2024 17:06:37 -0700 Subject: [PATCH 057/102] Clean up test coverage --- primed/cdsa/tests/test_views.py | 51 +++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 4df993bb..1cdfa57a 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -2515,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.""" @@ -4232,6 +4255,29 @@ def test_response_with_no_additional_limitations(self): 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( @@ -4240,9 +4286,10 @@ def test_response_requires_study_review(self): 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, - """
    Yes
    """, + """
    Study review required?
    Yes
    """, # noqa: E501 html=True, ) instance.requires_study_review = False @@ -4251,7 +4298,7 @@ def test_response_requires_study_review(self): self.assertContains(response, "Study review required?") self.assertContains( response, - """
    No
    """, + """
    Study review required?
    No
    """, # noqa: E501 html=True, ) From 670b01ad7f7d5f6818b416a0a8ac856a1c7db8f0 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 21 Mar 2024 10:31:13 -0700 Subject: [PATCH 058/102] Fix requires_study_review display in CDSAWorkspace table --- primed/cdsa/tables.py | 12 ++++++------ primed/cdsa/tests/test_tables.py | 14 ++++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/primed/cdsa/tables.py b/primed/cdsa/tables.py index 69512990..4fd93ce1 100644 --- a/primed/cdsa/tables.py +++ b/primed/cdsa/tables.py @@ -314,7 +314,7 @@ class CDSAWorkspaceStaffTable(tables.Table): verbose_name="DUO modifiers", linkify_item=True, ) - cdsaworkspace_requires_study_review = BooleanIconColumn( + cdsaworkspace__requires_study_review = BooleanIconColumn( verbose_name="Study review required?", orderable=False, true_icon="dash-circle-fill", @@ -333,12 +333,12 @@ class Meta: "cdsaworkspace__study", "cdsaworkspace__data_use_permission__abbreviation", "cdsaworkspace__data_use_modifiers", - "cdsaworkspace_requires_study_review", + "cdsaworkspace__requires_study_review", "cdsaworkspace__gsr_restricted", ) order_by = ("name",) - def render_requires_study_review(self, record): + def render_cdsaworkspace__requires_study_review(self, record): try: if record.cdsaworkspace.get_primary_cdsa().requires_study_review: icon = "dash-circle-fill" @@ -364,7 +364,7 @@ class CDSAWorkspaceUserTable(tables.Table): transform=lambda x: x.abbreviation, verbose_name="DUO modifiers", ) - cdsaworkspace_requires_study_review = BooleanIconColumn( + cdsaworkspace__requires_study_review = BooleanIconColumn( verbose_name="Study review required?", orderable=False, true_icon="dash-circle-fill", @@ -381,12 +381,12 @@ class Meta: "cdsaworkspace__study", "cdsaworkspace__data_use_permission__abbreviation", "cdsaworkspace__data_use_modifiers", - "cdsaworkspace_requires_study_review", + "cdsaworkspace__requires_study_review", "cdsaworkspace__gsr_restricted", ) order_by = ("name",) - def render_requires_study_review(self, record): + def render_cdsaworkspace__requires_study_review(self, record): try: if record.cdsaworkspace.get_primary_cdsa().requires_study_review: icon = "dash-circle-fill" diff --git a/primed/cdsa/tests/test_tables.py b/primed/cdsa/tests/test_tables.py index dbde4fd2..83f6bf55 100644 --- a/primed/cdsa/tests/test_tables.py +++ b/primed/cdsa/tests/test_tables.py @@ -478,7 +478,7 @@ def test_render_requires_study_review(self): cdsa_workspace = factories.CDSAWorkspaceFactory.create() self.assertIn( "question-circle-fill", - table.render_requires_study_review(cdsa_workspace.workspace), + table.render_cdsaworkspace__requires_study_review(cdsa_workspace.workspace), ) # With a primary - no review required. agreement = factories.DataAffiliateAgreementFactory.create( @@ -487,14 +487,15 @@ def test_render_requires_study_review(self): study=cdsa_workspace.study, ) self.assertEqual( - "", table.render_requires_study_review(cdsa_workspace.workspace) + "", + 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_requires_study_review(cdsa_workspace.workspace), + table.render_cdsaworkspace__requires_study_review(cdsa_workspace.workspace), ) @@ -533,7 +534,7 @@ def test_render_requires_study_review(self): cdsa_workspace = factories.CDSAWorkspaceFactory.create() self.assertIn( "question-circle-fill", - table.render_requires_study_review(cdsa_workspace.workspace), + table.render_cdsaworkspace__requires_study_review(cdsa_workspace.workspace), ) # With a primary - no review required. agreement = factories.DataAffiliateAgreementFactory.create( @@ -542,12 +543,13 @@ def test_render_requires_study_review(self): study=cdsa_workspace.study, ) self.assertEqual( - "", table.render_requires_study_review(cdsa_workspace.workspace) + "", + 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_requires_study_review(cdsa_workspace.workspace), + table.render_cdsaworkspace__requires_study_review(cdsa_workspace.workspace), ) From 37d47e9259e862b45ebb0e8b1346fa8627c9b4d9 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 21 Mar 2024 10:31:40 -0700 Subject: [PATCH 059/102] Show primary cdsa link on CDSAWorkspace detail page --- primed/cdsa/tests/test_views.py | 24 +++++++++++++++++++ .../templates/cdsa/cdsaworkspace_detail.html | 19 ++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/primed/cdsa/tests/test_views.py b/primed/cdsa/tests/test_views.py index 1cdfa57a..beb8e4e8 100644 --- a/primed/cdsa/tests/test_views.py +++ b/primed/cdsa/tests/test_views.py @@ -7620,6 +7620,30 @@ def test_response_requires_study_review_false(self): 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.""" diff --git a/primed/templates/cdsa/cdsaworkspace_detail.html b/primed/templates/cdsa/cdsaworkspace_detail.html index d8ea0341..3a192665 100644 --- a/primed/templates/cdsa/cdsaworkspace_detail.html +++ b/primed/templates/cdsa/cdsaworkspace_detail.html @@ -6,7 +6,17 @@ {% include "snippets/gsr_restricted_badge.html" %} {% endif %} - {% if primary_cdsa.requires_study_review %} + {% if not primary_cdsa %} + + + No primary CDSA + + + {% elif primary_cdsa.requires_study_review %} Study review required @@ -24,6 +34,13 @@ {% block workspace_data %}

    +
    Associated CDSA
    + {% if primary_cdsa %} + {{ primary_cdsa }} + {% else %} + — + {% endif %} +
    Study
    {{ object.cdsaworkspace.study }}
    From 39f294378e794c2a359717c53effccd6f008e7e6 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 21 Mar 2024 11:23:47 -0700 Subject: [PATCH 060/102] Add django-constnace to requirements files I can't run pip-compile since the macports MariaDB ports are broken due to a change in libxml2, so I manually edited the requirements.txt file. Not ideal. --- requirements/requirements.in | 3 +++ requirements/requirements.txt | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/requirements/requirements.in b/requirements/requirements.in index dfc31fc7..d6d790fd 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -66,3 +66,6 @@ django-htmx certifi>=2023.7.22 urllib3>=1.26.18 sqlparse>=0.4.4 + +# Dynamic settings +django-constance diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 6b04b099..ea1d67ab 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -65,6 +65,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==2.9.1 + # via -r requirements/requirements.in django-crispy-forms==2.1 # via # -r requirements/requirements.in @@ -88,6 +90,9 @@ django-maintenance-mode==0.21.1 # via -r requirements/requirements.in django-model-utils==4.4.0 # via -r requirements/requirements.in +django-picklefield==3.1.0 + # via -r requirements/requirements.in + # django-constnace django-simple-history==3.5.0 # via # -r requirements/requirements.in From 33f89b5c48672ae633b8fcb85960e68083ef5dda Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 21 Mar 2024 11:52:31 -0700 Subject: [PATCH 061/102] Install django-constance into the project Add django-constance to the third party apps section, and set the specific required settings for this app. We'll use the database backend for now, since we aren't using redis. --- config/settings/base.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/config/settings/base.py b/config/settings/base.py index 9710eaeb..da185606 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -85,6 +85,7 @@ "simple_history", "dbbackup", "django_htmx", + "constance", ] LOCAL_APPS = [ @@ -368,6 +369,13 @@ # 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 = {} +CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend" +CONSTANCE_IGNORE_ADMIN_VERSION_CHECK = True +CONSTANCE_DATABASE_CACHE_BACKEND = "default" + # django-anvil-consortium-manager # ------------------------------------------------------------------------------ ANVIL_API_SERVICE_ACCOUNT_FILE = env("ANVIL_API_SERVICE_ACCOUNT_FILE") From a73e35b14919f1ce2d8f8df74378df5d92cbf30c Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 21 Mar 2024 12:01:11 -0700 Subject: [PATCH 062/102] Disable constance caching This isn't working locally - maybe we can enable it in prod later. --- config/settings/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/settings/base.py b/config/settings/base.py index da185606..50296358 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -374,7 +374,8 @@ CONSTANCE_CONFIG = {} CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend" CONSTANCE_IGNORE_ADMIN_VERSION_CHECK = True -CONSTANCE_DATABASE_CACHE_BACKEND = "default" +# CONSTANCE_DATABASE_CACHE_BACKEND = "default" +CONSTANCE_DATABASE_CACHE_AUTOFILL_TIMEOUT = None # django-anvil-consortium-manager # ------------------------------------------------------------------------------ From 9903886b479e3f63efb024bafddcedb8f3f9b304 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 21 Mar 2024 12:02:40 -0700 Subject: [PATCH 063/102] Add a SITE_ANNOUNCEMENT constant to constance --- config/settings/base.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/settings/base.py b/config/settings/base.py index 50296358..24f58d19 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -371,7 +371,9 @@ # django-constance # ------------------------------------------------------------------------------ -CONSTANCE_CONFIG = {} +CONSTANCE_CONFIG = { + "SITE_ANNOUNCEMENT": ("", "Site-wide announcement message", str), +} CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend" CONSTANCE_IGNORE_ADMIN_VERSION_CHECK = True # CONSTANCE_DATABASE_CACHE_BACKEND = "default" From cf1aff5478c551de8793791eeee82acdddedeb72 Mon Sep 17 00:00:00 2001 From: Adrienne Stilp Date: Thu, 21 Mar 2024 15:08:40 -0700 Subject: [PATCH 064/102] Change account link alert bg to warning --- primed/templates/base.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/primed/templates/base.html b/primed/templates/base.html index 59db3d77..275bab1d 100644 --- a/primed/templates/base.html +++ b/primed/templates/base.html @@ -120,13 +120,12 @@ {% endif %} {% if not request.user.account %} -