diff --git a/api/base/parsers.py b/api/base/parsers.py index 1536bbd1ff42..1784b4eb0b55 100644 --- a/api/base/parsers.py +++ b/api/base/parsers.py @@ -356,3 +356,56 @@ def parse(self, stream, media_type=None, parser_context=None): else: res['query']['bool']['filter'].append({'term': {key: val}}) return res + + +class RelationshipsAddingParser(JSONParser): + """ + Parses JSON-serialized data, flattens relationships, and adds them to the serializer's data. + """ + media_type = 'application/vnd.api+json' + + def parse_relationships(self, relationships): + """ + Parses and flattens the relationships field from the JSON API payload. + """ + if not isinstance(relationships, dict): + raise JSONAPIException(source={'pointer': '/data/relationships'}, detail=NO_RELATIONSHIPS_ERROR) + + flattened = {} + for key, value in relationships.items(): + data = value.get('data') + if isinstance(data, list): + flattened[key] = [{'id': item['id'], 'type': item['type']} for item in data] + elif isinstance(data, dict): + flattened[key] = {'id': data['id'], 'type': data['type']} + else: + raise JSONAPIException(source={'pointer': f'/data/relationships/{key}/data'}, detail=NO_DATA_ERROR) + return flattened + + def parse(self, stream, media_type=None, parser_context=None): + """ + Parses the incoming bytestream as JSON and processes relationships. + """ + parsed_data = super().parse(stream, media_type=media_type, parser_context=parser_context) + + if not isinstance(parsed_data, dict): + raise ParseError(detail='Expected a dictionary of items.') + data = parsed_data.get('data') + + if not data: + raise JSONAPIException(source={'pointer': '/data'}, detail=NO_DATA_ERROR) + + attributes = data.get('attributes', {}) + relationships = data.get('relationships', {}) + + # Flatten relationships + if relationships: + flattened_relationships = self.parse_relationships(relationships) + attributes.update(flattened_relationships) + + # Add type and id if available + attributes['type'] = data.get('type') + if 'id' in data: + attributes['id'] = data['id'] + + return attributes diff --git a/api/users/permissions.py b/api/users/permissions.py index a9186a4efd5c..6a324c26f63c 100644 --- a/api/users/permissions.py +++ b/api/users/permissions.py @@ -1,5 +1,9 @@ from osf.models import OSFUser +from osf.models.user_message import MessageTypes from rest_framework import permissions +from django.apps import apps +from osf.models import Institution, OSFUser +from rest_framework.exceptions import ValidationError, NotAuthenticated class ReadOnlyOrCurrentUser(permissions.BasePermission): @@ -47,3 +51,54 @@ def has_permission(self, request, view): def has_object_permission(self, request, view, obj): assert isinstance(obj, OSFUser), f'obj must be a User, got {obj}' return not obj.is_registered + + +class UserMessagePermissions(permissions.BasePermission): + """ + Custom permission to allow only institutional admins to create certain types of UserMessages. + """ + + def has_permission(self, request, view): + user = request.user + + if not user or user.is_anonymous: + return False + + if request.method != 'POST': + return False + + message_type = request.data['message_type'] + + if message_type == MessageTypes.INSTITUTIONAL_REQUEST: + return self._has_institutional_request_permission(request, user) + else: + return False + + def _has_institutional_request_permission(self, request, user): + """ + Check permissions for creating an INSTITUTIONAL_REQUEST message. + """ + try: + institution = Institution.objects.get(_id=request.data['institution']['id']) + except Institution.DoesNotExist: + return False + + print(request.data) + try: + recipient = OSFUser.objects.get(guids___id=request.data['user']['id']) + except OSFUser.DoesNotExist: + return False + + if not user.is_institutional_admin: + raise NotAuthenticated(detail='You must be an institutional admin to perform this action.') + + if institution: + # Check that sender is an admin of the specified institution + if not user.is_admin_of_institution(institution): + raise NotAuthenticated(detail='You are not an admin of the specified institution.') + + # Check that recipient is affiliated with the institution + if not recipient.is_affiliated_with_institution(institution): + raise ValidationError({'user': 'User is not affiliated with your institution.'}) + + return True diff --git a/api/users/serializers.py b/api/users/serializers.py index 5e8ca59d9cf2..727467d2b9b8 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -19,12 +19,21 @@ JSONAPIListField, ShowIfCurrentUser, ) -from api.base.utils import absolute_reverse, default_node_list_queryset, get_user_auth, is_deprecated, hashids +from api.base.utils import ( + absolute_reverse, + default_node_list_queryset, + get_user_auth, + is_deprecated, + hashids, + get_object_or_error, +) + from api.base.versioning import get_kebab_snake_case_field from api.nodes.serializers import NodeSerializer, RegionRelationshipField from framework.auth.views import send_confirm_email_async from osf.exceptions import ValidationValueError, ValidationError, BlockedEmailError -from osf.models import Email, Node, OSFUser, Preprint, Registration +from osf.models import Email, Node, OSFUser, Preprint, Registration, UserMessage, Institution +from osf.models.user_message import MessageTypes from osf.models.provider import AbstractProviderGroupObjectPermission from osf.utils.requests import string_type_request_headers from website.profile.views import update_osf_help_mails_subscription, update_mailchimp_subscription @@ -657,3 +666,71 @@ def update(self, instance, validated_data): class UserNodeSerializer(NodeSerializer): filterable_fields = NodeSerializer.filterable_fields | {'current_user_permissions'} + + +class UserMessageSerializer(JSONAPISerializer): + message_text = ser.CharField(required=True) + message_type = ser.ChoiceField( + choices=MessageTypes.choices, + required=True, + ) + institution = RelationshipField( + related_view='institutions:institution-detail', + related_view_kwargs={'institution_id': ''}, + required=False, + allow_null=True, + ) + user = RelationshipField( + related_view='users:user-detail', + related_view_kwargs={'user_id': ''}, + ) + + def get_absolute_url(self, obj): + return absolute_reverse( + 'users:user-messages', + kwargs={ + 'user_id': self.context['request'].parser_context['kwargs']['user_id'], + 'version': self.context['request'].parser_context['kwargs']['version'], + }, + ) + + class Meta: + type_ = 'user-message' + + def create(self, validated_data): + request = self.context['request'] + sender = request.user + + recipient_id = request.data.get('user', {}).get('id') + if not recipient_id: + raise ValidationError({'user': 'User ID is required.'}) + + # Get the recipient object + recipient = get_object_or_error(OSFUser, recipient_id, request, 'user') + + institution_id = request.data.get('institution', {}).get('id') + if not institution_id: + raise ValidationError({'user': 'Institution ID is required.'}) + + institution = get_object_or_error(Institution, institution_id, request, 'institution') + + # Include recipient and institution in validated_data + validated_data['recipient'] = recipient + validated_data['institution'] = institution + + # Ensure the sender is an institutional admin + if not sender.is_institutional_admin: + raise ValidationError({'sender': 'Only institutional admins can create messages.'}) + + # Verify the recipient is affiliated with the institution (if provided) + if institution and not recipient.is_affiliated_with_institution(institution): + raise ValidationError({'user': 'User is not affiliated with your institution.'}) + + print(validated_data) + return UserMessage.objects.create( + sender=sender, + recipient=recipient, + institution=institution, + message_type=MessageTypes.INSTITUTIONAL_REQUEST, + message_text=validated_data['message_text'], + ) diff --git a/api/users/urls.py b/api/users/urls.py index cf9bd0bb7b91..de205f494631 100644 --- a/api/users/urls.py +++ b/api/users/urls.py @@ -19,6 +19,7 @@ re_path(r'^(?P\w+)/draft_preprints/$', views.UserDraftPreprints.as_view(), name=views.UserDraftPreprints.view_name), re_path(r'^(?P\w+)/registrations/$', views.UserRegistrations.as_view(), name=views.UserRegistrations.view_name), re_path(r'^(?P\w+)/settings/$', views.UserSettings.as_view(), name=views.UserSettings.view_name), + re_path(r'^(?P\w+)/messages/$', views.UserMessageList.as_view(), name=views.UserMessageList.view_name), re_path(r'^(?P\w+)/quickfiles/$', views.UserQuickFiles.as_view(), name=views.UserQuickFiles.view_name), re_path(r'^(?P\w+)/relationships/institutions/$', views.UserInstitutionsRelationship.as_view(), name=views.UserInstitutionsRelationship.view_name), re_path(r'^(?P\w+)/settings/emails/$', views.UserEmailsList.as_view(), name=views.UserEmailsList.view_name), diff --git a/api/users/views.py b/api/users/views.py index 927b5dc2f9b7..0d7e0290e72f 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -7,6 +7,7 @@ from api.addons.views import AddonSettingsMixin from api.base import permissions as base_permissions +from api.users.permissions import UserMessagePermissions from api.base.waffle_decorators import require_flag from api.base.exceptions import Conflict, UserGone, Gone from api.base.filters import ListFilterMixin, PreprintFilterMixin @@ -15,6 +16,7 @@ JSONAPIRelationshipParserForRegularJSON, JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON, + RelationshipsAddingParser, ) from api.base.serializers import get_meta_type, AddonAccountSerializer from api.base.utils import ( @@ -55,6 +57,7 @@ UserAccountExportSerializer, ReadEmailUserDetailSerializer, UserChangePasswordSerializer, + UserMessageSerializer, ) from django.contrib.auth.models import AnonymousUser from django.http import JsonResponse @@ -957,3 +960,30 @@ def perform_destroy(self, instance): else: user.remove_unconfirmed_email(email) user.save() + + +class UserMessageList(JSONAPIBaseView, generics.CreateAPIView, UserMixin): + """ + List and create UserMessages for a user. + """ + permission_classes = ( + drf_permissions.IsAuthenticated, + base_permissions.TokenHasScope, + UserMessagePermissions, + ) + + required_read_scopes = [CoreScopes.USERS_READ] + required_write_scopes = [CoreScopes.USERS_WRITE] + parser_classes = (RelationshipsAddingParser,) + + serializer_class = UserMessageSerializer + + view_category = 'users' + view_name = 'user-messages' + + def perform_create(self, serializer): + sender = self.get_user() + if self.request.user != sender: + raise NotAuthenticated(detail='Cannot create messages for other users.') + + serializer.save() diff --git a/api_tests/users/views/test_user_message_institutional_access.py b/api_tests/users/views/test_user_message_institutional_access.py new file mode 100644 index 000000000000..48f069f13fd9 --- /dev/null +++ b/api_tests/users/views/test_user_message_institutional_access.py @@ -0,0 +1,171 @@ +from unittest import mock +import pytest +from osf.models.user_message import MessageTypes, UserMessage +from api.base.settings.defaults import API_BASE +from osf_tests.factories import ( + AuthUserFactory, + InstitutionFactory +) + +@pytest.mark.django_db +class TestUserMessageInstitutionalAccess: + """ + Tests for `UserMessage`. + """ + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def noncontrib(self): + return AuthUserFactory() + + @pytest.fixture() + def user_with_affiliation(self, institution): + user = AuthUserFactory() + user.add_or_update_affiliated_institution(institution) + return user + + @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 url(self, institutional_admin): + return f'/{API_BASE}users/{institutional_admin._id}/messages/' + + @pytest.fixture() + def create_payload_user_outside_affiliation(self, institution, user): + return { + 'data': { + 'attributes': { + 'message_text': 'Requesting user access for collaboration', + 'message_type': MessageTypes.INSTITUTIONAL_REQUEST, + }, + 'relationships': { + 'institution': { + 'data': {'id': institution._id, 'type': 'institutions'}, + }, + 'user': { + 'data': {'id': user._id, 'type': 'users'}, + } + }, + 'type': 'user-message' + } + } + + @pytest.fixture() + def create_payload_user_affiliated(self, institution, user_with_affiliation): + return { + 'data': { + 'attributes': { + 'message_text': 'Requesting user access for collaboration', + 'message_type': MessageTypes.INSTITUTIONAL_REQUEST, + }, + 'relationships': { + 'institution': { + 'data': {'id': institution._id, 'type': 'institutions'}, + }, + 'user': { + 'data': {'id': user_with_affiliation._id, 'type': 'users'}, + } + }, + 'type': 'user-message' + } + } + + @mock.patch('osf.models.user_message.send_mail') + def test_institutional_admin_can_create_message(self, mock_send_mail, app, institutional_admin, institution, url, create_payload_user_affiliated): + """ + Ensure an institutional admin can create a `UserMessage` with a `message` and `institution`. + """ + mock_send_mail.return_value = mock.MagicMock() + + res = app.post_json_api(url, create_payload_user_affiliated, auth=institutional_admin.auth) + assert res.status_code == 201 + + user_message = UserMessage.objects.get(sender=institutional_admin) + assert user_message.message_text == create_payload_user_affiliated['data']['attributes']['message_text'] + assert user_message.institution == institution + + # Verify send_mail was called with the expected arguments + mock_send_mail.assert_called_once() + assert mock_send_mail.call_args[1]['to_addr'] == user_message.recipient.username + print(mock_send_mail.call_args[1]) + assert 'Requesting user access for collaboration' in mock_send_mail.call_args[1]['message'] + + def test_unauthenticated_user_cannot_create_message(self, app, user, url, create_payload_user_affiliated): + """ + Ensure that unauthenticated users cannot create a `UserMessage`. + """ + res = app.post_json_api(url, create_payload_user_affiliated, expect_errors=True) + assert res.status_code == 401 + assert 'Authentication credentials were not provided' in res.json['errors'][0]['detail'] + + def test_non_institutional_admin_cannot_create_message(self, app, noncontrib, user, url, create_payload_user_outside_affiliation): + """ + Ensure a non-institutional admin cannot create a `UserMessage`, even with valid data. + """ + res = app.post_json_api(url, create_payload_user_outside_affiliation, auth=noncontrib.auth, expect_errors=True) + assert res.status_code == 401 + + def test_request_without_institution(self, app, institutional_admin, user, url, create_payload_user_affiliated): + """ + Test that a `UserMessage` can be created without specifying an institution, and `institution` is None. + """ + del create_payload_user_affiliated['data']['relationships']['institution'] + + res = app.post_json_api(url, create_payload_user_affiliated, auth=institutional_admin.auth, expect_errors=True) + assert res.status_code == 403 + + def test_missing_message_fails(self, app, institutional_admin, user, url, create_payload_user_affiliated): + """ + Ensure a `UserMessage` cannot be created without a `message` attribute. + """ + del create_payload_user_affiliated['data']['attributes']['message_text'] + + res = app.post_json_api(url, create_payload_user_affiliated, auth=institutional_admin.auth, expect_errors=True) + assert res.status_code == 400 + + def test_request_with_invalid_user_relationship(self, app, institutional_admin, user, url, create_payload_user_affiliated): + """ + Ensure that invalid or missing `user` relationships are rejected. + """ + create_payload_user_affiliated['data']['relationships']['user']['data']['id'] = 'invalid_user_id' + + res = app.post_json_api(url, create_payload_user_affiliated, auth=institutional_admin.auth, expect_errors=True) + assert res.status_code == 403 + + def test_request_with_no_user_relationship(self, app, institutional_admin, user, url, create_payload_user_affiliated): + + del create_payload_user_affiliated['data']['relationships']['user'] + + res = app.post_json_api(url, create_payload_user_affiliated, auth=institutional_admin.auth, expect_errors=True) + assert res.status_code == 403 + + def test_admin_cannot_message_user_outside_institution( + self, + app, + institutional_admin, + url, + create_payload_user_affiliated, + user + ): + """ + Ensure that an institutional admin cannot create a `UserMessage` for a user who is not affiliated with their institution. + """ + # Modify the payload to target an external user + create_payload_user_affiliated['data']['relationships']['user'] = { + 'data': {'id': user._id, 'type': 'users'} + } + + res = app.post_json_api(url, create_payload_user_affiliated, auth=institutional_admin.auth, expect_errors=True) + assert res.status_code == 400 + assert 'User is not affiliated with your institution' in res.json['errors'][0]['detail'] diff --git a/osf/migrations/0025_usermessage.py b/osf/migrations/0025_usermessage.py new file mode 100644 index 000000000000..0b10e70eee6e --- /dev/null +++ b/osf/migrations/0025_usermessage.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.13 on 2024-12-02 18:03 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import osf.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0024_institution_link_to_external_reports_archive'), + ] + + operations = [ + migrations.CreateModel( + name='UserMessage', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('_id', models.CharField(db_index=True, default=osf.models.base.generate_object_id, max_length=24, unique=True)), + ('message_text', models.TextField(blank=True, null=True)), + ('message_type', models.CharField(choices=[('INSTITUTIONAL_REQUEST', 'institutional_request')], max_length=50)), + ('institution', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='osf.institution')), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_user_messages', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_user_messages', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + ] diff --git a/osf/models/__init__.py b/osf/models/__init__.py index cad31ea323f2..4dbcb4d42ffa 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -108,3 +108,5 @@ Email, OSFUser, ) +from .user_message import UserMessage + diff --git a/osf/models/user.py b/osf/models/user.py index bb0f97f91a9b..720470ab2607 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -644,6 +644,20 @@ def osf_groups(self): OSFGroup = apps.get_model('osf.OSFGroup') return get_objects_for_user(self, 'member_group', OSFGroup, with_superuser=False) + @property + def is_institutional_admin(self): + """ + Check if the user is an institutional admin. + + :param institution: Optional Institution object to check admin status for a specific institution. + :return: True if the user is an institutional admin. + """ + return self.groups.filter(name__startswith='institution_', name__endswith='_institutional_admins').exists() + + def is_admin_of_institution(self, institution): + group_name = institution.format_group('institutional_admins') + return self.groups.filter(name=group_name).exists() + def group_role(self, group): """ For the given OSFGroup, return the user's role - either member or manager diff --git a/osf/models/user_message.py b/osf/models/user_message.py new file mode 100644 index 000000000000..157dd214e7a4 --- /dev/null +++ b/osf/models/user_message.py @@ -0,0 +1,81 @@ +from django.db import models +from django.db.models.signals import post_save +from django.dispatch import receiver +from .base import BaseModel, ObjectIDMixin +from website import settings +from website.mails import send_mail, USER_MESSAGE_INSTITUTIONAL_ACCESS_REQUEST + + +class MessageTypes(models.TextChoices): + """ + These are the different types of message we send over UserMessages Models, a good thing to remember when adding + new message types is that they should incorporate only user-to-user communication, one single user contacting + another single user, sometimes on behalf of a group of users like an institution, but never messaging in bulk a + large group, or any group. + """ + + # This `INSTITUTIONAL_REQUEST` for institutional admins to contact members of their institutions only. There's + # another type of `INSTITUTIONAL_REQUEST` for specific nodes, but this is just for general communication between + # admin and institution member. + INSTITUTIONAL_REQUEST = ('INSTITUTIONAL_REQUEST', 'institutional_request') + + @classmethod + def template_from_message_type(cls, message_type): + return { + MessageTypes.INSTITUTIONAL_REQUEST: USER_MESSAGE_INSTITUTIONAL_ACCESS_REQUEST + }[message_type] + + +class UserMessage(BaseModel, ObjectIDMixin): + sender = models.ForeignKey( + 'OSFUser', + on_delete=models.CASCADE, + related_name='sent_user_messages' + ) + recipient = models.ForeignKey( + 'OSFUser', + on_delete=models.CASCADE, + related_name='received_user_messages' + ) + message_text = models.TextField(null=True, blank=True) + # No default message_type ensures no accidental messages + message_type = models.CharField( + max_length=50, + choices=MessageTypes.choices, + ) + institution = models.ForeignKey( + 'Institution', + on_delete=models.CASCADE, + null=True, + blank=True + ) + + def send_institution_request(self): + """ + Send or resend the message using the website's send_mail utility. + """ + print('send?') + send_mail( + to_addr=self.recipient.username, + mail=MessageTypes.template_from_message_type(MessageTypes.INSTITUTIONAL_REQUEST), + user=self.recipient, + **{ + 'sender': self.sender, + 'recipient': self.recipient, + 'message': self.message_text, + 'institution': self.institution, + 'osf_url': settings.DOMAIN, + }, + ) + + +@receiver(post_save, sender=UserMessage) +def user_message_created(sender, instance, created, **kwargs): + """ + Handle actions after a UserMessage is created. + """ + if not created: # if there's a second save necessary ignore it. + return + + if instance.message_type == MessageTypes.INSTITUTIONAL_REQUEST: + instance.send_institution_request() diff --git a/osf_tests/factories.py b/osf_tests/factories.py index 0bd1664977d7..ca3d2dacce43 100644 --- a/osf_tests/factories.py +++ b/osf_tests/factories.py @@ -1021,6 +1021,13 @@ class Meta: comment = factory.Faker('text') + +class UserMessageFactory(DjangoModelFactory): + class Meta: + model = models.UserMessage + + comment = factory.Faker('text') + osfstorage_settings = apps.get_app_config('addons_osfstorage') diff --git a/website/mails/mails.py b/website/mails/mails.py index afca9e78f03a..ef0b395729ba 100644 --- a/website/mails/mails.py +++ b/website/mails/mails.py @@ -595,3 +595,8 @@ def get_english_article(word): 'addons_boa_job_failure', subject='Your Boa job has failed' ) + +USER_MESSAGE_INSTITUTIONAL_ACCESS_REQUEST = Mail( + 'user_message_institutional_access_request', + subject='Institutional Access Request' +) diff --git a/website/templates/emails/user_message_institutional_access_request.html.mako b/website/templates/emails/user_message_institutional_access_request.html.mako new file mode 100644 index 000000000000..031513cdddf1 --- /dev/null +++ b/website/templates/emails/user_message_institutional_access_request.html.mako @@ -0,0 +1,11 @@ + + + TEST PLACEHOLDER +

Dear ${recipient.fullname},

+

You have received a new message from ${sender.fullname} at ${institution.name}:

+
+ ${message} +
+

Best regards,
${institution.name}

+ + \ No newline at end of file