Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cb4d4ad
added edit contributors button and basic table
ihorsokhanexoft Aug 26, 2025
bf991d8
added edit pop-up data display
ihorsokhanexoft Aug 29, 2025
192ad4b
moved remove functionality to a new edit contributors pop-up
ihorsokhanexoft Aug 29, 2025
1e153f7
added contributors removal and permissions update
ihorsokhanexoft Sep 1, 2025
d3165bc
added an ability to add a new contributor + html improvements
ihorsokhanexoft Sep 3, 2025
4e504f8
removed redundant code
ihorsokhanexoft Sep 3, 2025
f6d96eb
fixed permissions
ihorsokhanexoft Sep 3, 2025
0c1f875
fixed test
ihorsokhanexoft Sep 3, 2025
1aae477
added an ability to add multiple contributors + handle some edge cases
ihorsokhanexoft Sep 4, 2025
2f8ae09
removed redundant conversion
ihorsokhanexoft Sep 5, 2025
9d71fae
Merge branch 'feature/pbs-25-21' into feature/ENG-8516
ihorsokhanexoft Oct 27, 2025
4277e44
fix test (#11394)
adlius Oct 27, 2025
1884a03
added edit contributors button and basic table
ihorsokhanexoft Aug 26, 2025
f830d58
added edit pop-up data display
ihorsokhanexoft Aug 29, 2025
a79e7e4
moved remove functionality to a new edit contributors pop-up
ihorsokhanexoft Aug 29, 2025
1a299e0
added contributors removal and permissions update
ihorsokhanexoft Sep 1, 2025
875e292
added an ability to add a new contributor + html improvements
ihorsokhanexoft Sep 3, 2025
73408ea
removed redundant code
ihorsokhanexoft Sep 3, 2025
e45da24
fixed permissions
ihorsokhanexoft Sep 3, 2025
654b489
fixed test
ihorsokhanexoft Sep 3, 2025
01f750f
added an ability to add multiple contributors + handle some edge cases
ihorsokhanexoft Sep 4, 2025
ae34312
removed redundant conversion
ihorsokhanexoft Sep 5, 2025
be5ec28
Merge branch 'feature/ENG-8516' of github.com:ihorsokhanexoft/osf.io …
ihorsokhanexoft Oct 28, 2025
889bb1d
flake8 fix
ihorsokhanexoft Oct 28, 2025
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
1 change: 1 addition & 0 deletions admin/nodes/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,5 @@
re_path(r'^(?P<guid>[a-z0-9]+)/update_moderation_state/$', views.NodeUpdateModerationStateView.as_view(), name='node-update-mod-state'),
re_path(r'^(?P<guid>[a-z0-9]+)/resync_datacite/$', views.NodeResyncDataCiteView.as_view(), name='resync-datacite'),
re_path(r'^(?P<guid>[a-z0-9]+)/revert/$', views.NodeRevertToDraft.as_view(), name='revert-to-draft'),
re_path(r'^(?P<guid>[a-z0-9]+)/update_permissions/$', views.NodeUpdatePermissionsView.as_view(), name='update-permissions'),
]
76 changes: 75 additions & 1 deletion admin/nodes/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
REINDEX_SHARE,
REINDEX_ELASTIC,
)
from osf.utils.permissions import ADMIN
from osf.utils.permissions import ADMIN, API_CONTRIBUTOR_PERMISSIONS

from scripts.approve_registrations import approve_past_pendings

Expand Down Expand Up @@ -110,12 +110,17 @@ def get_context_data(self, **kwargs):
'SPAM_STATUS': SpamStatus,
'STORAGE_LIMITS': settings.StorageLimits,
'node': node,
# to edit contributors we should have guid as django prohibits _id usage as it starts with an underscore
'annotated_contributors': node.contributor_set.prefetch_related('user__guids').annotate(guid=F('user__guids___id')),
'children': children,
'permissions': API_CONTRIBUTOR_PERMISSIONS,
'has_update_permission': node.is_admin_contributor(self.request.user),
'duplicates': detailed_duplicates
})

return context


class NodeRemoveNotificationView(View):
def post(self, request, *args, **kwargs):
selected_ids = request.POST.getlist('selected_notifications')
Expand Down Expand Up @@ -197,6 +202,75 @@ def add_contributor_removed_log(self, node, user):
).save()


class NodeUpdatePermissionsView(NodeMixin, View):
permission_required = ('osf.view_node', 'osf.change_node')
raise_exception = True
redirect_view = NodeRemoveContributorView

def post(self, request, *args, **kwargs):
data = dict(request.POST)
contributor_id_to_remove = data.get('remove-user')
resource = self.get_object()

if contributor_id_to_remove:
contributor_id = contributor_id_to_remove[0]
# html renders form into form incorrectly,
# so this view handles contributors deletion and permissions update
return self.redirect_view(
request=request,
kwargs={'guid': resource.guid, 'user_id': contributor_id}
).post(request, user_id=contributor_id)

new_emails_to_add = data.get('new-emails', [])
new_permissions_to_add = data.get('new-permissions', [])

new_permission_indexes_to_remove = []
for email, permission in zip(new_emails_to_add, new_permissions_to_add):
contributor_user = OSFUser.objects.filter(emails__address=email.lower()).first()
if not contributor_user:
new_permission_indexes_to_remove.append(new_emails_to_add.index(email))
messages.error(self.request, f'Email {email} is not registered in OSF.')
continue
elif resource.is_contributor(contributor_user):
new_permission_indexes_to_remove.append(new_emails_to_add.index(email))
messages.error(self.request, f'User with email {email} is already a contributor.')
continue

resource.add_contributor_registered_or_not(
auth=request,
user_id=contributor_user._id,
permissions=permission,
save=True
)
messages.success(self.request, f'User with email {email} was successfully added.')

# should remove permissions of invalid emails because
# admin can make all existing contributors non admins
# and enter an invalid email with the only admin permission
for permission_index in new_permission_indexes_to_remove:
new_permissions_to_add.pop(permission_index)

updated_permissions = data.get('updated-permissions', [])
all_permissions = updated_permissions + new_permissions_to_add
has_admin = list(filter(lambda permission: ADMIN in permission, all_permissions))
if not has_admin:
messages.error(self.request, 'Must be at least one admin on this node.')
return redirect(self.get_success_url())

for contributor_permission in updated_permissions:
guid, permission = contributor_permission.split('-')
user = OSFUser.load(guid)
resource.update_contributor(
user,
permission,
resource.get_visible(user),
request,
save=True
)

return redirect(self.get_success_url())


class NodeDeleteView(NodeMixin, View):
""" Allows authorized users to mark nodes as deleted.
"""
Expand Down
1 change: 1 addition & 0 deletions admin/preprints/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@
re_path(r'^(?P<guid>\w+)/resync_crossref/$', views.PreprintResyncCrossRefView.as_view(), name='resync-crossref'),
re_path(r'^(?P<guid>\w+)/make_published/$', views.PreprintMakePublishedView.as_view(), name='make-published'),
re_path(r'^(?P<guid>\w+)/unwithdraw/$', views.PreprintUnwithdrawView.as_view(), name='unwithdraw'),
re_path(r'^(?P<guid>\w+)/update_permissions/$', views.PreprintUpdatePermissionsView.as_view(), name='update-permissions'),
]
13 changes: 12 additions & 1 deletion admin/preprints/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from admin.base.views import GuidView
from admin.base.forms import GuidForm
from admin.nodes.views import NodeRemoveContributorView
from admin.nodes.views import NodeRemoveContributorView, NodeUpdatePermissionsView
from admin.preprints.forms import ChangeProviderForm, MachineStateForm

from api.share.utils import update_share
Expand Down Expand Up @@ -48,6 +48,7 @@
UNFLAG_SPAM,
)
from osf.utils.workflows import DefaultStates
from osf.utils.permissions import API_CONTRIBUTOR_PERMISSIONS
from website import search
from website.files.utils import copy_files
from website.preprints.tasks import on_preprint_updated
Expand Down Expand Up @@ -75,9 +76,13 @@ def get_context_data(self, **kwargs):
preprint = self.get_object()
return super().get_context_data(**{
'preprint': preprint,
# to edit contributors we should have guid as django prohibits _id usage as it starts with an underscore
'annotated_contributors': preprint.contributor_set.prefetch_related('user__guids').annotate(guid=F('user__guids___id')),
'SPAM_STATUS': SpamStatus,
'change_provider_form': ChangeProviderForm(instance=preprint),
'change_machine_state_form': MachineStateForm(instance=preprint),
'permissions': API_CONTRIBUTOR_PERMISSIONS,
'has_update_permission': preprint.is_admin_contributor(self.request.user)
}, **kwargs)


Expand Down Expand Up @@ -272,6 +277,12 @@ def add_contributor_removed_log(self, preprint, user):
).save()


class PreprintUpdatePermissionsView(PreprintMixin, NodeUpdatePermissionsView):
permission_required = ('osf.view_preprint', 'osf.change_preprint')
raise_exception = True
redirect_view = PreprintRemoveContributorView


class PreprintDeleteView(PreprintMixin, View):
""" Allows authorized users to mark preprints as deleted.
"""
Expand Down
34 changes: 2 additions & 32 deletions admin/templates/nodes/contributors.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,7 @@
<tr>
<td>Email</td>
<td>Name</td>
<td>Permissions</td>
<td>Actions</td>
{% if perms.osf.change_node %}
<td></td>
{% endif %}
<td>Permission</td>
</tr>
</thead>
<tbody>
Expand All @@ -26,37 +22,11 @@
</td>
<td>{{ user.fullname }}</td>
<td>{% get_permissions user node %}</td>
{% if perms.osf.change_node %}
<td>
<a data-toggle="modal" data-target="#{{ user.id }}Modal" class="btn btn-danger">Remove</a>
<div class="modal" id="{{ user.id }}Modal">
<div class="modal-dialog">
<div class="modal-content">
<form class="well" method="post" action="{% url 'nodes:remove-user' guid=node.guid user_id=user.id %}">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">x</button>
<h3>Removing contributor: {{ user.username }}</h3>
</div>
<div class="modal-body">
User will be removed. Currently only an admin on this node type will be able to add them back.
{% csrf_token %}
</div>
<div class="modal-footer">
<input class="btn btn-danger" type="submit" value="Confirm" />
<button type="button" class="btn btn-default" data-dismiss="modal">
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% include 'nodes/edit_contributors.html' with contributors=annotated_contributors resource=node %}
</div>
</td>
</tr>
137 changes: 137 additions & 0 deletions admin/templates/nodes/edit_contributors.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<a data-toggle="modal" data-target="#editContributors" class="btn btn-info">
Edit Contributors
</a>
<div class="modal" id="editContributors" style="width: 100%;">
<div class="modal-dialog" style="width: 60vw; margin: 50px 20vw;">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" onclick="resetModal()" data-dismiss="modal">x</button>
<h3>Edit Contributors</h3>
</div>
{% if resource.type == 'osf.node' or resource.type == 'osf.registration' %}
<form action="{% url 'nodes:update-permissions' guid=resource.guid %}" method="post" id="contributors-form">
{% else %}
<form action="{% url 'preprints:update-permissions' guid=resource.guid %}" method="post" id="contributors-form">
{% endif %}
{% csrf_token %}
<table class="table table-bordered table-hover" id="contributors-table">
<thead>
<tr>
<td>Email</td>
<td>Name</td>
<td>Permission</td>
<td></td>
</tr>
</thead>
<tbody>
{% for contributor in contributors %}
<tr>
<td><a href="{% url 'users:user' guid=contributor.guid %}">{{ contributor.user.email }}</a></td>
<td>{{ contributor.user.fullname }}</td>
<td style="padding: 10px; margin: 0; text-align: center; align-items: center; display: grid;">
<select name="updated-permissions">
{% for permission in permissions %}
{% if contributor.permission == permission %}
<option value="{{ contributor.guid }}-{{ permission }}" selected>{{ permission }}</option>
{% else %}
<option value="{{ contributor.guid }}-{{ permission }}">{{ permission }}</option>
{% endif %}
{% endfor %}
</select>
</td>
{% if has_update_permission %}
<td style="text-align: center;">
<a data-toggle="modal" data-target="#{{ contributor.user.id }}Modal" class="btn btn-danger">Remove</a>
<div class="modal" id="{{ contributor.user.id }}Modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">x</button>
<h3>Removing contributor: {{ contributor.user.username }}</h3>
</div>
<div class="modal-body">
User will be removed. Currently only an admin on this node type will be able to add them back.
</div>
<div class="modal-footer">
<button type="submit" onclick="resetModal()" name="remove-user" value="{{ contributor.user.id }}" class="btn btn-danger">Delete</button>
<button type="button" onclick="resetModal()" name="{{ contributor.user.id }}" class="btn btn-default" data-dismiss="modal">
Cancel
</button>
</div>
</div>
</div>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
<div class="modal-footer" style="display: grid; grid-template: 'left right'; grid-template-columns: 1fr 5fr;">
<div class="left-button" style="grid-area: left; display: flex">
<input name="add-contributor" class="btn btn-success" onclick="addRow()" type="button" value="Add Contributor" />
</div>
<div class="right-buttons" style="grid-area: right">
<button type="button" class="btn btn-default" onclick="resetModal()" data-dismiss="modal">
Cancel
</button>
<input class="btn btn-success" type="submit" value="Save" />
</div>
</div>
</form>
</div>
</div>
</div>

<script>
let new_rows_counter = 0;

function addRow() {
const tableBody = document.getElementById("contributors-table").getElementsByTagName('tbody')[0];
const newRow = document.createElement("tr");
newRow.id = `new-contributor-row-${new_rows_counter}`;
new_rows_counter += 1;
const cell1 = document.createElement("td");
const cell2 = document.createElement("td");
const cell3 = document.createElement("td");
const cell4 = document.createElement("td");

cell1.innerHTML = '<input type="email" required name="new-emails" placeholder="Add email">'
cell3.innerHTML = `
<select name="new-permissions">
{% for permission in permissions %}
{% if contributor.permission == permission %}
<option value="{{ permission }}" selected>{{ permission }}</option>
{% else %}
<option value="{{ permission }}">{{ permission }}</option>
{% endif %}
{% endfor %}
</select>
`;
cell3.style="padding: 10px; margin: 0; text-align: center; align-items: center; display: grid;"
cell4.innerHTML = `<button type="button" class="btn btn-danger" onclick="removeRow('${newRow.id}')" data-dismiss="modal">Remove row</button>`;
cell4.style = 'text-align: center;'

newRow.appendChild(cell1);
newRow.appendChild(cell2);
newRow.appendChild(cell3);
newRow.appendChild(cell4)

tableBody.appendChild(newRow);
}

function removeRow(id) {
try {
document.getElementById(id).remove();
} catch {};
}

function resetModal() {
const table = document.getElementById("contributors-form");
table.reset();
for (let i = 0; i < new_rows_counter; i++) {
removeRow(`new-contributor-row-${i}`);
}
new_rows_counter = 0;
}
</script>
Loading
Loading