diff --git a/api/base/serializers.py b/api/base/serializers.py index ac28139da97..e10b602777f 100644 --- a/api/base/serializers.py +++ b/api/base/serializers.py @@ -171,6 +171,18 @@ def should_show(self, instance): return request and (request.user.is_anonymous or has_admin_scope) +class ShowIfObjectPermission(ConditionalField): + """Show the field only for users with a given object permission + """ + def __init__(self, field, *, permission: str, **kwargs): + super().__init__(field, **kwargs) + self._required_object_permission = permission + + def should_show(self, instance): + _request = self.context.get('request') + return _request.user.has_perm(self._required_object_permission, obj=instance) + + class HideIfRegistration(ConditionalField): """ If node is a registration, this field will return None. diff --git a/api/institutions/serializers.py b/api/institutions/serializers.py index d19e4f7ff0c..54e25d4d263 100644 --- a/api/institutions/serializers.py +++ b/api/institutions/serializers.py @@ -12,6 +12,7 @@ BaseAPISerializer, ShowIfVersion, IDField, + ShowIfObjectPermission, ) from api.nodes.serializers import CompoundIDField @@ -35,6 +36,10 @@ class InstitutionSerializer(JSONAPISerializer): ror_iri = ser.CharField(read_only=True, source='ror_uri') iris = ser.SerializerMethodField(read_only=True) assets = ser.SerializerMethodField(read_only=True) + link_to_external_reports_archive = ShowIfObjectPermission( + ser.CharField(read_only=True), + permission='view_institutional_metrics', + ) links = LinksField({ 'self': 'get_api_url', 'html': 'get_absolute_html_url', @@ -55,19 +60,28 @@ class InstitutionSerializer(JSONAPISerializer): related_view_kwargs={'institution_id': '<_id>'}, ) - department_metrics = RelationshipField( - related_view='institutions:institution-department-metrics', - related_view_kwargs={'institution_id': '<_id>'}, + department_metrics = ShowIfObjectPermission( + RelationshipField( + related_view='institutions:institution-department-metrics', + related_view_kwargs={'institution_id': '<_id>'}, + ), + permission='view_institutional_metrics', ) - user_metrics = RelationshipField( - related_view='institutions:institution-user-metrics', - related_view_kwargs={'institution_id': '<_id>'}, + user_metrics = ShowIfObjectPermission( + RelationshipField( + related_view='institutions:institution-user-metrics', + related_view_kwargs={'institution_id': '<_id>'}, + ), + permission='view_institutional_metrics', ) - summary_metrics = RelationshipField( - related_view='institutions:institution-summary-metrics', - related_view_kwargs={'institution_id': '<_id>'}, + summary_metrics = ShowIfObjectPermission( + RelationshipField( + related_view='institutions:institution-summary-metrics', + related_view_kwargs={'institution_id': '<_id>'}, + ), + permission='view_institutional_metrics', ) def get_api_url(self, obj): diff --git a/api_tests/institutions/views/test_institution_detail.py b/api_tests/institutions/views/test_institution_detail.py index e21e3a7087b..a8d81f7138f 100644 --- a/api_tests/institutions/views/test_institution_detail.py +++ b/api_tests/institutions/views/test_institution_detail.py @@ -1,6 +1,9 @@ import pytest -from osf_tests.factories import InstitutionFactory +from osf_tests.factories import ( + AuthUserFactory, + InstitutionFactory, +) from api.base.settings.defaults import API_BASE from django.core.validators import URLValidator @@ -11,6 +14,8 @@ class TestInstitutionDetail: 'nodes', 'registrations', 'users', + } + expected_metrics_relationships = { 'department_metrics', 'user_metrics', 'summary_metrics' @@ -26,34 +31,55 @@ def institution(self): def url(self, institution): return f'/{API_BASE}institutions/{institution._id}/' - def test_detail_response(self, app, institution, url): - - # 404 on wrong _id - res = app.get(f'/{institution}institutions/1PO/', expect_errors=True) - assert res.status_code == 404 - - res = app.get(url) - assert res.status_code == 200 - attrs = res.json['data']['attributes'] - assert attrs['name'] == institution.name - assert attrs['iri'] == institution.identifier_domain - assert attrs['ror_iri'] == institution.ror_uri - assert set(attrs['iris']) == { - institution.ror_uri, - institution.identifier_domain, - institution.absolute_url, - } - assert 'logo_path' in attrs - assert set(attrs['assets'].keys()) == {'logo', 'logo_rounded', 'banner'} - assert res.json['data']['links']['self'].endswith(url) - - relationships = res.json['data']['relationships'] - assert self.expected_relationships == set(relationships.keys()) - for relationships in list(relationships.values()): - # ↓ returns None if url is valid else throws error. - assert self.is_valid_url(relationships['links']['related']['href']) is None - - # test_return_without_logo_path - res = app.get(f'{url}?version=2.14') - assert res.status_code == 200 - assert 'logo_path' not in res.json['data']['attributes'] + @pytest.fixture() + def rando(self): + return AuthUserFactory() + + @pytest.fixture() + def institutional_admin(self, institution): + _admin_user = AuthUserFactory() + institution.get_group('institutional_admins').user_set.add(_admin_user) + return _admin_user + + def test_detail_response(self, app, institution, url, rando, institutional_admin): + + for _user in (None, rando, institutional_admin): + _auth = (None if _user is None else _user.auth) + # 404 on wrong _id + res = app.get(f'/{institution}institutions/1PO/', expect_errors=True, auth=_auth) + assert res.status_code == 404 + + res = app.get(url, auth=_auth) + assert res.status_code == 200 + attrs = res.json['data']['attributes'] + assert attrs['name'] == institution.name + assert attrs['iri'] == institution.identifier_domain + assert attrs['ror_iri'] == institution.ror_uri + assert set(attrs['iris']) == { + institution.ror_uri, + institution.identifier_domain, + institution.absolute_url, + } + assert 'logo_path' in attrs + assert set(attrs['assets'].keys()) == {'logo', 'logo_rounded', 'banner'} + if _user is institutional_admin: + assert attrs['link_to_external_reports_archive'] == institution.link_to_external_reports_archive + else: + assert 'link_to_external_reports_archive' not in attrs + assert res.json['data']['links']['self'].endswith(url) + + relationships = res.json['data']['relationships'] + _expected_relationships = ( + self.expected_relationships | self.expected_metrics_relationships + if _user is institutional_admin + else self.expected_relationships + ) + assert _expected_relationships == set(relationships.keys()) + for relationships in list(relationships.values()): + # ↓ returns None if url is valid else throws error. + assert self.is_valid_url(relationships['links']['related']['href']) is None + + # test_return_without_logo_path + res = app.get(f'{url}?version=2.14', auth=_auth) + assert res.status_code == 200 + assert 'logo_path' not in res.json['data']['attributes'] diff --git a/osf/migrations/0023_institution_link_to_external_reports_archive.py b/osf/migrations/0023_institution_link_to_external_reports_archive.py new file mode 100644 index 00000000000..2900f45e12d --- /dev/null +++ b/osf/migrations/0023_institution_link_to_external_reports_archive.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2024-08-16 15:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0022_alter_abstractnode_subjects_alter_abstractnode_tags_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='institution', + name='link_to_external_reports_archive', + field=models.URLField(blank=True, default='', help_text='Full URL where institutional admins can access archived metrics reports.', max_length=2048), + ), + ] diff --git a/osf/models/institution.py b/osf/models/institution.py index 0c3a9780ac2..d0ce38eacf4 100644 --- a/osf/models/institution.py +++ b/osf/models/institution.py @@ -118,6 +118,12 @@ class Institution(DirtyFieldsMixin, Loggable, ObjectIDMixin, BaseModel, Guardian blank=True, help_text='The full domain this institutions that will appear in DOI metadata.' ) + link_to_external_reports_archive = models.URLField( + max_length=2048, + blank=True, + default='', + help_text='Full URL where institutional admins can access archived metrics reports.', + ) class Meta: # custom permissions for use in the OSF Admin App