diff --git a/api/users/permissions.py b/api/users/permissions.py index a9186a4efd5..678c8b941ba 100644 --- a/api/users/permissions.py +++ b/api/users/permissions.py @@ -1,5 +1,7 @@ -from osf.models import OSFUser -from rest_framework import permissions +from rest_framework import permissions, exceptions + +from osf.models import OSFUser, Institution +from osf.models.user_message import MessageTypes class ReadOnlyOrCurrentUser(permissions.BasePermission): @@ -47,3 +49,63 @@ 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) -> bool: + """ + Validate if the user has permission to perform the requested action. + Args: + request: The HTTP request. + view: The view handling the request. + Returns: + bool: True if the user has the required permission, False otherwise. + """ + if request.method != 'POST': + return False + + user = request.user + if not user or user.is_anonymous: + return False + + message_type = request.data.get('message_type') + if message_type == MessageTypes.INSTITUTIONAL_REQUEST: + return self._validate_institutional_request(request, user) + + return False + + def _validate_institutional_request(self, request, user: OSFUser) -> bool: + """ + Validate the user's permissions for creating an `INSTITUTIONAL_REQUEST` message. + Args: + request: The HTTP request containing the institution ID. + user: The user making the request. + Returns: + bool: True if the user has the required permission. + """ + institution_id = request.data.get('institution') + if not institution_id: + raise exceptions.ValidationError({'institution': 'Institution ID is required.'}) + + institution = self._get_institution(institution_id) + + if not user.is_institutional_admin(institution): + raise exceptions.NotAuthenticated('You are not an admin of the specified institution.') + + return True + + def _get_institution(self, institution_id: str) -> Institution: + """ + Retrieve the institution by its ID. + Args: + institution_id (str): The ID of the institution. + Returns: + Institution: The retrieved institution. + """ + try: + return Institution.objects.get(_id=institution_id) + except Institution.DoesNotExist: + raise exceptions.ValidationError({'institution': 'Specified institution does not exist.'}) diff --git a/api/users/serializers.py b/api/users/serializers.py index 5e8ca59d9cf..a84e98b76bd 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -1,3 +1,5 @@ +from typing import Any, Dict + from django.utils import timezone from jsonschema import validate, Draft7Validator, ValidationError as JsonSchemaValidationError from rest_framework import exceptions @@ -19,16 +21,26 @@ 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 from website.settings import MAILCHIMP_GENERAL_LIST, OSF_HELP_LIST, CONFIRM_REGISTRATIONS_BY_EMAIL +from website.util import api_v2_url class SocialField(ser.DictField): @@ -657,3 +669,104 @@ def update(self, instance, validated_data): class UserNodeSerializer(NodeSerializer): filterable_fields = NodeSerializer.filterable_fields | {'current_user_permissions'} + + +class UserMessageSerializer(JSONAPISerializer): + """ + Serializer for creating and managing `UserMessage` instances. + + Attributes: + message_text (CharField): The text content of the message. + message_type (ChoiceField): The type of message being sent, restricted to `MessageTypes`. + institution (RelationshipField): The institution related to the message. Required. + user (RelationshipField): The recipient of the message. + """ + id = IDField(read_only=True, source='_id') + message_text = ser.CharField( + required=True, + help_text='The content of the message to be sent.', + ) + message_type = ser.ChoiceField( + choices=MessageTypes.choices, + required=True, + help_text='The type of message being sent. Must match one of the defined `MessageTypes`.', + ) + institution = RelationshipField( + related_view='institutions:institution-detail', + related_view_kwargs={'institution_id': ''}, + help_text='The institution associated with this message. This field is required.', + ) + user = RelationshipField( + related_view='users:user-detail', + related_view_kwargs={'user_id': ''}, + help_text='The recipient of the message.', + ) + + def get_absolute_url(self, obj: UserMessage) -> str: + return api_v2_url( + 'users:user-messages', + params={ + 'user_id': self.context['request'].parser_context['kwargs']['user_id'], + 'version': self.context['request'].parser_context['kwargs']['version'], + }, + ) + + def to_internal_value(self, data): + instituion_id = data.pop('institution') + data = super().to_internal_value(data) + data['institution'] = instituion_id + return data + + class Meta: + type_ = 'user-message' + + def create(self, validated_data: Dict[str, Any]) -> UserMessage: + """ + Creates a `UserMessage` instance based on validated data. + + Args: + validated_data (Dict[str, Any]): The data validated by the serializer. + + Raises: + ValidationError: If required validations fail (e.g., sender not an institutional admin, + or recipient not affiliated with the institution). + + Returns: + UserMessage: The created `UserMessage` instance. + """ + request = self.context['request'] + sender = request.user + + recipient = get_object_or_error( + OSFUser, + self.context['view'].kwargs['user_id'], + request, + 'user', + ) + + institution_id = validated_data.get('institution') + if not institution_id: + raise exceptions.ValidationError({'institution': 'Institution is required.'}) + + institution = get_object_or_error( + Institution, + institution_id, + request, + 'institution', + ) + + if not sender.is_institutional_admin(institution): + raise exceptions.ValidationError({'sender': 'Only institutional adminstraters can create messages.'}) + + if not recipient.is_affiliated_with_institution(institution): + raise exceptions.ValidationError( + {'user': 'Can not send to recipient that is not affiliated with the provided institution.'}, + ) + + 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 cf9bd0bb7b9..ef53094a121 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.UserMessageView.as_view(), name=views.UserMessageView.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 927b5dc2f9b..3ad59818680 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 @@ -55,6 +56,7 @@ UserAccountExportSerializer, ReadEmailUserDetailSerializer, UserChangePasswordSerializer, + UserMessageSerializer, ) from django.contrib.auth.models import AnonymousUser from django.http import JsonResponse @@ -957,3 +959,23 @@ def perform_destroy(self, instance): else: user.remove_unconfirmed_email(email) user.save() + + +class UserMessageView(JSONAPIBaseView, generics.CreateAPIView): + """ + 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 = (JSONAPIMultipleRelationshipsParser, JSONAPIMultipleRelationshipsParserForRegularJSON) + + serializer_class = UserMessageSerializer + + view_category = 'users' + view_name = 'user-messages' 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 00000000000..90142b76ae5 --- /dev/null +++ b/api_tests/users/views/test_user_message_institutional_access.py @@ -0,0 +1,137 @@ +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_with_affiliation(self, user_with_affiliation): + return f'/{API_BASE}users/{user_with_affiliation._id}/messages/' + + @pytest.fixture() + def url_without_affiliation(self, user): + return f'/{API_BASE}users/{user._id}/messages/' + + @pytest.fixture() + def payload(self, institution, user): + return { + 'data': { + 'attributes': { + 'message_text': 'Requesting user access for collaboration', + 'message_type': MessageTypes.INSTITUTIONAL_REQUEST.value, + }, + 'relationships': { + 'institution': { + 'data': {'id': institution._id, 'type': 'institutions'}, + }, + }, + '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_with_affiliation, payload): + """ + 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_with_affiliation, + payload, + auth=institutional_admin.auth + ) + assert res.status_code == 201 + data = res.json['data'] + + user_message = UserMessage.objects.get(sender=institutional_admin) + + assert user_message.message_text == payload['data']['attributes']['message_text'] + assert user_message.institution == institution + + mock_send_mail.assert_called_once() + assert mock_send_mail.call_args[1]['to_addr'] == user_message.recipient.username + assert 'Requesting user access for collaboration' in mock_send_mail.call_args[1]['message_text'] + assert user_message._id == data['id'] + + def test_unauthenticated_user_cannot_create_message(self, app, user, url_with_affiliation, payload): + """ + Ensure that unauthenticated users cannot create a `UserMessage`. + """ + res = app.post_json_api(url_with_affiliation, payload, 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_with_affiliation, payload): + """ + Ensure a non-institutional admin cannot create a `UserMessage`, even with valid data. + """ + res = app.post_json_api(url_with_affiliation, payload, auth=noncontrib.auth, expect_errors=True) + assert res.status_code == 401 + + def test_request_without_institution(self, app, institutional_admin, user, url_with_affiliation, payload): + """ + Test that a `UserMessage` can be created without specifying an institution, and `institution` is None. + """ + del payload['data']['relationships']['institution'] + + res = app.post_json_api(url_with_affiliation, payload, auth=institutional_admin.auth, expect_errors=True) + assert res.status_code == 400 + + def test_missing_message_fails(self, app, institutional_admin, user, url_with_affiliation, payload): + """ + Ensure a `UserMessage` cannot be created without a `message` attribute. + """ + del payload['data']['attributes']['message_text'] + + res = app.post_json_api(url_with_affiliation, payload, auth=institutional_admin.auth, expect_errors=True) + assert res.status_code == 400 + + def test_admin_cannot_message_user_outside_institution( + self, + app, + institutional_admin, + url_without_affiliation, + payload, + user + ): + """ + Ensure that an institutional admin cannot create a `UserMessage` for a user who is not affiliated with their institution. + """ + res = app.post_json_api(url_without_affiliation, payload, auth=institutional_admin.auth, expect_errors=True) + assert res.status_code == 400 + assert 'Can not send to recipient that is not affiliated with the provided 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 00000000000..d31f137c6c5 --- /dev/null +++ b/osf/migrations/0025_usermessage.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.13 on 2024-12-04 14:45 + +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(help_text='The content of the message. The custom text of a formatted email.')), + ('message_type', models.CharField(choices=[('institutional_request', 'INSTITUTIONAL_REQUEST')], help_text='The type of message being sent, as defined in MessageTypes.', max_length=50)), + ('institution', models.ForeignKey(help_text='The institution associated with this message.', on_delete=django.db.models.deletion.CASCADE, to='osf.institution')), + ('recipient', models.ForeignKey(help_text='The user who received this message.', on_delete=django.db.models.deletion.CASCADE, related_name='received_user_messages', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(help_text='The user who sent this message.', 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 cad31ea323f..4dbcb4d42ff 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 bb0f97f91a9..411381b0687 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -644,6 +644,10 @@ def osf_groups(self): OSFGroup = apps.get_model('osf.OSFGroup') return get_objects_for_user(self, 'member_group', OSFGroup, with_superuser=False) + def is_institutional_admin(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 00000000000..ee2af2f2f38 --- /dev/null +++ b/osf/models/user_message.py @@ -0,0 +1,111 @@ +from typing import Type +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.mails import send_mail, USER_MESSAGE_INSTITUTIONAL_ACCESS_REQUEST + + +class MessageTypes(models.TextChoices): + """ + Enumeration of the different user-to-user message types supported by UserMessage. + + Notes: + Message types should be limited to direct communication between two users. + These may include cases where the sender represents an organization or group, + but they must not involve bulk messaging or group-wide notifications. + """ + # Admin-to-member communication within an institution. + INSTITUTIONAL_REQUEST = ('institutional_request', 'INSTITUTIONAL_REQUEST') + + @classmethod + def get_template(cls: Type['MessageTypes'], message_type: str) -> str: + """ + Retrieve the email template associated with a specific message type. + + Args: + message_type (str): The type of the message. + + Returns: + str: The email template string for the specified message type. + """ + return { + cls.INSTITUTIONAL_REQUEST: USER_MESSAGE_INSTITUTIONAL_ACCESS_REQUEST + }[message_type] + + +class UserMessage(BaseModel, ObjectIDMixin): + """ + Represents a user-to-user message, potentially sent on behalf of an organization or group. + + Attributes: + sender (OSFUser): The user who initiated the message. + recipient (OSFUser): The intended recipient of the message. + message_text (str): The content of the message being sent. + message_type (str): The type of message, e.g., 'institutional_request'. + institution (Institution): The institution linked to the message, if applicable. + """ + sender = models.ForeignKey( + 'OSFUser', + on_delete=models.CASCADE, + related_name='sent_user_messages', + help_text='The user who sent this message.' + ) + recipient = models.ForeignKey( + 'OSFUser', + on_delete=models.CASCADE, + related_name='received_user_messages', + help_text='The user who received this message.' + ) + message_text = models.TextField( + help_text='The content of the message. The custom text of a formatted email.' + ) + message_type = models.CharField( + max_length=50, + choices=MessageTypes.choices, + help_text='The type of message being sent, as defined in MessageTypes.' + ) + institution = models.ForeignKey( + 'Institution', + on_delete=models.CASCADE, + help_text='The institution associated with this message.' + ) + + def send_institution_request(self) -> None: + """ + Sends an institutional access request email to the recipient of the message. + """ + send_mail( + to_addr=self.recipient.username, + mail=MessageTypes.get_template(MessageTypes.INSTITUTIONAL_REQUEST), + user=self.recipient, + **{ + 'sender': self.sender, + 'recipient': self.recipient, + 'message_text': self.message_text, + 'institution': self.institution, + }, + ) + + +@receiver(post_save, sender=UserMessage) +def user_message_created(sender: Type[UserMessage], instance: UserMessage, created: bool, **kwargs) -> None: + """ + Signal handler executed after a UserMessage instance is saved. + + Args: + sender (Type[UserMessage]): The UserMessage model class. + instance (UserMessage): The newly created instance of the UserMessage. + created (bool): Whether this is the first save of the instance. + + Notes: + If the message type is 'INSTITUTIONAL_REQUEST', it triggers sending an + institutional request email. Raises an error for unsupported message types. + """ + if not created: + return # Ignore subsequent saves. + + if instance.message_type == MessageTypes.INSTITUTIONAL_REQUEST: + instance.send_institution_request() + else: + raise NotImplementedError(f'Unsupported message type: {instance.message_type}') diff --git a/osf_tests/factories.py b/osf_tests/factories.py index 0bd1664977d..ca3d2dacce4 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 afca9e78f03..ef0b395729b 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/settings/defaults.py b/website/settings/defaults.py index 91e3c1bacc6..0467ef3c166 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -446,7 +446,6 @@ class CeleryConfig: 'osf.management.commands.daily_reporters_go', 'osf.management.commands.monthly_reporters_go', 'osf.management.commands.ingest_cedar_metadata_templates', - 'osf.metrics.reporters', } med_pri_modules = { 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 00000000000..c430938a11f --- /dev/null +++ b/website/templates/emails/user_message_institutional_access_request.html.mako @@ -0,0 +1,34 @@ +<%inherit file="notify_base.mako" /> + +<%def name="content()"> + + + <%!from website import settings%> + Hello ${recipient.fullname}, +

+ ${sender.fullname} from your ${institution.name}, has sent you a request regarding your project. +

+ % if message_text: +

+ Message from ${sender.fullname}:
+ ${message_text} +

+ % endif +

+ To review this request, please visit your project dashboard or contact your institution administrator for further details. +

+

+ Sincerely,
+ The OSF Team +

+

+ Want more information? Visit ${settings.DOMAIN} to learn about OSF, or + https://cos.io/ for information about its supporting organization, the Center + for Open Science. +

+

+ Questions? Email ${settings.OSF_CONTACT_EMAIL} +

+ + +