Skip to content

Commit

Permalink
initial commit for UserMessage
Browse files Browse the repository at this point in the history
  • Loading branch information
John Tordoff committed Dec 2, 2024
1 parent 9dad00c commit c8029fc
Show file tree
Hide file tree
Showing 13 changed files with 557 additions and 3 deletions.
53 changes: 53 additions & 0 deletions api/base/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
69 changes: 68 additions & 1 deletion api/users/permissions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from osf.models import OSFUser
from rest_framework import permissions
from rest_framework.exceptions import ValidationError, NotAuthenticated

from osf.models import Institution, OSFUser
from osf.models.user_message import MessageTypes


class ReadOnlyOrCurrentUser(permissions.BasePermission):
Expand Down Expand Up @@ -47,3 +50,67 @@ 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.get('message_type')

if message_type == MessageTypes.INSTITUTIONAL_REQUEST:
return self._has_institutional_request_permission(request, user)

return False

def _has_institutional_request_permission(self, request, user):
"""
Check permissions for creating an INSTITUTIONAL_REQUEST message.
"""
institution_id = request.data.get('institution', {}).get('id')
recipient_id = request.data.get('user', {}).get('id')

if not institution_id or not recipient_id:
return False

institution = self._get_institution(institution_id)
recipient = self._get_recipient(recipient_id)

if not user.is_institutional_admin:
raise NotAuthenticated('You must be an institutional admin to perform this action.')

if not user.is_admin_of_institution(institution):
raise NotAuthenticated('You are not an admin of the specified institution.')

if not recipient.is_affiliated_with_institution(institution):
raise ValidationError({'user': 'User is not affiliated with your institution.'})

return True

def _get_institution(self, institution_id):
"""
Fetch the institution by ID, or raise a validation error if not found.
"""
try:
return Institution.objects.get(_id=institution_id)
except Institution.DoesNotExist:
raise ValidationError({'institution': 'Specified institution does not exist.'})

def _get_recipient(self, recipient_id):
"""
Fetch the recipient user by ID, or raise a validation error if not found.
"""
try:
return OSFUser.objects.get(guids___id=recipient_id)
except OSFUser.DoesNotExist:
raise ValidationError({'user': 'Specified user does not exist.'})
81 changes: 79 additions & 2 deletions api/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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': '<institution._id>'},
required=False,
allow_null=True,
)
user = RelationshipField(
related_view='users:user-detail',
related_view_kwargs={'user_id': '<recipient._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'],
)
1 change: 1 addition & 0 deletions api/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
re_path(r'^(?P<user_id>\w+)/draft_preprints/$', views.UserDraftPreprints.as_view(), name=views.UserDraftPreprints.view_name),
re_path(r'^(?P<user_id>\w+)/registrations/$', views.UserRegistrations.as_view(), name=views.UserRegistrations.view_name),
re_path(r'^(?P<user_id>\w+)/settings/$', views.UserSettings.as_view(), name=views.UserSettings.view_name),
re_path(r'^(?P<user_id>\w+)/messages/$', views.UserMessageList.as_view(), name=views.UserMessageList.view_name),
re_path(r'^(?P<user_id>\w+)/quickfiles/$', views.UserQuickFiles.as_view(), name=views.UserQuickFiles.view_name),
re_path(r'^(?P<user_id>\w+)/relationships/institutions/$', views.UserInstitutionsRelationship.as_view(), name=views.UserInstitutionsRelationship.view_name),
re_path(r'^(?P<user_id>\w+)/settings/emails/$', views.UserEmailsList.as_view(), name=views.UserEmailsList.view_name),
Expand Down
30 changes: 30 additions & 0 deletions api/users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,6 +16,7 @@
JSONAPIRelationshipParserForRegularJSON,
JSONAPIMultipleRelationshipsParser,
JSONAPIMultipleRelationshipsParserForRegularJSON,
RelationshipsAddingParser,
)
from api.base.serializers import get_meta_type, AddonAccountSerializer
from api.base.utils import (
Expand Down Expand Up @@ -55,6 +57,7 @@
UserAccountExportSerializer,
ReadEmailUserDetailSerializer,
UserChangePasswordSerializer,
UserMessageSerializer,
)
from django.contrib.auth.models import AnonymousUser
from django.http import JsonResponse
Expand Down Expand Up @@ -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()
Loading

0 comments on commit c8029fc

Please sign in to comment.