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..edf553b6d0f 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' # Active, has a delegation protocol and SSO setup has been verified + UNAVAILABLE = 'Unavailable' # Does not have 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): @@ -79,6 +86,13 @@ class Institution(DirtyFieldsMixin, Loggable, ObjectIDMixin, BaseModel, Guardian default='' ) + # Institution SSO availability + sso_availability = models.CharField( + choices=[(choice.value, choice.name) for choice in SSOAvailability], + max_length=15, + default=SSOAvailability.HIDDEN.value + ) + # 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..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,16 @@ 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() + 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() @@ -145,6 +169,7 @@ def test_reactivate_institution(self): institution.save() institution.reactivate() assert institution.deactivated is None + assert institution.sso_availability == 'Unavailable' def test_send_deactivation_email_call_count(self): institution = InstitutionFactory()