From 179bed01446b76b7e6ec351fa37d4fd300802786 Mon Sep 17 00:00:00 2001 From: Ostap Zherebetskyi Date: Thu, 19 Mar 2026 17:28:29 +0200 Subject: [PATCH 1/3] Add SSO availability field and update institution reactivation logic --- admin_tests/institutions/test_views.py | 6 +++-- .../0036_institution_sso_in_progress.py | 18 -------------- .../0038_institution_sso_availability.py | 18 ++++++++++++++ osf/models/institution.py | 24 ++++++++++++++++++- osf_tests/test_institution.py | 10 ++++++++ 5 files changed, 55 insertions(+), 21 deletions(-) delete mode 100644 osf/migrations/0036_institution_sso_in_progress.py create mode 100644 osf/migrations/0038_institution_sso_availability.py diff --git a/admin_tests/institutions/test_views.py b/admin_tests/institutions/test_views.py index 13cb1456ab9..c3c8a3c3fab 100644 --- a/admin_tests/institutions/test_views.py +++ b/admin_tests/institutions/test_views.py @@ -139,7 +139,8 @@ def test_institution_form(self): 'name': 'New Name', 'logo_name': 'awesome_logo.png', 'domains': 'http://kris.biz/, http://www.little.biz/', - '_id': 'newawesomeprov' + '_id': 'newawesomeprov', + 'sso_availability': 'Public', } form = InstitutionForm(data=new_data) assert form.is_valid() @@ -214,7 +215,8 @@ def test_monthly_reporter_called_on_create(self, mock_monthly_reporter_do): 'email_domains': FakeList('domain_name', n=1), 'orcid_record_verified_source': '', 'delegation_protocol': '', - 'institutional_request_access_enabled': False + 'institutional_request_access_enabled': False, + 'sso_availability': 'Public', } form = InstitutionForm(data=data) assert form.is_valid() diff --git a/osf/migrations/0036_institution_sso_in_progress.py b/osf/migrations/0036_institution_sso_in_progress.py deleted file mode 100644 index 784188c4806..00000000000 --- a/osf/migrations/0036_institution_sso_in_progress.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.2.17 on 2026-02-16 14:34 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('osf', '0035_merge_20251215_1451'), - ] - - operations = [ - migrations.AddField( - model_name='institution', - name='sso_in_progress', - field=models.BooleanField(default=False), - ), - ] diff --git a/osf/migrations/0038_institution_sso_availability.py b/osf/migrations/0038_institution_sso_availability.py new file mode 100644 index 00000000000..c4be5de4001 --- /dev/null +++ b/osf/migrations/0038_institution_sso_availability.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2026-03-13 11:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0037_notification_refactor_post_release'), + ] + + operations = [ + migrations.AddField( + model_name='institution', + name='sso_availability', + field=models.CharField(choices=[('Public', 'PUBLIC'), ('Unavailable', 'UNAVAILABLE'), ('Hidden', 'HIDDEN')], default='Hidden', max_length=15), + ), + ] diff --git a/osf/models/institution.py b/osf/models/institution.py index 8dccdf6919a..bae41a8a177 100644 --- a/osf/models/institution.py +++ b/osf/models/institution.py @@ -46,6 +46,13 @@ class SsoFilterCriteriaAction(Enum): CONTAINS = 'contains' # Type 2: SSO releases a multi-value attribute, of which one value matches IN = 'in' # Type 3: SSO releases a single-value attribute that have multiple valid values +class SSOAvailability(Enum): + """Defines 3 SSO availability states for institutions. + """ + PUBLIC = 'Public' + UNAVAILABLE = 'Unavailable' + HIDDEN = 'Hidden' + class InstitutionManager(models.Manager): @@ -79,6 +86,13 @@ class Institution(DirtyFieldsMixin, Loggable, ObjectIDMixin, BaseModel, Guardian default='' ) + # Institution SSO availability + sso_availability = models.CharField( + choices=[(el.value, el.name) for el in SSOAvailability], + max_length=15, + default='Hidden' + ) + # Default Storage Region storage_regions = models.ManyToManyField( 'addons_osfstorage.Region', @@ -125,7 +139,6 @@ class Institution(DirtyFieldsMixin, Loggable, ObjectIDMixin, BaseModel, Guardian default='', help_text='Full URL where institutional admins can access archived metrics reports.', ) - sso_in_progress = models.BooleanField(default=False) class Meta: # custom permissions for use in the OSF Admin App @@ -238,6 +251,11 @@ def deactivate(self): """ if not self.deactivated: self.deactivated = timezone.now() + if not self.delegation_protocol: + self.sso_availability = SSOAvailability.UNAVAILABLE.value + else: + self.sso_availability = SSOAvailability.HIDDEN.value + self.save() # Django mangers aren't used when querying on related models. Thus, we can query # affiliated users and send notification emails after the institution has been deactivated. @@ -252,6 +270,10 @@ def reactivate(self): """ if self.deactivated: self.deactivated = None + if not self.delegation_protocol: + self.sso_availability = SSOAvailability.UNAVAILABLE.value + else: + self.sso_availability = SSOAvailability.HIDDEN.value self.save() else: message = f'Action rejected - reactivating an active institution [{self._id}].' diff --git a/osf_tests/test_institution.py b/osf_tests/test_institution.py index 039b0ce04dd..9006769ebbe 100644 --- a/osf_tests/test_institution.py +++ b/osf_tests/test_institution.py @@ -139,12 +139,22 @@ def test_deactivate_institution(self): assert institution.deactivated is not None assert mock__send_deactivation_email.called + def test_reactivate_sso_institution(self): + institution = InstitutionFactory() + institution.delegation_protocol = 'saml-shib' + institution.deactivated = timezone.now() + institution.save() + institution.reactivate() + assert institution.deactivated is None + assert institution.sso_availability == 'Hidden' + def test_reactivate_institution(self): institution = InstitutionFactory() institution.deactivated = timezone.now() institution.save() institution.reactivate() assert institution.deactivated is None + assert institution.sso_availability == 'Unavailable' def test_send_deactivation_email_call_count(self): institution = InstitutionFactory() From 1d74ad3a2fcb6c07ca7bcd18f101689eead02651 Mon Sep 17 00:00:00 2001 From: Ostap Zherebetskyi Date: Thu, 19 Mar 2026 17:34:01 +0200 Subject: [PATCH 2/3] Update SSO availability logic and add tests for institution deactivation --- osf/models/institution.py | 10 +++++----- osf_tests/test_institution.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/osf/models/institution.py b/osf/models/institution.py index bae41a8a177..c1679892f69 100644 --- a/osf/models/institution.py +++ b/osf/models/institution.py @@ -49,9 +49,9 @@ class SsoFilterCriteriaAction(Enum): class SSOAvailability(Enum): """Defines 3 SSO availability states for institutions. """ - PUBLIC = 'Public' - UNAVAILABLE = 'Unavailable' - HIDDEN = 'Hidden' + PUBLIC = 'Public' # Active and has a delegation protocol + UNAVAILABLE = 'Unavailable' # Does not have a delegation protocol + HIDDEN = 'Hidden' # Inactive and has a delegation protocol class InstitutionManager(models.Manager): @@ -88,9 +88,9 @@ class Institution(DirtyFieldsMixin, Loggable, ObjectIDMixin, BaseModel, Guardian # Institution SSO availability sso_availability = models.CharField( - choices=[(el.value, el.name) for el in SSOAvailability], + choices=[(choice.value, choice.name) for choice in SSOAvailability], max_length=15, - default='Hidden' + default=SSOAvailability.HIDDEN.value ) # Default Storage Region diff --git a/osf_tests/test_institution.py b/osf_tests/test_institution.py index 9006769ebbe..867723cf291 100644 --- a/osf_tests/test_institution.py +++ b/osf_tests/test_institution.py @@ -128,6 +128,20 @@ def test_deactivated_institution_in_all_institutions(self): institution.save() assert institution in Institution.objects.get_all_institutions() + def test_deactivate_sso_institution(self): + institution = InstitutionFactory() + institution.delegation_protocol = 'saml-shib' + institution.save() + with mock.patch.object( + institution, + '_send_deactivation_email', + return_value=None + ) as mock__send_deactivation_email: + institution.deactivate() + assert institution.deactivated is not None + assert mock__send_deactivation_email.called + assert institution.sso_availability == 'Hidden' + def test_deactivate_institution(self): institution = InstitutionFactory() with mock.patch.object( @@ -138,6 +152,7 @@ def test_deactivate_institution(self): institution.deactivate() assert institution.deactivated is not None assert mock__send_deactivation_email.called + assert institution.sso_availability == 'Unavailable' def test_reactivate_sso_institution(self): institution = InstitutionFactory() From 26efc85c0cd9656df1156226e2d1b55c7e84b661 Mon Sep 17 00:00:00 2001 From: Longze Chen Date: Thu, 19 Mar 2026 11:43:39 -0400 Subject: [PATCH 3/3] Apply suggestions from @cslzchen Co-authored-by: Longze Chen --- osf/models/institution.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osf/models/institution.py b/osf/models/institution.py index c1679892f69..edf553b6d0f 100644 --- a/osf/models/institution.py +++ b/osf/models/institution.py @@ -49,9 +49,9 @@ class SsoFilterCriteriaAction(Enum): class SSOAvailability(Enum): """Defines 3 SSO availability states for institutions. """ - PUBLIC = 'Public' # Active and has a delegation protocol + PUBLIC = 'Public' # Active, has a delegation protocol and SSO setup has been verified UNAVAILABLE = 'Unavailable' # Does not have a delegation protocol - HIDDEN = 'Hidden' # Inactive and has a delegation protocol + HIDDEN = 'Hidden' # 1) Inactive and has a delegation protocol, or 2) active, has a delegation protocol and SSO setup is in-progress class InstitutionManager(models.Manager):