Skip to content

Commit 1965f4a

Browse files
[ENG-8516] Add contributor and update permissions functionality on admin (#11278)
* added edit contributors button and basic table * added edit pop-up data display * moved remove functionality to a new edit contributors pop-up * added contributors removal and permissions update * added an ability to add a new contributor + html improvements * removed redundant code * fixed permissions * fixed test * added an ability to add multiple contributors + handle some edge cases * removed redundant conversion * fix test (#11394) * fix test * fix other serializers * linting * added edit contributors button and basic table * added edit pop-up data display * moved remove functionality to a new edit contributors pop-up * added contributors removal and permissions update * added an ability to add a new contributor + html improvements * removed redundant code * fixed permissions * fixed test * added an ability to add multiple contributors + handle some edge cases * removed redundant conversion * flake8 fix --------- Co-authored-by: Yuhuai Liu <yuhuai@cos.io>
1 parent 4f18fa4 commit 1965f4a

File tree

8 files changed

+231
-63
lines changed

8 files changed

+231
-63
lines changed

admin/nodes/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,5 @@
4646
re_path(r'^(?P<guid>[a-z0-9]+)/update_moderation_state/$', views.NodeUpdateModerationStateView.as_view(), name='node-update-mod-state'),
4747
re_path(r'^(?P<guid>[a-z0-9]+)/resync_datacite/$', views.NodeResyncDataCiteView.as_view(), name='resync-datacite'),
4848
re_path(r'^(?P<guid>[a-z0-9]+)/revert/$', views.NodeRevertToDraft.as_view(), name='revert-to-draft'),
49+
re_path(r'^(?P<guid>[a-z0-9]+)/update_permissions/$', views.NodeUpdatePermissionsView.as_view(), name='update-permissions'),
4950
]

admin/nodes/views.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
REINDEX_SHARE,
4848
REINDEX_ELASTIC,
4949
)
50-
from osf.utils.permissions import ADMIN
50+
from osf.utils.permissions import ADMIN, API_CONTRIBUTOR_PERMISSIONS
5151

5252
from scripts.approve_registrations import approve_past_pendings
5353

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

117121
return context
118122

123+
119124
class NodeRemoveNotificationView(View):
120125
def post(self, request, *args, **kwargs):
121126
selected_ids = request.POST.getlist('selected_notifications')
@@ -197,6 +202,75 @@ def add_contributor_removed_log(self, node, user):
197202
).save()
198203

199204

205+
class NodeUpdatePermissionsView(NodeMixin, View):
206+
permission_required = ('osf.view_node', 'osf.change_node')
207+
raise_exception = True
208+
redirect_view = NodeRemoveContributorView
209+
210+
def post(self, request, *args, **kwargs):
211+
data = dict(request.POST)
212+
contributor_id_to_remove = data.get('remove-user')
213+
resource = self.get_object()
214+
215+
if contributor_id_to_remove:
216+
contributor_id = contributor_id_to_remove[0]
217+
# html renders form into form incorrectly,
218+
# so this view handles contributors deletion and permissions update
219+
return self.redirect_view(
220+
request=request,
221+
kwargs={'guid': resource.guid, 'user_id': contributor_id}
222+
).post(request, user_id=contributor_id)
223+
224+
new_emails_to_add = data.get('new-emails', [])
225+
new_permissions_to_add = data.get('new-permissions', [])
226+
227+
new_permission_indexes_to_remove = []
228+
for email, permission in zip(new_emails_to_add, new_permissions_to_add):
229+
contributor_user = OSFUser.objects.filter(emails__address=email.lower()).first()
230+
if not contributor_user:
231+
new_permission_indexes_to_remove.append(new_emails_to_add.index(email))
232+
messages.error(self.request, f'Email {email} is not registered in OSF.')
233+
continue
234+
elif resource.is_contributor(contributor_user):
235+
new_permission_indexes_to_remove.append(new_emails_to_add.index(email))
236+
messages.error(self.request, f'User with email {email} is already a contributor.')
237+
continue
238+
239+
resource.add_contributor_registered_or_not(
240+
auth=request,
241+
user_id=contributor_user._id,
242+
permissions=permission,
243+
save=True
244+
)
245+
messages.success(self.request, f'User with email {email} was successfully added.')
246+
247+
# should remove permissions of invalid emails because
248+
# admin can make all existing contributors non admins
249+
# and enter an invalid email with the only admin permission
250+
for permission_index in new_permission_indexes_to_remove:
251+
new_permissions_to_add.pop(permission_index)
252+
253+
updated_permissions = data.get('updated-permissions', [])
254+
all_permissions = updated_permissions + new_permissions_to_add
255+
has_admin = list(filter(lambda permission: ADMIN in permission, all_permissions))
256+
if not has_admin:
257+
messages.error(self.request, 'Must be at least one admin on this node.')
258+
return redirect(self.get_success_url())
259+
260+
for contributor_permission in updated_permissions:
261+
guid, permission = contributor_permission.split('-')
262+
user = OSFUser.load(guid)
263+
resource.update_contributor(
264+
user,
265+
permission,
266+
resource.get_visible(user),
267+
request,
268+
save=True
269+
)
270+
271+
return redirect(self.get_success_url())
272+
273+
200274
class NodeDeleteView(NodeMixin, View):
201275
""" Allows authorized users to mark nodes as deleted.
202276
"""

admin/preprints/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,5 @@
2929
re_path(r'^(?P<guid>\w+)/resync_crossref/$', views.PreprintResyncCrossRefView.as_view(), name='resync-crossref'),
3030
re_path(r'^(?P<guid>\w+)/make_published/$', views.PreprintMakePublishedView.as_view(), name='make-published'),
3131
re_path(r'^(?P<guid>\w+)/unwithdraw/$', views.PreprintUnwithdrawView.as_view(), name='unwithdraw'),
32+
re_path(r'^(?P<guid>\w+)/update_permissions/$', views.PreprintUpdatePermissionsView.as_view(), name='update-permissions'),
3233
]

admin/preprints/views.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from admin.base.views import GuidView
1717
from admin.base.forms import GuidForm
18-
from admin.nodes.views import NodeRemoveContributorView
18+
from admin.nodes.views import NodeRemoveContributorView, NodeUpdatePermissionsView
1919
from admin.preprints.forms import ChangeProviderForm, MachineStateForm
2020

2121
from api.share.utils import update_share
@@ -48,6 +48,7 @@
4848
UNFLAG_SPAM,
4949
)
5050
from osf.utils.workflows import DefaultStates
51+
from osf.utils.permissions import API_CONTRIBUTOR_PERMISSIONS
5152
from website import search
5253
from website.files.utils import copy_files
5354
from website.preprints.tasks import on_preprint_updated
@@ -75,9 +76,13 @@ def get_context_data(self, **kwargs):
7576
preprint = self.get_object()
7677
return super().get_context_data(**{
7778
'preprint': preprint,
79+
# to edit contributors we should have guid as django prohibits _id usage as it starts with an underscore
80+
'annotated_contributors': preprint.contributor_set.prefetch_related('user__guids').annotate(guid=F('user__guids___id')),
7881
'SPAM_STATUS': SpamStatus,
7982
'change_provider_form': ChangeProviderForm(instance=preprint),
8083
'change_machine_state_form': MachineStateForm(instance=preprint),
84+
'permissions': API_CONTRIBUTOR_PERMISSIONS,
85+
'has_update_permission': preprint.is_admin_contributor(self.request.user)
8186
}, **kwargs)
8287

8388

@@ -272,6 +277,12 @@ def add_contributor_removed_log(self, preprint, user):
272277
).save()
273278

274279

280+
class PreprintUpdatePermissionsView(PreprintMixin, NodeUpdatePermissionsView):
281+
permission_required = ('osf.view_preprint', 'osf.change_preprint')
282+
raise_exception = True
283+
redirect_view = PreprintRemoveContributorView
284+
285+
275286
class PreprintDeleteView(PreprintMixin, View):
276287
""" Allows authorized users to mark preprints as deleted.
277288
"""

admin/templates/nodes/contributors.html

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,7 @@
99
<tr>
1010
<td>Email</td>
1111
<td>Name</td>
12-
<td>Permissions</td>
13-
<td>Actions</td>
14-
{% if perms.osf.change_node %}
15-
<td></td>
16-
{% endif %}
12+
<td>Permission</td>
1713
</tr>
1814
</thead>
1915
<tbody>
@@ -26,37 +22,11 @@
2622
</td>
2723
<td>{{ user.fullname }}</td>
2824
<td>{% get_permissions user node %}</td>
29-
{% if perms.osf.change_node %}
30-
<td>
31-
<a data-toggle="modal" data-target="#{{ user.id }}Modal" class="btn btn-danger">Remove</a>
32-
<div class="modal" id="{{ user.id }}Modal">
33-
<div class="modal-dialog">
34-
<div class="modal-content">
35-
<form class="well" method="post" action="{% url 'nodes:remove-user' guid=node.guid user_id=user.id %}">
36-
<div class="modal-header">
37-
<button type="button" class="close" data-dismiss="modal">x</button>
38-
<h3>Removing contributor: {{ user.username }}</h3>
39-
</div>
40-
<div class="modal-body">
41-
User will be removed. Currently only an admin on this node type will be able to add them back.
42-
{% csrf_token %}
43-
</div>
44-
<div class="modal-footer">
45-
<input class="btn btn-danger" type="submit" value="Confirm" />
46-
<button type="button" class="btn btn-default" data-dismiss="modal">
47-
Cancel
48-
</button>
49-
</div>
50-
</form>
51-
</div>
52-
</div>
53-
</div>
54-
</td>
55-
{% endif %}
5625
</tr>
5726
{% endfor %}
5827
</tbody>
5928
</table>
29+
{% include 'nodes/edit_contributors.html' with contributors=annotated_contributors resource=node %}
6030
</div>
6131
</td>
6232
</tr>
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<a data-toggle="modal" data-target="#editContributors" class="btn btn-info">
2+
Edit Contributors
3+
</a>
4+
<div class="modal" id="editContributors" style="width: 100%;">
5+
<div class="modal-dialog" style="width: 60vw; margin: 50px 20vw;">
6+
<div class="modal-content">
7+
<div class="modal-header">
8+
<button type="button" class="close" onclick="resetModal()" data-dismiss="modal">x</button>
9+
<h3>Edit Contributors</h3>
10+
</div>
11+
{% if resource.type == 'osf.node' or resource.type == 'osf.registration' %}
12+
<form action="{% url 'nodes:update-permissions' guid=resource.guid %}" method="post" id="contributors-form">
13+
{% else %}
14+
<form action="{% url 'preprints:update-permissions' guid=resource.guid %}" method="post" id="contributors-form">
15+
{% endif %}
16+
{% csrf_token %}
17+
<table class="table table-bordered table-hover" id="contributors-table">
18+
<thead>
19+
<tr>
20+
<td>Email</td>
21+
<td>Name</td>
22+
<td>Permission</td>
23+
<td></td>
24+
</tr>
25+
</thead>
26+
<tbody>
27+
{% for contributor in contributors %}
28+
<tr>
29+
<td><a href="{% url 'users:user' guid=contributor.guid %}">{{ contributor.user.email }}</a></td>
30+
<td>{{ contributor.user.fullname }}</td>
31+
<td style="padding: 10px; margin: 0; text-align: center; align-items: center; display: grid;">
32+
<select name="updated-permissions">
33+
{% for permission in permissions %}
34+
{% if contributor.permission == permission %}
35+
<option value="{{ contributor.guid }}-{{ permission }}" selected>{{ permission }}</option>
36+
{% else %}
37+
<option value="{{ contributor.guid }}-{{ permission }}">{{ permission }}</option>
38+
{% endif %}
39+
{% endfor %}
40+
</select>
41+
</td>
42+
{% if has_update_permission %}
43+
<td style="text-align: center;">
44+
<a data-toggle="modal" data-target="#{{ contributor.user.id }}Modal" class="btn btn-danger">Remove</a>
45+
<div class="modal" id="{{ contributor.user.id }}Modal">
46+
<div class="modal-dialog">
47+
<div class="modal-content">
48+
<div class="modal-header">
49+
<button type="button" class="close" data-dismiss="modal">x</button>
50+
<h3>Removing contributor: {{ contributor.user.username }}</h3>
51+
</div>
52+
<div class="modal-body">
53+
User will be removed. Currently only an admin on this node type will be able to add them back.
54+
</div>
55+
<div class="modal-footer">
56+
<button type="submit" onclick="resetModal()" name="remove-user" value="{{ contributor.user.id }}" class="btn btn-danger">Delete</button>
57+
<button type="button" onclick="resetModal()" name="{{ contributor.user.id }}" class="btn btn-default" data-dismiss="modal">
58+
Cancel
59+
</button>
60+
</div>
61+
</div>
62+
</div>
63+
</div>
64+
</td>
65+
{% endif %}
66+
</tr>
67+
{% endfor %}
68+
</tbody>
69+
</table>
70+
<div class="modal-footer" style="display: grid; grid-template: 'left right'; grid-template-columns: 1fr 5fr;">
71+
<div class="left-button" style="grid-area: left; display: flex">
72+
<input name="add-contributor" class="btn btn-success" onclick="addRow()" type="button" value="Add Contributor" />
73+
</div>
74+
<div class="right-buttons" style="grid-area: right">
75+
<button type="button" class="btn btn-default" onclick="resetModal()" data-dismiss="modal">
76+
Cancel
77+
</button>
78+
<input class="btn btn-success" type="submit" value="Save" />
79+
</div>
80+
</div>
81+
</form>
82+
</div>
83+
</div>
84+
</div>
85+
86+
<script>
87+
let new_rows_counter = 0;
88+
89+
function addRow() {
90+
const tableBody = document.getElementById("contributors-table").getElementsByTagName('tbody')[0];
91+
const newRow = document.createElement("tr");
92+
newRow.id = `new-contributor-row-${new_rows_counter}`;
93+
new_rows_counter += 1;
94+
const cell1 = document.createElement("td");
95+
const cell2 = document.createElement("td");
96+
const cell3 = document.createElement("td");
97+
const cell4 = document.createElement("td");
98+
99+
cell1.innerHTML = '<input type="email" required name="new-emails" placeholder="Add email">'
100+
cell3.innerHTML = `
101+
<select name="new-permissions">
102+
{% for permission in permissions %}
103+
{% if contributor.permission == permission %}
104+
<option value="{{ permission }}" selected>{{ permission }}</option>
105+
{% else %}
106+
<option value="{{ permission }}">{{ permission }}</option>
107+
{% endif %}
108+
{% endfor %}
109+
</select>
110+
`;
111+
cell3.style="padding: 10px; margin: 0; text-align: center; align-items: center; display: grid;"
112+
cell4.innerHTML = `<button type="button" class="btn btn-danger" onclick="removeRow('${newRow.id}')" data-dismiss="modal">Remove row</button>`;
113+
cell4.style = 'text-align: center;'
114+
115+
newRow.appendChild(cell1);
116+
newRow.appendChild(cell2);
117+
newRow.appendChild(cell3);
118+
newRow.appendChild(cell4)
119+
120+
tableBody.appendChild(newRow);
121+
}
122+
123+
function removeRow(id) {
124+
try {
125+
document.getElementById(id).remove();
126+
} catch {};
127+
}
128+
129+
function resetModal() {
130+
const table = document.getElementById("contributors-form");
131+
table.reset();
132+
for (let i = 0; i < new_rows_counter; i++) {
133+
removeRow(`new-contributor-row-${i}`);
134+
}
135+
new_rows_counter = 0;
136+
}
137+
</script>

0 commit comments

Comments
 (0)