Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO.

26.9.0 (2026-05-07)
===================

- OSF4I In-progress SSO Project - BE Piece

26.8.2 (2026-05-06)
===================

Expand Down
21 changes: 20 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,25 @@ 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(None, 'SSO availability must be set to "Unavailable" when no delegation protocol is configured.')

elif self.cleaned_data['deactivated']:
if self.cleaned_data['sso_availability'] != SSOAvailability.HIDDEN.value:
self.add_error(None, 'SSO availability must be set to "Hidden" when the institution is deactivated.')

else:
if self.cleaned_data['sso_availability'] not in [
SSOAvailability.PUBLIC.value,
SSOAvailability.HIDDEN.value
]:
self.add_error(None, 'SSO availability must be set to "Public" or "Hidden" when delegation protocol is configured.')


class InstitutionalMetricsAdminRegisterForm(forms.Form):
""" A form that finds an existing OSF User, and grants permissions to that
Expand Down
12 changes: 12 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,17 @@ 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:
for error in form.non_field_errors():
messages.error(request, error)
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 %}
<button
{% if not cas_login_url or institution.deactivated is not None %}disabled{% endif %}
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>
</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 {'__all__': ['SSO availability must be set to "Unavailable" when no delegation protocol is configured.']} == 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
14 changes: 14 additions & 0 deletions framework/auth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from framework import sentry
from website import settings
from website.util import web_url_for

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -169,3 +170,16 @@ def generate_csl_given_name(given_name, middle_names='', suffix=''):
if suffix:
given = f'{given}, {suffix}'
return given

def get_default_osf_login_url():
"""Return the default OSF login URL.
"""
next_url = web_url_for(view_name='index', _absolute=True, _angular_route=True)
return web_url_for(view_name='auth_login', _absolute=True, next=next_url)


def get_default_osf_logout_url():
"""Return the default OSF logout URL.
"""
next_url = web_url_for(view_name='index', _absolute=True, _angular_route=True)
return web_url_for(view_name='auth_logout', _absolute=True, next=next_url)
Loading