Skip to content

Commit 55d84b5

Browse files
committed
Merge 'develop' into feature/9691-osfmetrics-migration
2 parents ef6e31f + 9e13c06 commit 55d84b5

40 files changed

Lines changed: 807 additions & 121 deletions

CHANGELOG

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,21 @@
22

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

5+
26.9.0 (2026-05-07)
6+
===================
7+
8+
- OSF4I In-progress SSO Project - BE Piece
9+
10+
26.8.2 (2026-05-06)
11+
===================
12+
13+
- Hotfix to avoid 502 on creating preprint version from rejected version
14+
15+
26.8.1 (2026-04-30)
16+
===================
17+
18+
- Hotfix for registrations sometimes fail to become public after approval
19+
520
26.8.0 (2026-04-23)
621
===================
722

admin/institutions/forms.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from django import forms
2-
from osf.models import Institution
2+
from osf.models.institution import Institution, SSOAvailability
33

44

55
class InstitutionForm(forms.ModelForm):
@@ -10,6 +10,25 @@ class Meta:
1010
'is_deleted', 'contributors', 'storage_regions',
1111
]
1212

13+
def clean(self):
14+
super().clean()
15+
16+
if hasattr(self, 'cleaned_data') and self.changed_data:
17+
if not self.cleaned_data['delegation_protocol']:
18+
if self.cleaned_data['sso_availability'] != SSOAvailability.UNAVAILABLE.value:
19+
self.add_error(None, 'SSO availability must be set to "Unavailable" when no delegation protocol is configured.')
20+
21+
elif self.cleaned_data['deactivated']:
22+
if self.cleaned_data['sso_availability'] != SSOAvailability.HIDDEN.value:
23+
self.add_error(None, 'SSO availability must be set to "Hidden" when the institution is deactivated.')
24+
25+
else:
26+
if self.cleaned_data['sso_availability'] not in [
27+
SSOAvailability.PUBLIC.value,
28+
SSOAvailability.HIDDEN.value
29+
]:
30+
self.add_error(None, 'SSO availability must be set to "Public" or "Hidden" when delegation protocol is configured.')
31+
1332

1433
class InstitutionalMetricsAdminRegisterForm(forms.Form):
1534
""" A form that finds an existing OSF User, and grants permissions to that

admin/institutions/views.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def get_context_data(self, *args, **kwargs):
5656
institution_dict = model_to_dict(institution)
5757
kwargs.setdefault('page_number', self.request.GET.get('page', '1'))
5858
kwargs['institution'] = institution_dict
59+
kwargs['cas_login_url'] = institution.cas_login_url
5960
kwargs['logo_path'] = institution.logo_path
6061
kwargs['banner_path'] = institution.banner_path
6162
fields = institution_dict
@@ -117,6 +118,17 @@ def get_context_data(self, *args, **kwargs):
117118
def get_success_url(self, *args, **kwargs):
118119
return reverse_lazy('institutions:detail', kwargs={'institution_id': self.kwargs.get('institution_id')})
119120

121+
def post(self, request, *args, **kwargs):
122+
# Override `post` method in `django.views.generic.edit.ProcessFormView` due to custom behavior
123+
self.object = self.get_object()
124+
form = self.get_form()
125+
if form.is_valid():
126+
return self.form_valid(form)
127+
else:
128+
for error in form.non_field_errors():
129+
messages.error(request, error)
130+
return redirect('institutions:detail', institution_id=self.kwargs.get('institution_id'))
131+
120132

121133
class InstitutionExport(PermissionRequiredMixin, View):
122134
permission_required = 'osf.view_institution'

admin/templates/institutions/detail.html

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,33 @@
11
{% extends "base.html" %}
22
{% load static %}
33
{% block top_includes %}
4-
<link rel="stylesheet" type="text/css" href="/static/css/institutions.css" />
4+
<link rel="stylesheet" type="text/css" href="/static/css/institutions.css" />
5+
<style>
6+
#copy-modal {
7+
display: none; /* hidden by default */
8+
position: fixed;
9+
z-index: 2000;
10+
inset: 0;
11+
}
12+
#copy-modal.show_modal {
13+
display: flex;
14+
align-items: center;
15+
justify-content: center;
16+
padding: 16px;
17+
}
18+
#copy-modal .modal-content {
19+
background: white;
20+
width: 100%;
21+
max-width: 600px;
22+
max-height: 80vh;
23+
padding: 20px;
24+
border-radius: 10px;
25+
display: flex;
26+
flex-direction: column;
27+
gap: 10px;
28+
overflow: hidden;
29+
}
30+
</style>
531
{% endblock %}
632
{% load comment_extras %}
733
{% block title %}
@@ -34,6 +60,18 @@
3460
{% if perms.osf.change_institution %}
3561
<a class="btn btn-primary" href={% url 'institutions:list_and_add_admin' institution.id %}>Manage Admins</a>
3662
{% endif %}
63+
<button
64+
{% if not cas_login_url or institution.deactivated is not None %}disabled{% endif %}
65+
class="btn btn-primary" onclick="openCopyPopup('{{ cas_login_url|escapejs }}')">
66+
Copy SSO URL
67+
</button>
68+
<div id="copy-modal" class="modal">
69+
<div class="modal-content">
70+
<span class="close" onclick="closeCopyPopup()">&times;</span>
71+
<p>Value copied. You can also copy manually:</p>
72+
<textarea id="copy-input" readonly></textarea>
73+
</div>
74+
</div>
3775
</div>
3876
</div>
3977

@@ -169,5 +207,27 @@ <h3>Are you sure you want to run monthly report for this institution?</h3>
169207
});
170208
});
171209
});
210+
211+
window.openCopyPopup = function(text) {
212+
const modal = document.getElementById("copy-modal");
213+
const input = document.getElementById("copy-input");
214+
input.value = text;
215+
modal.classList.add("show_modal");
216+
navigator.clipboard.writeText(text).catch(() => {});
217+
input.focus();
218+
input.select();
219+
};
220+
221+
window.closeCopyPopup = function() {
222+
document.getElementById("copy-modal").classList.remove("show_modal");
223+
};
224+
225+
// Close on outside click
226+
window.onclick = function(event) {
227+
const modal = document.getElementById("copy-modal");
228+
if (event.target === modal) {
229+
modal.classList.remove("show_modal");
230+
}
231+
};
172232
</script>
173233
{% endblock %}

admin/templates/institutions/list.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ <h2>List of Institutions</h2>
2020
<th>Name</th>
2121
<th>Description</th>
2222
<th>Status</th>
23+
<th>SSO Availability</th>
2324
</tr>
2425
</thead>
2526
<tbody>
@@ -37,6 +38,7 @@ <h2>List of Institutions</h2>
3738
{% else %}
3839
<td>DEACTIVATED</td>
3940
{% endif %}
41+
<td>{{ institution.sso_availability }}</td>
4042
</tr>
4143
{% endfor %}
4244
</tbody>

admin_tests/institutions/test_views.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,24 @@ def test_institution_form(self):
139139
'name': 'New Name',
140140
'logo_name': 'awesome_logo.png',
141141
'domains': 'http://kris.biz/, http://www.little.biz/',
142-
'_id': 'newawesomeprov'
142+
'_id': 'newawesomeprov',
143+
'sso_availability': 'Unavailable',
143144
}
144145
form = InstitutionForm(data=new_data)
145146
assert form.is_valid()
146147

148+
def test_institution_form_invalid(self):
149+
new_data = {
150+
'name': 'New Name',
151+
'logo_name': 'awesome_logo.png',
152+
'domains': 'http://kris.biz/, http://www.little.biz/',
153+
'_id': 'newawesomeprov',
154+
'sso_availability': 'Public',
155+
}
156+
form = InstitutionForm(data=new_data)
157+
assert not form.is_valid()
158+
assert {'__all__': ['SSO availability must be set to "Unavailable" when no delegation protocol is configured.']} == form.errors
159+
147160

148161
class TestInstitutionExport(AdminTestCase):
149162
def setUp(self):
@@ -214,7 +227,8 @@ def test_monthly_reporter_called_on_create(self, mock_monthly_reporter_do):
214227
'email_domains': FakeList('domain_name', n=1),
215228
'orcid_record_verified_source': '',
216229
'delegation_protocol': '',
217-
'institutional_request_access_enabled': False
230+
'institutional_request_access_enabled': False,
231+
'sso_availability': 'Unavailable',
218232
}
219233
form = InstitutionForm(data=data)
220234
assert form.is_valid()

api/institutions/serializers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ class InstitutionSerializer(JSONAPISerializer):
2929
'id',
3030
'name',
3131
'auth_url',
32+
'sso_availability',
3233
])
3334

3435
name = ser.CharField(read_only=True)
3536
id = ser.CharField(read_only=True, source='_id')
37+
sso_availability = ser.CharField(read_only=True)
3638
description = ser.CharField(read_only=True)
3739
auth_url = ser.CharField(read_only=True)
3840
iri = ser.CharField(read_only=True, source='identifier_domain')

api/institutions/views.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ class InstitutionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin):
7373
base_permissions.TokenHasScope,
7474
)
7575

76+
# Adding sso_availability to MULTIPLE_VALUES_FIELDS to allow filtering institutions by multiple sso_availability values, e.g. ?filter[sso_availability]=[Unavailable,Hidden]
77+
MULTIPLE_VALUES_FIELDS = ListFilterMixin.MULTIPLE_VALUES_FIELDS + ['sso_availability']
78+
7679
required_read_scopes = [CoreScopes.INSTITUTION_READ]
7780
required_write_scopes = [CoreScopes.NULL]
7881
model_class = Institution
@@ -85,7 +88,9 @@ class InstitutionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin):
8588
ordering = ('name',)
8689

8790
def get_default_queryset(self):
88-
return Institution.objects.filter(_id__isnull=False, is_deleted=False)
91+
if 'filter[sso_availability]' in self.request.query_params:
92+
return Institution.objects.filter(_id__isnull=False, is_deleted=False)
93+
return Institution.objects.get_non_hidden_institutions().filter(_id__isnull=False, is_deleted=False)
8994

9095
# overrides ListAPIView
9196
def get_queryset(self):

api/users/views.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1480,7 +1480,6 @@ def post(self, request, *args, **kwargs):
14801480

14811481
user.date_last_logged_in = timezone.now()
14821482
user.external_identity[provider][provider_id] = 'VERIFIED'
1483-
user.social[provider.lower()] = provider_id
14841483
del user.email_verifications[token]
14851484
user.verification_key = generate_verification_key()
14861485
user.save()

api_tests/institutions/views/test_institution_list.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ def institution_one(self):
1515
def institution_two(self):
1616
return InstitutionFactory()
1717

18+
@pytest.fixture()
19+
def institution_three(self):
20+
return InstitutionFactory()
21+
1822
@pytest.fixture()
1923
def url_institution(self):
2024
return f'/{API_BASE}institutions/'
@@ -47,3 +51,53 @@ def test_does_not_return_deleted_institution(
4751
assert len(res.json['data']) == 1
4852
assert institution_one._id not in ids
4953
assert institution_two._id in ids
54+
55+
def test_sso_availability_filter(
56+
self, app, institution_one, institution_two, institution_three, url_institution
57+
):
58+
institution_one.sso_availability = 'Unavailable'
59+
institution_one.save()
60+
61+
institution_two.sso_availability = 'Public'
62+
institution_two.save()
63+
64+
institution_three.sso_availability = 'Hidden'
65+
institution_three.save()
66+
67+
res = app.get(f'{url_institution}?filter[sso_availability]=[Unavailable]')
68+
assert res.status_code == 200
69+
70+
ids = [each['id'] for each in res.json['data']]
71+
assert len(res.json['data']) == 1
72+
assert institution_one._id in ids
73+
assert institution_two._id not in ids
74+
75+
res = app.get(f'{url_institution}?filter[sso_availability]=[Unavailable,Hidden]')
76+
assert res.status_code == 200
77+
78+
ids = [each['id'] for each in res.json['data']]
79+
assert len(res.json['data']) == 2
80+
assert institution_one._id in ids
81+
assert institution_three._id in ids
82+
assert institution_two._id not in ids
83+
84+
def test_default_filter_excludes_institutions_with_sso_availability_hidden(
85+
self, app, institution_one, institution_two, institution_three, url_institution
86+
):
87+
institution_one.sso_availability = 'Unavailable'
88+
institution_one.save()
89+
90+
institution_two.sso_availability = 'Public'
91+
institution_two.save()
92+
93+
institution_three.sso_availability = 'Hidden'
94+
institution_three.save()
95+
96+
res = app.get(url_institution)
97+
assert res.status_code == 200
98+
99+
ids = [each['id'] for each in res.json['data']]
100+
assert len(res.json['data']) == 2
101+
assert institution_one._id in ids
102+
assert institution_two._id in ids
103+
assert institution_three._id not in ids

0 commit comments

Comments
 (0)