diff --git a/api_tests/nodes/views/test_node_contributor_insti_admin.py b/api_tests/nodes/views/test_node_contributor_insti_admin.py new file mode 100644 index 00000000000..0c55ae1023f --- /dev/null +++ b/api_tests/nodes/views/test_node_contributor_insti_admin.py @@ -0,0 +1,61 @@ +import pytest +from osf.models import Contributor, NodeLog +from osf_tests.factories import ( + AuthUserFactory, + ProjectFactory, + InstitutionFactory, +) +from api.base.settings.defaults import API_BASE +from rest_framework import status +from tests.utils import assert_latest_log + + +@pytest.mark.django_db +class TestChangeInstitutionalAdminContributor: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + @pytest.fixture() + def institutional_admin(self, institution): + admin_user = AuthUserFactory() + institution.get_group('institutional_admins').user_set.add(admin_user) + return admin_user + + @pytest.fixture() + def project(self, user, institutional_admin): + project = ProjectFactory(creator=user) + project.add_contributor(institutional_admin, visible=False) + return project + + @pytest.fixture() + def url_contrib(self, project, user): + return f'/{API_BASE}nodes/{project._id}/contributors/{user._id}/' + + def test_cannot_set_institutional_admin_contributor_bibliographic(self, app, user, project, institutional_admin, url_contrib): + res = app.put_json_api( + url_contrib, + { + 'data': { + 'id': f'{project._id}-{institutional_admin._id}', + 'type': 'contributors', + 'attributes': { + 'bibliographic': True, + } + } + }, + auth=user.auth, + expect_errors=True + ) + assert res.status_code == 409 + project.reload() + contributor = Contributor.objects.get( + node=project, + user=institutional_admin + ) + assert not contributor.visible diff --git a/osf/migrations/0025_contributor_institutional_admin_and_more.py b/osf/migrations/0025_contributor_institutional_admin_and_more.py new file mode 100644 index 00000000000..72648343b18 --- /dev/null +++ b/osf/migrations/0025_contributor_institutional_admin_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.13 on 2024-12-04 23:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0024_institution_link_to_external_reports_archive'), + ] + + operations = [ + migrations.AddField( + model_name='contributor', + name='institutional_admin', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='osf.institution'), + ), + migrations.AddConstraint( + model_name='contributor', + constraint=models.CheckConstraint(check=models.Q(('visible', True), ('institutional_admin__isnull', False), _negated=True), name='no_visible_with_institutional_admin'), + ), + ] diff --git a/osf/models/contributor.py b/osf/models/contributor.py index 4c5807f3ee2..df1f4c8b55b 100644 --- a/osf/models/contributor.py +++ b/osf/models/contributor.py @@ -30,6 +30,12 @@ def permission(self): class Contributor(AbstractBaseContributor): node = models.ForeignKey('AbstractNode', on_delete=models.CASCADE) + institutional_admin = models.ForeignKey( + 'Institution', + on_delete=models.CASCADE, + null=True, + blank=True + ) @property def _id(self): @@ -40,6 +46,12 @@ class Meta: # Make contributors orderable # NOTE: Adds an _order column order_with_respect_to = 'node' + constraints = [ + models.CheckConstraint( + check=~(models.Q(visible=True) & models.Q(institutional_admin__isnull=False)), + name='no_visible_with_institutional_admin', + ) + ] class PreprintContributor(AbstractBaseContributor): diff --git a/osf_tests/test_institutional_admin_contributors.py b/osf_tests/test_institutional_admin_contributors.py new file mode 100644 index 00000000000..e218f6a309a --- /dev/null +++ b/osf_tests/test_institutional_admin_contributors.py @@ -0,0 +1,75 @@ +import pytest +from django.core.exceptions import ValidationError +from osf.models import Contributor, Institution +from osf_tests.factories import AuthUserFactory, ProjectFactory, InstitutionFactory +from django.db.utils import IntegrityError + +@pytest.mark.django_db +class TestContributorModel: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def project(self, user): + return ProjectFactory(creator=user) + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + def test_contributor_with_visible_and_institutional_admin_raises_error(self, user, project, institution): + contributor = Contributor( + user=user, + node=project, + visible=True, + institutional_admin=institution + ) + with pytest.raises(IntegrityError, match='new row for relation "osf_contributor" violates check constraint "no_visible_with_institutional_admin"'): + contributor.save() # Use clean() for validation logic + + def test_contributor_with_visible_but_no_institutional_admin(self, user, project): + # Ensure no duplicate contributor exists + Contributor.objects.filter(user=user, node=project).delete() + + contributor = Contributor( + user=user, + node=project, + visible=True, + institutional_admin=None + ) + # This should not raise an error + contributor.save() + + def test_contributor_with_institutional_admin_but_not_visible(self, user, project, institution): + # Ensure no duplicate contributor exists + Contributor.objects.filter(user=user, node=project).delete() + + contributor = Contributor( + user=user, + node=project, + visible=False, + institutional_admin=institution + ) + # This should not raise an error + contributor.save() + + def test_database_constraint_no_visible_with_institutional_admin(self, user, project, institution): + # Ensure no duplicate contributor exists + Contributor.objects.filter(user=user, node=project).delete() + + Contributor.objects.create( + user=user, + node=project, + visible=False, + institutional_admin=institution + ) # Should succeed + + with pytest.raises(Exception): # Check database constraint + Contributor.objects.create( + user=user, + node=project, + visible=True, + institutional_admin=institution + ) diff --git a/website/templates/project/contributors.mako b/website/templates/project/contributors.mako index 65954d3aced..368afc19545 100644 --- a/website/templates/project/contributors.mako +++ b/website/templates/project/contributors.mako @@ -196,6 +196,16 @@ data-html="true" > + + Curator + + @@ -315,6 +325,7 @@ /> + Curator