Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/base/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1456,6 +1456,11 @@ class JSONAPISerializer(BaseAPISerializer):
@classmethod
def many_init(cls, *args, **kwargs):
kwargs['child'] = cls(*args, **kwargs)
# Use DRF list_serializer_class if it exists, otherwise use default JSONAPIListSerializer
meta = getattr(cls, 'Meta', None)
list_cls = getattr(meta, 'list_serializer_class', None)
if list_cls:
return list_cls(*args, **kwargs)
return JSONAPIListSerializer(*args, **kwargs)

def invalid_embeds(self, fields, embeds):
Expand Down
93 changes: 88 additions & 5 deletions api/nodes/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
)
from api.base.serializers import (
VersionedDateTimeField, HideIfRegistration, IDField,
JSONAPISerializer, LinksField,
JSONAPISerializer, JSONAPIListSerializer, LinksField,
NodeFileHyperLinkField, RelationshipField,
ShowIfVersion, TargetTypeField, TypeField,
WaterbutlerLink, BaseAPISerializer,
Expand Down Expand Up @@ -38,7 +38,7 @@
Comment, DraftRegistration, ExternalAccount,
RegistrationSchema, AbstractNode, PrivateLink, Preprint,
RegistrationProvider, NodeLicense, DraftNode,
Registration, Node,
Registration, Node, OSFUser,
)
from website.project import new_private_link
from website.project.model import NodeUpdateError
Expand Down Expand Up @@ -1219,6 +1219,89 @@ def get_unregistered_contributor(self, obj):
return unclaimed_records.get('name', None)


class NodeContributorsBulkCreateListSerializer(JSONAPIListSerializer):

def create(self, validated_data):
request = self.context['request']
node = self.context['resource']
auth = Auth(request.user)

def _perm(item):
return osf_permissions.get_contributor_proposed_permissions(item)

send_email_default = request.GET.get('send_email') or self.context['default_email']
# Preload users once and pass through to the bulk method (also reused for children)
user_ids = {item.get('_id') for item in validated_data if item.get('_id')}
user_map = {}
if user_ids:
for u in OSFUser.objects.filter(guids___id__in=user_ids):
user_map[u._id] = u

payload = []
for item in validated_data:
uid = item.get('_id')
user = user_map.get(uid)
email = item.get('user', {}).get('email', None)
full_name = item.get('full_name') or (user.fullname if user and not user.is_registered else None)
if not uid and not full_name:
raise exceptions.ValidationError(detail='A user ID or full name must be provided to add a contributor.')
payload.append({
'user_id': uid,
'user': user,
'email': email,
'full_name': full_name,
'send_email': send_email_default,
'permissions': _perm(item),
'bibliographic': item.get('bibliographic'),
'index': item.get('_order') if '_order' in item else None,
})

try:
contribs = node.add_contributors_registered_or_not(payload, auth=auth, save=True)
except ValidationError as e:
raise exceptions.ValidationError(detail=e.messages[0])
except ValueError as e:
raise exceptions.NotFound(detail=e.args[0])

child_to_items = {}
for item in validated_data:
child_nodes = item.get('child_nodes')
if child_nodes:
for child_id in child_nodes:
child_to_items.setdefault(child_id, []).append(item)

for child_id, items in child_to_items.items():
child = AbstractNode.load(child_id)
if not child:
continue
child_payload = []
for item in items:
uid = item.get('_id')
user = user_map.get(uid)
email = item.get('user', {}).get('email', None)
full_name = item.get('full_name') or (user.fullname if user and not user.is_registered else None)
if not uid and not full_name:
raise exceptions.ValidationError(detail='A user ID or full name must be provided to add a contributor.')
child_payload.append({
'user_id': uid,
'user': user,
'email': email,
'full_name': full_name,
'send_email': send_email_default,
'permissions': _perm(item),
'bibliographic': item.get('bibliographic'),
'index': item.get('_order') if '_order' in item else None,
})
try:
child.add_contributors_registered_or_not(child_payload, auth=auth, save=True)
except ValidationError as e:
raise exceptions.ValidationError(detail=e.messages[0])
except ValueError as e:
raise exceptions.NotFound(detail=e.args[0])

return contribs


class NodeContributorsCreateSerializer(NodeContributorsSerializer):
"""
Overrides NodeContributorsSerializer to add email, full_name, send_email, and non-required index and users field.
Expand All @@ -1239,8 +1322,8 @@ class NodeContributorsCreateSerializer(NodeContributorsSerializer):

email_preferences = ['default', 'false']

def get_proposed_permissions(self, validated_data):
return validated_data.get('permission') or osf_permissions.DEFAULT_CONTRIBUTOR_PERMISSIONS
class Meta(NodeContributorsSerializer.Meta):
list_serializer_class = NodeContributorsBulkCreateListSerializer

def validate_data(self, node, user_id=None, full_name=None, email=None, index=None, child_nodes=None):
if not user_id and not full_name:
Expand All @@ -1264,7 +1347,7 @@ def create(self, validated_data):
bibliographic = validated_data.get('bibliographic')
send_email = self.context['request'].GET.get('send_email') or self.context['default_email']
child_nodes = validated_data.get('child_nodes')
permissions = self.get_proposed_permissions(validated_data)
permissions = osf_permissions.get_contributor_proposed_permissions(validated_data)

self.validate_data(node, user_id=id, full_name=full_name, email=email, index=index, child_nodes=child_nodes)

Expand Down
87 changes: 75 additions & 12 deletions osf/models/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -1432,7 +1432,7 @@ def add_contributor(self, contributor, permissions=None, visible=True,
# Add default contributor permissions
permissions = permissions or self.DEFAULT_CONTRIBUTOR_PERMISSIONS

self.add_permission(contrib_to_add, permissions, save=True)
self.add_permission(contrib_to_add, permissions, save=False)
if make_curator:
contributor_obj.is_curator = True
contributor_obj.save()
Expand Down Expand Up @@ -1482,11 +1482,13 @@ def add_contributors(self, contributors, auth=None, log=True, save=False):
:param log: Add log to self
:param save: Save after adding contributor
"""
users = []
for contrib in contributors:
self.add_contributor(
contributor=contrib['user'], permissions=contrib['permissions'],
visible=contrib['visible'], auth=auth, log=False, save=False,
)
users.append(contrib['user'])
if log and contributors:
params = self.log_params
params['contributors'] = [
Expand All @@ -1502,8 +1504,11 @@ def add_contributors(self, contributors, auth=None, log=True, save=False):
if save:
self.save()

return self.contributor_set.filter(user__in=users)

def add_unregistered_contributor(self, fullname, email, auth, send_email=None,
visible=True, permissions=None, save=False, existing_user=None):
visible=True, permissions=None, save=True, existing_user=None,
log=True):
"""Add a non-registered contributor to the project.

:param str fullname: The full name of the person.
Expand Down Expand Up @@ -1551,20 +1556,22 @@ def add_unregistered_contributor(self, fullname, email, auth, send_email=None,

self.add_contributor(
contributor, permissions=permissions, auth=auth,
visible=visible, send_email=send_email, log=True, save=False
visible=visible, send_email=send_email, log=log, save=False
)
self._add_related_source_tags(contributor)
self.save()
if save:
self.save()
return contributor

def add_contributor_registered_or_not(self, auth, user_id=None,
full_name=None, email=None, send_email=None,
permissions=None, bibliographic=True, index=None, save=False):
permissions=None, bibliographic=True, index=None, save=True,
user=None, log=True):
OSFUser = apps.get_model('osf.OSFUser')
send_email = send_email or self.contributor_email_template

if user_id:
contributor = OSFUser.load(user_id)
contributor = user or OSFUser.load(user_id)
if not contributor:
raise ValueError(f'User with id {user_id} was not found.')

Expand All @@ -1573,7 +1580,7 @@ def add_contributor_registered_or_not(self, auth, user_id=None,

if contributor.is_registered:
contributor = self.add_contributor(contributor=contributor, auth=auth, visible=bibliographic,
permissions=permissions, send_email=send_email, save=True)
permissions=permissions, send_email=send_email, save=save, log=log)
else:
if not full_name:
raise ValueError(
Expand All @@ -1583,7 +1590,7 @@ def add_contributor_registered_or_not(self, auth, user_id=None,
contributor = self.add_unregistered_contributor(
fullname=full_name, email=contributor.username, auth=auth,
send_email=send_email, permissions=permissions,
visible=bibliographic, existing_user=contributor, save=True
visible=bibliographic, existing_user=contributor, save=save, log=log
)

else:
Expand All @@ -1592,24 +1599,80 @@ def add_contributor_registered_or_not(self, auth, user_id=None,
raise ValidationValueError(f'{contributor.fullname} is already a contributor.')

if contributor and contributor.is_registered:
self.add_contributor(contributor=contributor, auth=auth, visible=bibliographic,
send_email=send_email, permissions=permissions, save=True)
self.add_contributor(
contributor=contributor,
auth=auth,
visible=bibliographic,
send_email=send_email,
permissions=permissions,
save=save,
log=log,
)
else:
contributor = self.add_unregistered_contributor(
fullname=full_name, email=email, auth=auth,
send_email=send_email, permissions=permissions,
visible=bibliographic, save=True
visible=bibliographic, save=save, log=log
)

auth.user.email_last_sent = timezone.now()
auth.user.save()

if index is not None:
self.move_contributor(contributor=contributor, index=index, auth=auth, save=True)
self.move_contributor(contributor=contributor, index=index, auth=auth, save=save)

contributor_obj = self.contributor_set.get(user=contributor)
return contributor_obj

def add_contributors_registered_or_not(self, contributors, auth=None, log=True, save=False):
"""Add multiple contributors using the unified registered-or-not path.

Each item should be a dictionary with keys compatible with
`add_contributor_registered_or_not`, e.g.:
{
'user_id': '<user guid>',
'user': '<OSFUser>' or None,
'email': '<email>' or None,
'full_name': '<full name>' or None,
'send_email': '<email preference>' or None,
'permissions': <permission string>,
'bibliographic': <bool>,
'index': <int or None>,
}
"""
results = []

for item in contributors:
contributor_obj = self.add_contributor_registered_or_not(
auth=auth,
user_id=item.get('user_id'),
user=item.get('user'),
full_name=item.get('full_name'),
email=item.get('email'),
send_email=item.get('send_email') or getattr(self, 'contributor_email_template', None),
permissions=item.get('permissions'),
bibliographic=item.get('bibliographic', True),
index=item.get('index'),
save=False,
log=False,
)
results.append(contributor_obj)

if log and results:
params = self.log_params
params['contributors'] = [c.user._id for c in results]
self.add_log(
action=self.log_class.CONTRIB_ADDED,
params=params,
auth=auth,
save=False,
)

if save:
self.save()

return results

def replace_contributor(self, old, new):
"""
Replacing unregistered contributor with a verified user
Expand Down
4 changes: 4 additions & 0 deletions osf/utils/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,7 @@ def check_private_key_for_anonymized_link(private_key):
except PrivateLink.DoesNotExist:
return False
return link.anonymous


def get_contributor_proposed_permissions(validated_data):
return validated_data.get('permission') or DEFAULT_CONTRIBUTOR_PERMISSIONS
Loading