Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
224f38b
allow angular url to be in next_url
ihorsokhanexoft Feb 11, 2026
7b67b12
renamed constant
ihorsokhanexoft Feb 12, 2026
235dac6
added tests and debug mode
ihorsokhanexoft Feb 13, 2026
23dd926
angular url
ihorsokhanexoft Feb 11, 2026
cb141e1
angular url
ihorsokhanexoft Feb 12, 2026
b16005c
added tests and debug mode
ihorsokhanexoft Feb 13, 2026
d48f9ae
edited comments
ihorsokhanexoft Feb 16, 2026
261a680
added sso_in_progress field to Institution model
ihorsokhanexoft Feb 16, 2026
7c7dab7
Merge pull request #11580 from ihorsokhanexoft/fix/ENG-10238
Ostap-Zherebetskyi Feb 18, 2026
e966ae1
Merge pull request #11592 from ihorsokhanexoft/feature/ENG-10289
Ostap-Zherebetskyi Feb 18, 2026
5d474ef
Add tests
Vlad0n20 Feb 13, 2026
2fce58d
Merge pull request #11589 from Vlad0n20/fix/ENG-10272
Ostap-Zherebetskyi Feb 19, 2026
7a54304
Merge remote-tracking branch 'upstream/develop' into feature/osf4i-in…
cslzchen Mar 19, 2026
179bed0
Add SSO availability field and update institution reactivation logic
Ostap-Zherebetskyi Mar 19, 2026
1d74ad3
Update SSO availability logic and add tests for institution deactivation
Ostap-Zherebetskyi Mar 19, 2026
26efc85
Apply suggestions from @cslzchen
cslzchen Mar 19, 2026
1c709f6
Merge pull request #11629 from Ostap-Zherebetskyi/feature/reactivate_…
cslzchen Mar 19, 2026
73dfa28
Add CAS login URL property and implement copy modal in institution de…
Ostap-Zherebetskyi Mar 20, 2026
ff32e01
Refactor SSO URL generation and clean up unused code in institution m…
Ostap-Zherebetskyi Mar 23, 2026
e67647a
Merge pull request #11645 from Ostap-Zherebetskyi/feature/sso_login_url
cslzchen Mar 23, 2026
7d6a4a3
Add management command to backfill SSO availability
Ostap-Zherebetskyi Mar 24, 2026
070b376
Merge pull request #11650 from Ostap-Zherebetskyi/feature/sso_availab…
cslzchen Mar 24, 2026
50ad102
Add sso_availability field to InstitutionSerializer and enable filter…
Ostap-Zherebetskyi Mar 25, 2026
ced2282
Merge pull request #11653 from Ostap-Zherebetskyi/feature/institution…
cslzchen Mar 25, 2026
40da7b3
Add SSO Availability column to institutions list view
Ostap-Zherebetskyi Mar 26, 2026
2412860
Merge pull request #11662 from Ostap-Zherebetskyi/feature/sso_availab…
cslzchen Mar 26, 2026
dc02e51
Add get_sso_institutions method to InstitutionManager and update Inst…
Ostap-Zherebetskyi Mar 27, 2026
51ed771
Add test for default filter excluding institutions with hidden SSO av…
Ostap-Zherebetskyi Mar 27, 2026
417473d
Rename get_sso_institutions to get_non_hidden_institutions
Ostap-Zherebetskyi Mar 30, 2026
3a5a6b6
Merge pull request #11665 from Ostap-Zherebetskyi/feature/institution…
cslzchen Mar 30, 2026
3ae6a47
Enhance institution population script with test institution generatio…
Ostap-Zherebetskyi Mar 31, 2026
001345d
Refactor institution update logic to handle non-existent institutions…
Ostap-Zherebetskyi Mar 31, 2026
bb67d74
Merge pull request #11670 from Ostap-Zherebetskyi/feature/institution…
cslzchen Mar 31, 2026
5dd0b75
Merge branch 'develop' into feature/merge_develop
Ostap-Zherebetskyi Apr 2, 2026
d367f10
Implement validation for SSO availability in InstitutionForm
Ostap-Zherebetskyi Apr 6, 2026
2e5719a
fix institution form unit tests
Ostap-Zherebetskyi Apr 6, 2026
cb9fc6b
Refactor SSO availability validation in InstitutionForm and override …
Ostap-Zherebetskyi Apr 6, 2026
8fc73b2
Merge remote-tracking branch 'upstream/develop' into feature/merge_de…
Ostap-Zherebetskyi Apr 6, 2026
4c4f559
Apply suggestion from @cslzchen
cslzchen Apr 6, 2026
0302ccd
Merge pull request #11681 from Ostap-Zherebetskyi/feature/sso_constrains
cslzchen Apr 6, 2026
612a9ab
Merge pull request #11675 from Ostap-Zherebetskyi/feature/merge_develop
cslzchen Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion admin/institutions/forms.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from django import forms
from osf.models import Institution
from osf.models.institution import Institution, SSOAvailability


class InstitutionForm(forms.ModelForm):
Expand All @@ -10,6 +10,18 @@ class Meta:
'is_deleted', 'contributors', 'storage_regions',
]

def clean(self):
super().clean()

if hasattr(self, 'cleaned_data') and self.changed_data:
if not self.cleaned_data['delegation_protocol']:
if self.cleaned_data['sso_availability'] != SSOAvailability.UNAVAILABLE.value:
self.add_error('sso_availability', 'Must be UNAVAILABLE when no protocol')

elif self.cleaned_data['deactivated']:
if self.cleaned_data['sso_availability'] != SSOAvailability.HIDDEN.value:
self.add_error('sso_availability', 'Inactive must be HIDDEN')


class InstitutionalMetricsAdminRegisterForm(forms.Form):
""" A form that finds an existing OSF User, and grants permissions to that
Expand Down
11 changes: 11 additions & 0 deletions admin/institutions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def get_context_data(self, *args, **kwargs):
institution_dict = model_to_dict(institution)
kwargs.setdefault('page_number', self.request.GET.get('page', '1'))
kwargs['institution'] = institution_dict
kwargs['cas_login_url'] = institution.cas_login_url
kwargs['logo_path'] = institution.logo_path
kwargs['banner_path'] = institution.banner_path
fields = institution_dict
Expand Down Expand Up @@ -117,6 +118,16 @@ def get_context_data(self, *args, **kwargs):
def get_success_url(self, *args, **kwargs):
return reverse_lazy('institutions:detail', kwargs={'institution_id': self.kwargs.get('institution_id')})

def post(self, request, *args, **kwargs):
# Override `post` method in `django.views.generic.edit.ProcessFormView` due to custom behavior
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
else:
messages.error(request, form.errors)
return redirect('institutions:detail', institution_id=self.kwargs.get('institution_id'))


class InstitutionExport(PermissionRequiredMixin, View):
permission_required = 'osf.view_institution'
Expand Down
62 changes: 61 additions & 1 deletion admin/templates/institutions/detail.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,33 @@
{% extends "base.html" %}
{% load static %}
{% block top_includes %}
<link rel="stylesheet" type="text/css" href="/static/css/institutions.css" />
<link rel="stylesheet" type="text/css" href="/static/css/institutions.css" />
<style>
#copy-modal {
display: none; /* hidden by default */
position: fixed;
z-index: 2000;
inset: 0;
}
#copy-modal.show_modal {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
#copy-modal .modal-content {
background: white;
width: 100%;
max-width: 600px;
max-height: 80vh;
padding: 20px;
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
</style>
{% endblock %}
{% load comment_extras %}
{% block title %}
Expand Down Expand Up @@ -34,6 +60,18 @@
{% if perms.osf.change_institution %}
<a class="btn btn-primary" href={% url 'institutions:list_and_add_admin' institution.id %}>Manage Admins</a>
{% endif %}
{% if cas_login_url %}
<button class="btn btn-primary" onclick="openCopyPopup('{{ cas_login_url|escapejs }}')">
Copy SSO URL
</button>
<div id="copy-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeCopyPopup()">&times;</span>
<p>Value copied. You can also copy manually:</p>
<textarea id="copy-input" readonly></textarea>
</div>
</div>
{% endif %}
</div>
</div>

Expand Down Expand Up @@ -169,5 +207,27 @@ <h3>Are you sure you want to run monthly report for this institution?</h3>
});
});
});

window.openCopyPopup = function(text) {
const modal = document.getElementById("copy-modal");
const input = document.getElementById("copy-input");
input.value = text;
modal.classList.add("show_modal");
navigator.clipboard.writeText(text).catch(() => {});
input.focus();
input.select();
};

window.closeCopyPopup = function() {
document.getElementById("copy-modal").classList.remove("show_modal");
};

// Close on outside click
window.onclick = function(event) {
const modal = document.getElementById("copy-modal");
if (event.target === modal) {
modal.classList.remove("show_modal");
}
};
</script>
{% endblock %}
2 changes: 2 additions & 0 deletions admin/templates/institutions/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ <h2>List of Institutions</h2>
<th>Name</th>
<th>Description</th>
<th>Status</th>
<th>SSO Availability</th>
</tr>
</thead>
<tbody>
Expand All @@ -37,6 +38,7 @@ <h2>List of Institutions</h2>
{% else %}
<td>DEACTIVATED</td>
{% endif %}
<td>{{ institution.sso_availability }}</td>
</tr>
{% endfor %}
</tbody>
Expand Down
18 changes: 16 additions & 2 deletions admin_tests/institutions/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,24 @@ 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': 'Unavailable',
}
form = InstitutionForm(data=new_data)
assert form.is_valid()

def test_institution_form_invalid(self):
new_data = {
'name': 'New Name',
'logo_name': 'awesome_logo.png',
'domains': 'http://kris.biz/, http://www.little.biz/',
'_id': 'newawesomeprov',
'sso_availability': 'Public',
}
form = InstitutionForm(data=new_data)
assert not form.is_valid()
assert 'sso_availability' in form.errors


class TestInstitutionExport(AdminTestCase):
def setUp(self):
Expand Down Expand Up @@ -214,7 +227,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': 'Unavailable',
}
form = InstitutionForm(data=data)
assert form.is_valid()
Expand Down
2 changes: 2 additions & 0 deletions api/institutions/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ class InstitutionSerializer(JSONAPISerializer):
'id',
'name',
'auth_url',
'sso_availability',
])

name = ser.CharField(read_only=True)
id = ser.CharField(read_only=True, source='_id')
sso_availability = ser.CharField(read_only=True)
description = ser.CharField(read_only=True)
auth_url = ser.CharField(read_only=True)
iri = ser.CharField(read_only=True, source='identifier_domain')
Expand Down
7 changes: 6 additions & 1 deletion api/institutions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ class InstitutionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin):
base_permissions.TokenHasScope,
)

# Adding sso_availability to MULTIPLE_VALUES_FIELDS to allow filtering institutions by multiple sso_availability values, e.g. ?filter[sso_availability]=[Unavailable,Hidden]
MULTIPLE_VALUES_FIELDS = ListFilterMixin.MULTIPLE_VALUES_FIELDS + ['sso_availability']

required_read_scopes = [CoreScopes.INSTITUTION_READ]
required_write_scopes = [CoreScopes.NULL]
model_class = Institution
Expand All @@ -85,7 +88,9 @@ class InstitutionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin):
ordering = ('name',)

def get_default_queryset(self):
return Institution.objects.filter(_id__isnull=False, is_deleted=False)
if 'filter[sso_availability]' in self.request.query_params:
return Institution.objects.filter(_id__isnull=False, is_deleted=False)
return Institution.objects.get_non_hidden_institutions().filter(_id__isnull=False, is_deleted=False)

# overrides ListAPIView
def get_queryset(self):
Expand Down
54 changes: 54 additions & 0 deletions api_tests/institutions/views/test_institution_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ def institution_one(self):
def institution_two(self):
return InstitutionFactory()

@pytest.fixture()
def institution_three(self):
return InstitutionFactory()

@pytest.fixture()
def url_institution(self):
return f'/{API_BASE}institutions/'
Expand Down Expand Up @@ -47,3 +51,53 @@ def test_does_not_return_deleted_institution(
assert len(res.json['data']) == 1
assert institution_one._id not in ids
assert institution_two._id in ids

def test_sso_availability_filter(
self, app, institution_one, institution_two, institution_three, url_institution
):
institution_one.sso_availability = 'Unavailable'
institution_one.save()

institution_two.sso_availability = 'Public'
institution_two.save()

institution_three.sso_availability = 'Hidden'
institution_three.save()

res = app.get(f'{url_institution}?filter[sso_availability]=[Unavailable]')
assert res.status_code == 200

ids = [each['id'] for each in res.json['data']]
assert len(res.json['data']) == 1
assert institution_one._id in ids
assert institution_two._id not in ids

res = app.get(f'{url_institution}?filter[sso_availability]=[Unavailable,Hidden]')
assert res.status_code == 200

ids = [each['id'] for each in res.json['data']]
assert len(res.json['data']) == 2
assert institution_one._id in ids
assert institution_three._id in ids
assert institution_two._id not in ids

def test_default_filter_excludes_institutions_with_sso_availability_hidden(
self, app, institution_one, institution_two, institution_three, url_institution
):
institution_one.sso_availability = 'Unavailable'
institution_one.save()

institution_two.sso_availability = 'Public'
institution_two.save()

institution_three.sso_availability = 'Hidden'
institution_three.save()

res = app.get(url_institution)
assert res.status_code == 200

ids = [each['id'] for each in res.json['data']]
assert len(res.json['data']) == 2
assert institution_one._id in ids
assert institution_two._id in ids
assert institution_three._id not in ids
4 changes: 4 additions & 0 deletions framework/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1200,6 +1200,10 @@ def validate_next_url(next_url):
:return: True if valid, False otherwise
"""

# allow redirection to angular locally
if settings.LOCAL_ANGULAR_URL in next_url and settings.DEBUG_MODE:
return True

# disable external domain using `//`: the browser allows `//` as a shortcut for non-protocol specific requests
# like http:// or https:// depending on the use of SSL on the page already.
if next_url.startswith('//'):
Expand Down
79 changes: 79 additions & 0 deletions osf/management/commands/backfill_sso_availability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q

from osf.models.institution import Institution, SSOAvailability, IntegrationType


class Command(BaseCommand):
help = 'Backfill sso_availability using fast DB-level updates'

def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show how many rows would be updated without applying changes',
)

def handle(self, *args, **options):
dry_run = options['dry_run']

# Build querysets
qs_no_protocol = Institution.objects.filter(
delegation_protocol=IntegrationType.NONE.value
).exclude(
sso_availability=SSOAvailability.UNAVAILABLE.value
)

qs_inactive_with_protocol = Institution.objects.filter(
~Q(delegation_protocol=IntegrationType.NONE.value),
deactivated__isnull=False
).exclude(
sso_availability=SSOAvailability.HIDDEN.value
)

qs_active_with_protocol = Institution.objects.filter(
~Q(delegation_protocol=IntegrationType.NONE.value),
deactivated__isnull=True
).exclude(
sso_availability=SSOAvailability.PUBLIC.value
)

count_no_protocol = qs_no_protocol.count()
count_inactive = qs_inactive_with_protocol.count()
count_active = qs_active_with_protocol.count()

total = count_no_protocol + count_inactive + count_active

self.stdout.write('Planned updates:')
self.stdout.write(f" No protocol → UNAVAILABLE: {count_no_protocol}")
self.stdout.write(f" Inactive + protocol → HIDDEN: {count_inactive}")
self.stdout.write(f" Active + protocol → PUBLIC: {count_active}")
self.stdout.write(f" TOTAL: {total}")

if dry_run:
self.stdout.write(self.style.WARNING('Dry run, no changes applied.'))
return

with transaction.atomic():
updated_no_protocol = qs_no_protocol.update(
sso_availability=SSOAvailability.UNAVAILABLE.value
)

updated_inactive = qs_inactive_with_protocol.update(
sso_availability=SSOAvailability.HIDDEN.value
)

updated_active = qs_active_with_protocol.update(
sso_availability=SSOAvailability.PUBLIC.value
)

self.stdout.write(
self.style.SUCCESS(
'Done:\n'
f" UNAVAILABLE: {updated_no_protocol}\n"
f" HIDDEN: {updated_inactive}\n"
f" PUBLIC: {updated_active}\n"
f" TOTAL: {updated_no_protocol + updated_inactive + updated_active}"
)
)
18 changes: 18 additions & 0 deletions osf/migrations/0038_institution_sso_availability.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
Loading
Loading