diff --git a/src/core/logic.py b/src/core/logic.py index 98268f3ae4..acfa714a6e 100755 --- a/src/core/logic.py +++ b/src/core/logic.py @@ -347,6 +347,10 @@ def get_settings_to_edit(display_group, journal, user): 'name': 'default_review_days', 'object': setting_handler.get_setting('general', 'default_review_days', journal), }, + { + 'name': 'default_editor_assignment_request_days', + 'object': setting_handler.get_setting('general', 'default_editor_assignment_request_days', journal), + }, { 'name': 'enable_save_review_progress', 'object': setting_handler.get_setting('general', 'enable_save_review_progress', journal), @@ -363,6 +367,10 @@ def get_settings_to_edit(display_group, journal, user): 'name': 'draft_decisions', 'object': setting_handler.get_setting('general', 'draft_decisions', journal), }, + { + 'name': 'enable_invite_editor', + 'object': setting_handler.get_setting('general', 'enable_invite_editor', journal), + }, { 'name': 'default_review_form', 'object': setting_handler.get_setting('general', 'default_review_form', journal), @@ -424,6 +432,10 @@ def get_settings_to_edit(display_group, journal, user): 'name': 'display_completed_reviews_in_additional_rounds_text', 'object': setting_handler.get_setting('general', 'display_completed_reviews_in_additional_rounds_text', journal), }, + { + 'name': 'enable_custom_editor_assignment', + 'object': setting_handler.get_setting('general', 'enable_custom_editor_assignment', journal), + }, ] setting_group = 'general' diff --git a/src/events/logic.py b/src/events/logic.py index 13dfa72e81..d4128cc5aa 100755 --- a/src/events/logic.py +++ b/src/events/logic.py @@ -27,6 +27,17 @@ class Events: # kwargs: editor_assignment, request, email_data, acknowledgement (true), skip (boolean) # raised when an editor is manually assigned to an article(or skip the acknowledgement) ON_EDITOR_MANUALLY_ASSIGNED = 'on_editor_manually_assigned' + # kwargs: editor_assignment, request, email_data, acknowledgement (true), skip (boolean) + # raised when an editor decides to notify to another editor with a custom message or skipped the email + ON_EDITOR_REQUESTED_NOTIFICATION = 'on_editor_requested_notification' + ON_EDITOR_REQUEST_REMINDED = 'on_editor_request_reminded' + # kwargs: review_assignment, request, user_message_content, skip (boolean) + # raised when an editor decides to notify the reviewer of a assignment withdrawl (or skip the notification) + ON_EDITOR_REQUEST_WITHDRAWL = 'on_editor_request_withdrawl' + # kwargs: editor_assignment, request, accepted (boolean) + # raised when an editor accepts or declines to assignment request + ON_EDITOR_ASSIGNMENT_ACCEPTED = 'on_editor_assignment_accepted' + ON_EDITOR_ASSIGNMENT_DECLINED = 'on_editor_assignment_declined' # kwargs: request, editor_assignment, user_message_content (will be blank), acknowledgement (false) # raised when an editor is assigned to an article diff --git a/src/events/registration.py b/src/events/registration.py index ea9955351f..20b22b3be9 100755 --- a/src/events/registration.py +++ b/src/events/registration.py @@ -21,9 +21,18 @@ transactional_emails.send_editor_assigned_acknowledgements) event_logic.Events.register_for_event(event_logic.Events.ON_EDITOR_MANUALLY_ASSIGNED, transactional_emails.send_editor_manually_assigned) +event_logic.Events.register_for_event(event_logic.Events.ON_EDITOR_REQUESTED_NOTIFICATION, + transactional_emails.send_editor_assignment_requested) +event_logic.Events.register_for_event(event_logic.Events.ON_EDITOR_REQUEST_REMINDED, + transactional_emails.send_editor_assignment_reminder) +event_logic.Events.register_for_event(event_logic.Events.ON_EDITOR_REQUEST_WITHDRAWL, + transactional_emails.send_editor_assignment_withdrawl) event_logic.Events.register_for_event(event_logic.Events.ON_ARTICLE_UNASSIGNED, - transactional_emails.send_editor_unassigned_notice) - + transactional_emails.send_editor_unassigned_notice) +event_logic.Events.register_for_event(event_logic.Events.ON_EDITOR_ASSIGNMENT_ACCEPTED, + transactional_emails.send_editor_assign_accepted_or_decline_acknowledgements) +event_logic.Events.register_for_event(event_logic.Events.ON_EDITOR_ASSIGNMENT_DECLINED, + transactional_emails.send_editor_assign_accepted_or_decline_acknowledgements) # Review event_logic.Events.register_for_event(event_logic.Events.ON_REVIEWER_REQUESTED_NOTIFICATION, transactional_emails.send_reviewer_requested) diff --git a/src/review/forms.py b/src/review/forms.py index 344886edda..64fbe5ac83 100755 --- a/src/review/forms.py +++ b/src/review/forms.py @@ -132,6 +132,81 @@ def check_for_potential_errors(self): return potential_errors +class EditorAssignmentForm(core_forms.ConfirmableIfErrorsForm): + editor = forms.ModelChoiceField(queryset=None) + date_due = forms.DateField(required=False) + + def __init__(self, *args, **kwargs): + self.journal = kwargs.pop('journal', None) + self.article = kwargs.pop('article') + self.editors = kwargs.pop('editors') + self.invite_editor = kwargs.pop('invite_editor', False) + + super(EditorAssignmentForm, self).__init__(*args, **kwargs) + + default_due = setting_handler.get_setting( + 'general', + 'default_review_days', + self.journal, + create=True, + ).value + + if default_due: + due_date = timezone.now() + timedelta(days=int(default_due)) + self.fields['date_due'].initial = due_date + self.fields['date_due'].required = False + + if self.editors: + self.fields['editor'].queryset = self.editors + + def clean(self): + cleaned_data = super().clean() + if self.invite_editor and not cleaned_data.get('date_due'): + self.add_error('date_due', 'This field is required for inviting an editor.') + return cleaned_data + + def save(self, commit=True, request=None): + editor = self.cleaned_data['editor'] + date_due = self.cleaned_data['date_due'] + + if request: + if self.invite_editor: + editor_assignment = models.EditorAssignmentRequest( + article=self.article, + editor=editor, + date_due=date_due, + ) + editor_assignment.requesting_editor = request.user + else: + editor_assignment = models.EditorAssignment( + article=self.article, + editor=editor, + ) + + if editor_assignment.editor.is_editor(request): + editor_assignment.editor_type = 'editor' + elif editor_assignment.editor.is_section_editor(request): + editor_assignment.editor_type = 'section-editor' + + if commit: + editor_assignment.save() + + return editor_assignment + + +class EditEditorAssignmentForm(forms.ModelForm): + class Meta: + model = models.EditorAssignmentRequest + fields = ('date_due',) + widgets = { + 'date_due': HTMLDateInput, + } + + def __init__(self, *args, **kwargs): + self.journal = kwargs.pop('journal', None) + super(EditEditorAssignmentForm, self).__init__(*args, **kwargs) + + class BulkReviewAssignmentForm(forms.ModelForm): template = forms.CharField( widget=TinyMCE, diff --git a/src/review/logic.py b/src/review/logic.py index b9c1e32c56..4dbc611107 100755 --- a/src/review/logic.py +++ b/src/review/logic.py @@ -22,7 +22,10 @@ When, BooleanField, Value, + F, + Q, ) +from django.db.models.functions import Coalesce from django.shortcuts import redirect, reverse from django.utils import timezone from django.db import IntegrityError @@ -41,6 +44,77 @@ from submission import models as submission_models +def get_editors(article, candidate_queryset, exclude_pks): + prefetch_editor_assignment = Prefetch( + 'editor', + queryset=models.EditorAssignment.objects.filter( + article__journal=article.journal + ) + ) + active_assignments_count = models.EditorAssignment.objects.filter( + editor=OuterRef("id"), + ).values( + "editor_id", + ).annotate( + rev_count=Count("editor_id"), + ).values("rev_count") + + editors = candidate_queryset.exclude( + pk__in=exclude_pks, + ).prefetch_related( + prefetch_editor_assignment, + 'interest', + ) + order_by = [] + + editors = editors.annotate( + active_assignments_count=Subquery( + active_assignments_count, + output_field=IntegerField(), + ) + ).annotate( + active_assignments_count=Coalesce(F('active_assignments_count'), Value(0)), + ) + order_by.append('active_assignments_count') + + editors = editors.order_by(*order_by) + + return editors + + +def get_editors_candidates(article, user=None, editors_to_exclude=None): + """ Builds a queryset of candidates for editor assignment requests for the given article + :param article: an instance of submission.models.Article + :param user: The user requesting candidates who would be filtered out + :param editors_to_exclude: queryset of Account objects + """ + editor_assignment_requests = article.editorassignmentrequest_set.filter( + Q(is_complete=False) & + Q(article__stage__in=submission_models.EDITOR_REVIEW_STAGES) & + Q(date_accepted__isnull=True) & + Q(date_declined__isnull=True) + ) + editors = article.editorassignment_set.all() + editor_pks_to_exclude = [assignment.editor.pk for assignment in editor_assignment_requests] + editor_pks_to_exclude = editor_pks_to_exclude + [assignment.editor.pk for assignment in editors] + + if editors_to_exclude: + for editor in editors_to_exclude: + editor_pks_to_exclude.append( + editor.pk, + ) + + queryset_editor = article.journal.users_with_role('editor') + queryset_section_editor = article.journal.users_with_role('section-editor') + + return get_editors( + article, + queryset_editor | queryset_section_editor, + editor_pks_to_exclude + ) + + + def get_reviewers(article, candidate_queryset, exclude_pks): prefetch_review_assignment = Prefetch( 'reviewer', @@ -225,6 +299,27 @@ def get_article_details_for_review(article): return mark_safe(detail_string) +def get_editor_notification_context( + request, article, editor, + editor_assignment, +): + review_unassigned_url = request.journal.site_url(path=reverse( + 'review_unassigned_article', kwargs={'article_id': article.id} + )) + + article_details = get_article_details_for_review(article) + + email_context = { + 'article': article, + 'editor': editor, + 'editor_assignment': editor_assignment, + 'review_unassigned_url': review_unassigned_url, + 'article_details': article_details, + } + + return email_context + + def get_reviewer_notification_context( request, article, editor, review_assignment, @@ -626,6 +721,15 @@ def quick_assign(request, article, reviewer_user=None): messages.add_message(request, messages.WARNING, error) +def handle_editor_form(request, new_editor_form, editor_type): + account = new_editor_form.save(commit=False) + account.is_active = True + account.save() + account.add_account_role(editor_type, request.journal) + messages.add_message(request, messages.INFO, 'A new account has been created.') + return account + + def handle_reviewer_form(request, new_reviewer_form): account = new_reviewer_form.save(commit=False) account.is_active = True diff --git a/src/review/migrations/0024_editorassignmentrequest.py b/src/review/migrations/0024_editorassignmentrequest.py new file mode 100644 index 0000000000..de25eac199 --- /dev/null +++ b/src/review/migrations/0024_editorassignmentrequest.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.16 on 2024-12-04 08:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('submission', '0084_remove_article_jats_article_type_and_more'), + ('review', '0023_auto_20240312_0922'), + ] + + operations = [ + migrations.CreateModel( + name='EditorAssignmentRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('editor_type', models.CharField(choices=[('editor', 'Editor'), ('section-editor', 'Section Editor')], max_length=20)), + ('notified', models.BooleanField(default=False)), + ('date_requested', models.DateTimeField(auto_now_add=True, null=True)), + ('date_due', models.DateField(null=True)), + ('date_accepted', models.DateTimeField(blank=True, null=True)), + ('date_declined', models.DateTimeField(blank=True, null=True)), + ('date_complete', models.DateTimeField(blank=True, null=True)), + ('date_reminded', models.DateField(blank=True, null=True)), + ('is_complete', models.BooleanField(default=False)), + ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='submission.article')), + ('editor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('editor_assignment', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='review.editorassignment')), + ('requesting_editor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='requesting_editor', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/review/models.py b/src/review/models.py index 397102c141..c02004f9df 100755 --- a/src/review/models.py +++ b/src/review/models.py @@ -95,6 +95,43 @@ class Meta: unique_together = ('article', 'editor') +class EditorAssignmentRequest(models.Model): + + article = models.ForeignKey( + 'submission.Article', + on_delete=models.CASCADE, + ) + editor = models.ForeignKey( + 'core.Account', + on_delete=models.CASCADE, + ) + requesting_editor = models.ForeignKey( + 'core.Account', + on_delete=models.CASCADE, + related_name='requesting_editor', + null=True, + ) + + editor_assignment = models.ForeignKey( + EditorAssignment, + on_delete=models.CASCADE, + null=True, + ) + + editor_type = models.CharField(max_length=20, choices=assignment_choices) + notified = models.BooleanField(default=False) + + # Dates + date_requested = models.DateTimeField(auto_now_add=True, null=True) + date_due = models.DateField(null=True) + date_accepted = models.DateTimeField(blank=True, null=True) + date_declined = models.DateTimeField(blank=True, null=True) + date_complete = models.DateTimeField(blank=True, null=True) + date_reminded = models.DateField(blank=True, null=True) + + is_complete = models.BooleanField(default=False) + + class ReviewRound(models.Model): article = models.ForeignKey( 'submission.Article', diff --git a/src/review/urls.py b/src/review/urls.py index 692d4569c9..57165cf4ac 100755 --- a/src/review/urls.py +++ b/src/review/urls.py @@ -32,6 +32,9 @@ re_path(r'^unassigned/article/(?P\d+)/notify/(?P\d+)/$', views.assignment_notification, name='review_assignment_notification'), re_path(r'^unassigned/article/(?P\d+)/move/review/$', views.move_to_review, name='review_move_to_review'), + re_path(r'^article/(?P\d+)/editor/add/$', views.add_editor_assignment, name='add_editor_assignment'), + re_path(r'^article/(?P\d+)/editor/invite/(?P\d+)/notify/$', views.notify_invite_editor, + name='notify_invite_editor_assignment'), re_path(r'^article/(?P\d+)/crosscheck/$', views.view_ithenticate_report, name='review_crosscheck'), re_path(r'^article/(?P\d+)/move/(?Paccept|decline|undecline)/$', views.review_decision, name='review_decision'), @@ -101,6 +104,13 @@ views.upload_review_file, name='upload_review_file'), + re_path(r'^requests/editor/$', views.editor_assignment_requests, name='editor_assignment_requests'), + re_path(r'^requests/editor/(?P\d+)/accept/$', views.accept_editor_assignment_request, name='accept_editor_assignment'), + re_path(r'^requests/editor/(?P\d+)/decline/$', views.decline_editor_assignment_request, name='decline_editor_assignment'), + re_path(r'^requests/editor/(?P\d+)/delete/$', views.delete_editor_assignment_request, name='delete_editor_assignment'), + re_path(r'^requests/editor/(?P\d+)/edit/$', views.edit_editor_assignment_request, name='edit_editor_assignment'), + re_path(r'^requests/editor/(?P\d+)/withdraw/$', views.withdraw_editor_assignment_request, name='withdraw_editor_assignment'), + re_path(r'^requests/editor/(?P\d+)/reminder/$', views.remind_editor_assignment_request, name='remind_editor_assignment'), re_path(r'^author/(?P\d+)/$', views.author_view_reviews, name='review_author_view'), diff --git a/src/review/views.py b/src/review/views.py index aa13351be9..1678cf2e18 100755 --- a/src/review/views.py +++ b/src/review/views.py @@ -38,7 +38,8 @@ editor_is_not_author, senior_editor_user_required, section_editor_draft_decisions, article_stage_review_required, any_editor_user_required, setting_is_enabled, - user_has_completed_review_for_article + user_has_completed_review_for_article, + editor_user_for_assignment_request_required ) from submission import models as submission_models, forms as submission_forms from utils import models as util_models, ithenticate, shared, setting_handler @@ -140,19 +141,38 @@ def unassigned_article(request, article_id): current_editors = [assignment.editor.pk for assignment in models.EditorAssignment.objects.filter(article=article)] + + requested_editors = models.EditorAssignmentRequest.objects.filter( + Q(is_complete=False) & + Q(article__stage__in=submission_models.EDITOR_REVIEW_STAGES) & + Q(date_accepted__isnull=True) & + Q(date_declined__isnull=True) + ) + + exclude_editors = [ + assignment.editor.pk + for assignment in models.EditorAssignment.objects.filter(article=article) + ] + + enable_invite_editor = setting_handler.get_setting('general', 'enable_invite_editor', request.journal).value + if enable_invite_editor: + exclude_editors = exclude_editors + [request.editor.pk for request in requested_editors] + editors = core_models.AccountRole.objects.filter( role__slug='editor', - journal=request.journal).exclude(user__id__in=current_editors) + journal=request.journal + ).exclude(user__id__in=exclude_editors) section_editors = core_models.AccountRole.objects.filter( role__slug='section-editor', journal=request.journal - ).exclude(user__id__in=current_editors) + ).exclude(user__id__in=exclude_editors) template = 'review/unassigned_article.html' context = { 'article': article, 'editors': editors, 'section_editors': section_editors, + 'requested_editors': requested_editors, } return render(request, template, context) @@ -229,6 +249,122 @@ def view_ithenticate_report(request, article_id): return render(request, template, context) +@editor_is_not_author +@editor_user_required +def add_editor_assignment(request, article_id): + """ + Allow an editor to add a new editor assignment + :param request: HttpRequest object + :param article_id: Article PK + :return: HttpResponse + """ + article = get_object_or_404(submission_models.Article, pk=article_id) + + editors = logic.get_editors_candidates( + article, + user=request.user, + ) + + form = forms.EditorAssignmentForm( + journal=request.journal, + article=article, + editors=editors + ) + + new_editor_form = core_forms.QuickUserForm() + + if request.POST: + + if 'assign' in request.POST: + # first check whether the user exists + new_editor_form = core_forms.QuickUserForm(request.POST) + try: + user = core_models.Account.objects.get(email=new_editor_form.data['email']) + user.add_account_role('section-editor', request.journal) + except core_models.Account.DoesNotExist: + user = None + + if user: + return redirect( + reverse( + 'add_editor_assignment', + kwargs={'article_id': article.pk} + ) + '?' + parse.urlencode({'user': new_editor_form.data['email'], 'id': str(user.pk)},) + ) + + valid = new_editor_form.is_valid() + + if valid: + acc = logic.handle_editor_form(request, new_editor_form, 'section-editor') + return redirect( + reverse( + 'add_editor_assignment', kwargs={'article_id': article.pk} + ) + '?' + parse.urlencode({'user': new_editor_form.data['email'], 'id': str(acc.pk)}), + ) + else: + form.modal = {'id': 'editor'} + + elif 'invite' in request.POST: + form = forms.EditorAssignmentForm( + request.POST, + journal=request.journal, + article=article, + editors=editors, + invite_editor=True + ) + if form.is_valid() and form.is_confirmed(): + editor_assignment = form.save(request=request) + article.save() + return redirect( + reverse( + 'notify_invite_editor_assignment', + kwargs={'article_id': article_id, 'editor_assignment_id': editor_assignment.id} + ) + ) + else: + form = forms.EditorAssignmentForm( + request.POST, + journal=request.journal, + article=article, + editors=editors, + ) + if form.is_valid() and form.is_confirmed(): + editor_assignment = form.save(request=request, commit=False) + editor = editor_assignment.editor + assignment_type = editor_assignment.editor_type + + if not editor.has_an_editor_role(request): + messages.add_message(request, messages.WARNING, 'User is not an Editor or Section Editor') + return redirect(reverse('review_unassigned_article', kwargs={'article_id': article.pk})) + + _, created = logic.assign_editor(article, editor, assignment_type, request) + messages.add_message(request, messages.SUCCESS, '{0} added as an Editor'.format(editor.full_name())) + if created and editor: + return redirect( + reverse( + 'review_assignment_notification', + kwargs={'article_id': article_id, 'editor_id': editor.pk} + ), + ) + else: + messages.add_message(request, messages.WARNING, + '{0} is already an Editor on this article.'.format(editor.full_name())) + + return redirect(reverse('review_unassigned_article', kwargs={'article_id': article_id})) + + template = 'admin/review/add_editor_assignment.html' + + context = { + 'article': article, + 'form': form, + 'editors': editors.filter(accountrole__role__slug='editor'), + 'section_editors': editors.filter(accountrole__role__slug='section-editor'), + 'new_editor_form': new_editor_form, + } + + return render(request, template, context) + + @senior_editor_user_required def assign_editor_move_to_review(request, article_id, editor_id, assignment_type): """Allows an editor to assign another editor to an article and moves to review.""" @@ -782,6 +918,286 @@ def decline_review_request(request, assignment_id): return render(request, template, context) +@editor_user_for_assignment_request_required +def accept_editor_assignment_request(request, assignment_id): + """ + Accept an editor assignment request + :param request: the request object + :param assignment_id: the assignment ID to handle + :return: a context for a Django template + """ + + # update the EditorAssignmentRequest object + assignment = models.EditorAssignmentRequest.objects.get( + Q(pk=assignment_id) & + Q(is_complete=False) & + Q(editor=request.user) & + Q(article__stage__in=submission_models.EDITOR_REVIEW_STAGES) & + Q(date_accepted__isnull=True) + ) + + editor_assignment = models.EditorAssignment( + article=assignment.article, + editor=assignment.editor, + editor_type=assignment.editor_type, + notified=True + ) + editor_assignment.save() + + assignment.date_accepted = timezone.now() + assignment.editor_assignment = editor_assignment + assignment.save() + + kwargs = {'editor_assignment': assignment, + 'request': request, + 'accepted': True} + + event_logic.Events.raise_event(event_logic.Events.ON_EDITOR_ASSIGNMENT_ACCEPTED, + task_object=assignment.article, + **kwargs) + + return redirect( + reverse( + 'review_unassigned_article', + kwargs={'article_id': assignment.article.pk}, + ) + ) + + +@editor_user_for_assignment_request_required +def decline_editor_assignment_request(request, assignment_id): + """ + Decline an editor assignment request + :param request: the request object + :param assignment_id: the assignment ID to handle + :return: a context for a Django template + """ + + assignment = models.EditorAssignmentRequest.objects.get( + Q(pk=assignment_id) & + Q(is_complete=False) & + Q(article__stage__in=submission_models.EDITOR_REVIEW_STAGES) & + Q(editor=request.user) + ) + + assignment.date_declined = timezone.now() + assignment.date_accepted = None + assignment.is_complete = True + assignment.save() + + template = 'review/editor_assignment_decline.html' + context = { + 'assigned_articles_for_user_editor_review': assignment, + 'access_code': '' + } + + kwargs = {'editor_assignment': assignment, + 'request': request, + 'accepted': False} + event_logic.Events.raise_event(event_logic.Events.ON_EDITOR_ASSIGNMENT_ACCEPTED, + task_object=assignment.article, + **kwargs) + + return render(request, template, context) + + +@senior_editor_user_required +def edit_editor_assignment_request(request, assignment_id): + """ + A view that allows a user to edit an editor assignment request. + :param request: Django's request object + :param assignment_id: EditorAssignmentRequest PK + :return: a rendered django template + """ + + assignment = get_object_or_404( + models.EditorAssignmentRequest, id=assignment_id + ) + + if assignment.date_complete: + messages.add_message(request, messages.WARNING, 'You cannot edit an editor assignment that is already complete.') + return redirect(reverse('review_unassigned_article', kwargs={'article_id': assignment.article.id})) + + form = forms.EditEditorAssignmentForm(instance=assignment, journal=request.journal) + + if request.POST: + form = forms.EditEditorAssignmentForm(request.POST, instance=assignment, journal=request.journal) + + if form.is_valid(): + form.save() + messages.add_message(request, messages.INFO, 'Editor Assignment updates.') + util_models.LogEntry.add_entry('Editor Assignment Deleted', 'Editor Assignment updated.', level='Info', actor=request.user, + request=request, target=assignment) + return redirect(reverse('review_unassigned_article', kwargs={'article_id': assignment.article.id})) + + template = 'review/edit_editor_assignment.html' + context = { + 'article': assignment.article, + 'assignment': assignment, + 'form': form, + } + + return render(request, template, context) + + +@senior_editor_user_required +def remind_editor_assignment_request(request, assignment_id): + """ + Allows a senior editor to resent an editor assignment invite or manually send a reminder. + :param request: Django's request object + :param assignment_id: EditorAssignmentRequest PK + :return: HttpResponse or HttpRedirect + """ + + assignment = get_object_or_404( + models.EditorAssignmentRequest, id=assignment_id + ) + + email_context = logic.get_editor_notification_context( + request, assignment.article, request.user, assignment) + + form = core_forms.SettingEmailForm( + setting_name="editor_assignment_reminder", + email_context=email_context, + request=request, + ) + + if request.POST: + form = core_forms.SettingEmailForm( + request.POST, request.FILES, + setting_name="editor_assignment_reminder", + email_context=email_context, + request=request, + ) + + if form.is_valid(): + kwargs = { + 'email_data': form.as_dataclass(), + 'editor_assignment': assignment, + 'request': request, + } + + event_logic.Events.raise_event( + event_logic.Events.ON_EDITOR_REQUEST_REMINDED, **kwargs) + + return redirect(reverse( + 'review_unassigned_article', + kwargs={'article_id': assignment.article.pk}, + )) + + template = 'review/notify_remind_editor.html' + context = { + 'article': assignment.article, + 'editor': assignment.editor, + 'form': form, + 'assignment': assignment, + } + + return render(request, template, context) + + +@senior_editor_user_required +def withdraw_editor_assignment_request(request, assignment_id): + """ + A view that allows a user to withdraw an editor assignment request. + :param request: Django's request object + :param assignment_id: EditorAssignmentRequest PK + :return:a rendered django template + """ + assignment = get_object_or_404( + models.EditorAssignmentRequest, id=assignment_id + ) + + if assignment.date_complete: + messages.add_message( + request, + messages.WARNING, + 'You cannot withdraw an editor assigment that is already complete.', + ) + return redirect(reverse( + 'review_unassigned_article', + kwargs={'article_id': assignment.article.pk}, + )) + + email_context = { + 'article': assignment.article, + 'editor_assignment': assignment, + 'editor': request.user, + } + form = core_forms.SettingEmailForm( + setting_name="editor_assignment_withdrawl", + email_context=email_context, + request=request, + ) + if request.POST: + skip = request.POST.get("skip") + form = core_forms.SettingEmailForm( + request.POST, request.FILES, + setting_name="editor_assignment_withdrawl", + email_context=email_context, + request=request, + ) + if form.is_valid() or skip: + assignment.date_complete = timezone.now() + assignment.is_complete = True + assignment.save() + + kwargs = { + 'editor_assignment': assignment, + 'request': request, + 'email_data': form.as_dataclass(), + 'skip': skip, + } + event_logic.Events.raise_event( + event_logic.Events.ON_EDITOR_REQUEST_WITHDRAWL, + **kwargs, + ) + + messages.add_message(request, messages.SUCCESS, 'Editor Assignment withdrawn') + return redirect(reverse( + 'review_unassigned_article', + kwargs={'article_id': assignment.article.pk}, + )) + + template = 'review/withdraw_editor_assignment.html' + context = { + 'article': assignment.article, + 'assignment': assignment, + 'form': form, + } + + return render(request, template, context) + + +@senior_editor_user_required +def delete_editor_assignment_request(request, assignment_id): + """ + Delete an editor assignment request + :param request: the request object + :param assignment_id: the assignment ID to handle + :return: a context for a Django template + """ + + assignment = get_object_or_404( + models.EditorAssignmentRequest, id=assignment_id + ) + + assignment.delete() + + util_models.LogEntry.add_entry( + types='EditorialAction', + description='Editor {0} unrequested from article {1}' + ''.format(assignment.editor.full_name(), assignment.article.id), + level='Info', + request=request, + target=assignment.article, + ) + + return redirect(reverse( + 'review_unassigned_article', kwargs={'article_id': assignment.article.id} + )) + + @reviewer_user_for_assignment_required def suggest_reviewers(request, assignment_id): """ @@ -868,6 +1284,29 @@ def review_requests(request): return render(request, template, context) +@any_editor_user_required +def editor_assignment_requests(request): + """ + A list of editor assignment requests for the current user + :param request: the request object + :return: a context for a Django template + """ + new_requests = models.EditorAssignmentRequest.objects.filter( + Q(is_complete=False) & + Q(editor=request.user) & + Q(article__stage__in=submission_models.EDITOR_REVIEW_STAGES) & + Q(date_accepted__isnull=True), + article__journal=request.journal + ).select_related('article') + + template = 'review/editor_assignment_requests.html' + context = { + 'new_requests': new_requests, + } + + return render(request, template, context) + + @reviewer_user_for_assignment_required def do_review(request, assignment_id): """ @@ -1416,6 +1855,67 @@ def edit_review_answer(request, article_id, review_id, answer_id): return render(request, template, context) +@editor_is_not_author +@editor_user_required +def notify_invite_editor(request, article_id, editor_assignment_id): + """ + Allows the editor to send a notification to another invited editor + :param request: HttpRequest object + :param article_id: Articke PK + :param editor_id: EditorAssignmentRequest PK + :return: HttpResponse or HttpRedirect + """ + article = get_object_or_404(submission_models.Article, pk=article_id) + editor_assignment_request = get_object_or_404(models.EditorAssignmentRequest, pk=editor_assignment_id) + + email_context = logic.get_editor_notification_context( + request, article, request.user, editor_assignment_request) + + form = core_forms.SettingEmailForm( + setting_name="editor_assignment_request", + email_context=email_context, + request=request, + ) + + if request.POST: + skip = request.POST.get("skip") + form = core_forms.SettingEmailForm( + request.POST, request.FILES, + setting_name="editor_assignment_request", + email_context=email_context, + request=request, + ) + + if form.is_valid() or skip: + kwargs = { + 'email_data': form.as_dataclass(), + 'editor_assignment': editor_assignment_request, + 'request': request, + 'skip': skip, + } + + event_logic.Events.raise_event( + event_logic.Events.ON_EDITOR_REQUESTED_NOTIFICATION, **kwargs) + + editor_assignment_request.date_requested = timezone.now() + editor_assignment_request.save() + + return redirect(reverse( + 'review_unassigned_article', + kwargs={'article_id': article.pk}, + )) + + template = 'review/notify_invite_editor.html' + context = { + 'article': article, + 'editor': editor_assignment_request, + 'form': form, + 'assignment': editor_assignment_request, + } + + return render(request, template, context) + + @editor_is_not_author @article_decision_not_made @editor_user_required diff --git a/src/security/decorators.py b/src/security/decorators.py index 7b53afee47..1e89a5addc 100755 --- a/src/security/decorators.py +++ b/src/security/decorators.py @@ -587,6 +587,35 @@ def wrapper(request, *args, **kwargs): return wrapper +def editor_user_for_assignment_request_required(func): + """ This decorator checks that a user is an editor, or + that the user is a section editor assigned to the article in the url. + :param func: the function to callback from the decorator + :return: either the function call or raises an Http404 + """ + + @base_check_required + def wrapper(request, *args, **kwargs): + + assignment_id = kwargs.get('assignment_id', None) + + if request.user.is_editor(request) or request.user.is_staff or request.user.is_journal_manager(request.journal): + return func(request, *args, **kwargs) + + elif request.user.is_section_editor(request) and assignment_id: + assignment = get_object_or_404(review_models.EditorAssignmentRequest, pk=assignment_id) + editor_assignment_requests = [assign['editor'] for assign in assignment.article.requested_editors()] + if request.user in editor_assignment_requests: + return func(request, *args, **kwargs) + else: + deny_access(request, "You are not a section editor for this article") + + else: + deny_access(request) + + return wrapper + + def user_has_completed_review_for_article(func): """ Checks that the current user has completed a review for the current diff --git a/src/submission/models.py b/src/submission/models.py index 9a544f929c..5e40c1d0e4 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -29,6 +29,7 @@ from django.utils.translation import gettext_lazy as _ from django.template import Context, Template from django.template.loader import render_to_string +from django.db.models import Q from django.db.models.signals import pre_delete, m2m_changed from django.dispatch import receiver from django.core import exceptions @@ -267,6 +268,12 @@ def get_jats_article_types(): STAGE_ACCEPTED, } +EDITOR_REVIEW_STAGES = { + STAGE_UNASSIGNED, + STAGE_ASSIGNED, + STAGE_UNDER_REVIEW, +} + # Stages used to determine if a review assignment is open REVIEW_ACCESSIBLE_STAGES = { STAGE_ASSIGNED, @@ -1341,6 +1348,10 @@ def editors(self): return [{'editor': assignment.editor, 'editor_type': assignment.editor_type, 'assignment': assignment} for assignment in self.editorassignment_set.all()] + def senior_editors(self): + return [{'editor': assignment.editor, 'editor_type': assignment.editor_type, 'assignment': assignment} for + assignment in self.editorassignment_set.filter(editor_type='editor')] + def section_editors(self, emails=False): editors = [assignment.editor for assignment in self.editorassignment_set.filter(editor_type='section-editor')] @@ -1350,6 +1361,16 @@ def section_editors(self, emails=False): else: return editors + def requested_editors(self): + return [{'editor': assignment.editor, 'editor_type': assignment.editor_type, 'assignment': assignment} for + assignment in self.editorassignmentrequest_set.filter( + Q(is_complete=False) & + Q(article__stage__in=EDITOR_REVIEW_STAGES) & + Q(date_accepted__isnull=True) & + Q(date_declined__isnull=True) + ) + ] + def editor_emails(self): return [assignment.editor.email for assignment in self.editorassignment_set.all()] diff --git a/src/templates/admin/core/dashboard.html b/src/templates/admin/core/dashboard.html index d37ad01c73..745c40fd1f 100644 --- a/src/templates/admin/core/dashboard.html +++ b/src/templates/admin/core/dashboard.html @@ -174,6 +174,9 @@

Proofing Corrections

Section Editor

+ {% if journal_settings.general.enable_invite_editor %} + {% include "admin/elements/core/editor_assign_request_alert.html" %} + {% endif %}
diff --git a/src/templates/admin/elements/breadcrumbs/editor_assign_base.html b/src/templates/admin/elements/breadcrumbs/editor_assign_base.html new file mode 100644 index 0000000000..227e0263f6 --- /dev/null +++ b/src/templates/admin/elements/breadcrumbs/editor_assign_base.html @@ -0,0 +1,2 @@ +
  • Editor Assign Requests
  • +{% if editor or assignment %}
  • Editor #{% if review %}{{ review.pk }}{% elif assignment %}{{ assignment.pk }}{% endif %}
  • {% endif %} \ No newline at end of file diff --git a/src/templates/admin/elements/core/editor_assign_request_alert.html b/src/templates/admin/elements/core/editor_assign_request_alert.html new file mode 100644 index 0000000000..34ff358d31 --- /dev/null +++ b/src/templates/admin/elements/core/editor_assign_request_alert.html @@ -0,0 +1,8 @@ + + {% if assigned_articles_for_user_editor_request_count > 0 %} + ❗{{assigned_articles_for_user_editor_request_count}} + {% else %} + View + {% endif %} + Assign Requests + \ No newline at end of file diff --git a/src/templates/admin/elements/forms/group_review.html b/src/templates/admin/elements/forms/group_review.html index 77e9e51ef4..85c10e0573 100644 --- a/src/templates/admin/elements/forms/group_review.html +++ b/src/templates/admin/elements/forms/group_review.html @@ -6,9 +6,12 @@

    General Review Settings

    {% include "admin/elements/forms/field.html" with field=edit_form.review_file_help %} {% include "admin/elements/forms/field.html" with field=edit_form.default_review_form %} {% include "admin/elements/forms/field.html" with field=edit_form.default_review_days %} + {% include "admin/elements/forms/field.html" with field=edit_form.default_editor_assignment_request_days %} {% include "admin/elements/forms/field.html" with field=edit_form.default_review_visibility %} {% include "admin/elements/forms/field.html" with field=edit_form.enable_one_click_access %} {% include "admin/elements/forms/field.html" with field=edit_form.draft_decisions %} + {% include "admin/elements/forms/field.html" with field=edit_form.enable_custom_editor_assignment %} + {% include "admin/elements/forms/field.html" with field=edit_form.enable_invite_editor %} {% include "admin/elements/forms/field.html" with field=edit_form.enable_suggested_reviewers %} diff --git a/src/templates/admin/elements/review/add_editor_table_custom_row.html b/src/templates/admin/elements/review/add_editor_table_custom_row.html new file mode 100644 index 0000000000..6a6ad24100 --- /dev/null +++ b/src/templates/admin/elements/review/add_editor_table_custom_row.html @@ -0,0 +1,15 @@ + + + + + + + + \ No newline at end of file diff --git a/src/templates/admin/elements/review/editor_assignment_list_element.html b/src/templates/admin/elements/review/editor_assignment_list_element.html new file mode 100644 index 0000000000..93e550ab34 --- /dev/null +++ b/src/templates/admin/elements/review/editor_assignment_list_element.html @@ -0,0 +1,33 @@ +
  • +
    +
    +

    {{ assign_request.article.pk }} - {{ assign_request.article.title|truncatechars_html:200|safe }} ({{ assign_request.article.correspondence_author.last_name }}) + + +
    + + A request for editor review has been made.
    + Authors: {{ assign_request.article.author_list }}
    + {% for editor in assign_request.article.editors %}{% if forloop.first %}Editors: {% endif %}{{ editor.editor.full_name }} ( + {% if editor.editor_type == 'section-editor' %}SE {% else %}E + {% endif %}){% if not forloop.last %}, {% endif %}{% endfor %} +
    +

    +
    +
    + +

    + Section: {{ assign_request.article.section.name }} +
    + Stage: {{ assign_request.article.get_stage_display }} + +

    +
    + + Accept Task + Decline Task +
    +
    +
    +
    +
  • \ No newline at end of file diff --git a/src/templates/admin/elements/review/editor_assignment_request_metadata.html b/src/templates/admin/elements/review/editor_assignment_request_metadata.html new file mode 100644 index 0000000000..e42a08a393 --- /dev/null +++ b/src/templates/admin/elements/review/editor_assignment_request_metadata.html @@ -0,0 +1,84 @@ +{% load foundation %} + +
    +
    +
    +

    Editor Assignment Request for: {{ assignment_request.article.safe_title }}

    +
    +
    +
    +
    + + {{ editor.full_name }}{{ editor.email }}{{ editor_type_label }}{{ editor.active_assignments_count|default_if_none:0 }} + {% for interest in editor.interest.all %}{{ interest.name }}{% if not forloop.last %}, {% endif %}{% endfor %} +
    + + + + + + + + + + + + + + + + + + + + + + +
    Article Title
    {{ assignment_request.article.title }}
    SectionLanguageDate Due
    {{ assignment_request.article.section.name }}{{ assignment_request.article.get_language_display }}{{ assignment_request.date_due|date:"Y-m-d" }}
    Abstract
    {{ assignment_request.article.abstract|safe }}
    +
    + +
    +

    Handling Editors

    +
    +
    + {% if assignment_request.article.senior_editors %} + + + + + + + {% for editor in assignment_request.article.senior_editors %} + + + + + + {% endfor %} +
    NameEmailAffiliation
    {{ editor.editor }}  {{ editor.editor.email }}{{ editor.editor.affiliation }}
    + {% endif %} +
    + +
    +

    Authors

    +
    +
    + + + + + + + {% for order in assignment_request.article.articleauthororder_set.all %} + + + + + + {% endfor %} +
    NameEmailAffiliation
    {{ order.author.full_name }}{{ order.author.email }}{{ order.author.affiliation }}
    +
    +
    + + + \ No newline at end of file diff --git a/src/templates/admin/elements/review/editor_request_dropdown.html b/src/templates/admin/elements/review/editor_request_dropdown.html new file mode 100644 index 0000000000..9ac55c2a30 --- /dev/null +++ b/src/templates/admin/elements/review/editor_request_dropdown.html @@ -0,0 +1,26 @@ +
    + +
    + \ No newline at end of file diff --git a/src/templates/admin/review/add_editor_assignment.html b/src/templates/admin/review/add_editor_assignment.html new file mode 100644 index 0000000000..9f09ccf634 --- /dev/null +++ b/src/templates/admin/review/add_editor_assignment.html @@ -0,0 +1,173 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} +{% block title %}Add Editor Assignment{% endblock title %} +{% block title-section %}Add Editor Assignment{% endblock %} +{% block title-sub %}#{{ article.pk }} / {{ article.correspondence_author.last_name }} / {{ article.safe_title }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/unassigned_base.html" %} + {% if article %}
  • {{ article.safe_title }}
  • {% endif %} +
  • Add Editor Assignment
  • +{% endblock breadcrumbs %} + +{% block body %} +
    +
    + {% include "elements/forms/errors.html" with form=form %} + {% csrf_token %} +
    + +
    +
    +

      + {% blocktrans %} + You can select an editor using the radio + buttons in the first column. + {% endblocktrans %} + {% if journal_settings.general.enable_invite_editor %} + {% blocktrans %} + If you want to invite + an editor you must complete the + section under Set Options. + {% endblocktrans %} + {% endif %} + {% blocktrans %} + If you cannot find the editor you want in + this list you can use Enroll Existing User to + search the database and give users the Editor + role, or Add New Editor to create a new + account for an editor (this process is silent, + they will not receive an account creation + email). + {% endblocktrans %} +

    +
    + + + + + + + + + + + + + + + {% for editor in editors %} + {% include "admin/elements/review/add_editor_table_custom_row.html" with editor_type_label='Editor' %} + {% endfor %} + {% for editor in section_editors %} + {% include "admin/elements/review/add_editor_table_custom_row.html" with editor_type_label='Section Editor' %} + {% endfor %} + {% if not editors and not section_editors %} + + + + + + + {% endif %} + +
    SelectNameEmail AddressTypeActive AssignmentsInterests
    No suitable editors.
    +
    +
    +
    + +   +
    +
    + {% if journal_settings.general.enable_invite_editor %} +
    +
    +

    Set Options

    +
    +
    +
    {{ form.date_due|foundation }}
    +
    + +   +
    +
    +
    + {% endif %} +
    +
       + + + + {% if journal_settings.general.enable_one_click_access %} +
    +
    +
    +

     Add New Editor

    +
    +
    + +
    +

    This form allows you to quickly create a new editor without having to input a full user's data.

    +
    + {% include "elements/forms/errors.html" with form=new_editor_form %} + {% csrf_token %} + {{ new_editor_form|foundation }} + +
    +
    +
    +
    +
    + {% endif %} + + {% if form.modal %} + {% include "admin/elements/confirm_modal.html" with modal=form.modal form_id="editor_assignment_form" %} + {% endif %} + +{% endblock body %} + +{% block js %} + {% include "elements/datatables.html" with target="#editors" %} + {% if form.modal %} + {% include "admin/elements/open_modal.html" with target=form.modal.id %} + {% endif %} + {% include "elements/datatables.html" with target="#enrolluser" %} + + +{% endblock js %} \ No newline at end of file diff --git a/src/templates/admin/review/edit_editor_assignment.html b/src/templates/admin/review/edit_editor_assignment.html new file mode 100644 index 0000000000..5c98872800 --- /dev/null +++ b/src/templates/admin/review/edit_editor_assignment.html @@ -0,0 +1,41 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} + +{% block title %}Edit Editor Assignment Request{% endblock title %} +{% block title-section %}Edit Editor Assignment Request{% endblock %} +{% block title-sub %}#{{ article.pk }} / {{ article.correspondence_author.last_name }} / {{ article.safe_title }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/unassigned_base.html" %} + {% if article %}
  • {{ article.safe_title }}
  • {% endif %} +
  • Edit Editor Assignment Request
  • +{% endblock breadcrumbs %} + +{% block body %} +
    +
    +
    +

    Set Options

    +
    +
    +

    Once the request is accepted you can no longer change the review type or the form.

    +
    +
    + {% csrf_token %} +
    +
    {{ form.date_due|foundation }}
    +
    +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    +
    +{% endblock body %} \ No newline at end of file diff --git a/src/templates/admin/review/editor_assignment_decline.html b/src/templates/admin/review/editor_assignment_decline.html new file mode 100644 index 0000000000..4d597c0658 --- /dev/null +++ b/src/templates/admin/review/editor_assignment_decline.html @@ -0,0 +1,20 @@ +{% extends "admin/core/base.html" %} + +{% block title %}Decline to Editor Assignment Request{% endblock title %} +{% block title-section %}Decline to Editor Assignment Request{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/editor_assign_base.html" %} +
  • Decline Editor Assignment Request
  • +{% endblock breadcrumbs %} + +{% block body %} +
    +
    +
    +

    Thank you for letting us know that you are unable to participate in the editorial process for the article at this time. +

    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/src/templates/admin/review/editor_assignment_requests.html b/src/templates/admin/review/editor_assignment_requests.html new file mode 100644 index 0000000000..21c6300fab --- /dev/null +++ b/src/templates/admin/review/editor_assignment_requests.html @@ -0,0 +1,45 @@ +{% extends "admin/core/base.html" %} + +{% block title %}Editor Assign Requests{% endblock title %} +{% block title-section %}Editor Assign Requests{% endblock %} + +{% block breadcrumbs %} +{{ block.super }} +{% include "elements/breadcrumbs/editor_assign_base.html" %} +{% endblock breadcrumbs %} + +{% load static %} +{% load securitytags %} + +{% is_editor as editor %} + +{% block body %} + +
    + +
    + {% include "admin/elements/no_stage.html" %} +
    +
    +
    +

    Assignment Requests List

    +
    +
    +
      + {% for assign_request in new_requests %} + {% include "elements/review/editor_assignment_list_element.html" %} + {% empty %} + No Requests + {% endfor %} +
    +
    +
    +
    +
    +
    + + {% for assignment_request in new_requests %} + {% include "admin/elements/review/editor_assignment_request_metadata.html" %} + {% endfor %} + +{% endblock %} \ No newline at end of file diff --git a/src/templates/admin/review/notify_invite_editor.html b/src/templates/admin/review/notify_invite_editor.html new file mode 100644 index 0000000000..ebe6040395 --- /dev/null +++ b/src/templates/admin/review/notify_invite_editor.html @@ -0,0 +1,48 @@ +{% extends "admin/core/base.html" %} +{% load static %} +{% load settings %} + + +{% block title %}Invite Editor to Assignment{% endblock title %} +{% block title-section %}Invite Editor to Assignment{% endblock %} +{% block title-sub %}#{{ article.pk }} / {{ article.correspondence_author.last_name }} / {{ article.safe_title }}{% endblock %} + +{% block css %} + +{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/review_base.html" %} +
  • Send Editor Assignment Notification
  • +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +

    3. Notify the Editor

    +
    +
    +

    You can send a message to the editor or skip it.

    +
    +
    +

    To {{ review.editor.full_name }}

    +
    From {{ request.user.full_name }}
    +
    +
    + {% include "admin/elements/email_form.html" with form=form skip=1 %} +
    +
    +
    + + +{% endblock body %} + +{% block js %} + {{ block.super}} + + {{ form.media.js }} + +{% endblock js %} \ No newline at end of file diff --git a/src/templates/admin/review/notify_remind_editor.html b/src/templates/admin/review/notify_remind_editor.html new file mode 100644 index 0000000000..0b5c5498b9 --- /dev/null +++ b/src/templates/admin/review/notify_remind_editor.html @@ -0,0 +1,49 @@ +{% extends "admin/core/base.html" %} +{% load static %} +{% load settings %} + + +{% block title %}Editor Assignment Reminders{% endblock title %} +{% block title-section %}Editor Assignment Reminders{% endblock %} +{% block title-sub %}#{{ article.pk }} / {{ article.correspondence_author.last_name }} / {{ article.safe_title }}{% endblock %} + +{% block css %} + +{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/unassigned_base.html" %} + {% if article %}
  • {{ article.safe_title }}
  • {% endif %} +
  • Send Editor Assignment Reminder
  • +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +

    Send Request Reminder

    +
    +
    +

    As this editor assignment has not been accepted, you can send a reminder to the editor asking them to undertake the request.

    +
    +
    +

    To {{ assignment.editor.full_name }}

    +
    From {{ request.user.full_name }}
    +
    +
    + {% include "admin/elements/email_form.html" with form=form skip=0 %} +
    +
    +
    + + +{% endblock body %} + +{% block js %} + {{ block.super}} + + {{ form.media.js }} + +{% endblock js %} \ No newline at end of file diff --git a/src/templates/admin/review/unassigned_article.html b/src/templates/admin/review/unassigned_article.html index 536cc480d4..946d15b6d6 100644 --- a/src/templates/admin/review/unassigned_article.html +++ b/src/templates/admin/review/unassigned_article.html @@ -210,6 +210,9 @@

    Files

    Editors

    + {% if journal_settings.general.enable_custom_editor_assignment %} + Add Editor + {% endif %}
    @@ -217,6 +220,9 @@

    Editors

    + {% if journal_settings.general.enable_invite_editor %} + + {% endif %} {% for assignment in article.editors %} @@ -224,6 +230,9 @@

    Editors

    + {% if journal_settings.general.enable_invite_editor %} + + {% endif %} @@ -236,6 +245,43 @@

    Editors

    + {% if journal_settings.general.enable_invite_editor and requested_editors %} +
    +
    +

    Editor Requests

    +
    +
    +
    Name Email TypeStatus
    {{ assignment.editor.full_name }} {{ assignment.editor.email }} {{ assignment.editor_type|capfirst }}AssignedRemove
    + + + + + + + + {% for assignment in requested_editors %} + + + + + + + + {% empty %} + + + + {% endfor %} +
    NameEmailTypeDate Due
    + {{ assignment.editor.full_name }}  +   + {{ assignment.editor.email }}{{ assignment.editor_type|capfirst }}{{ assignment.date_due|date:"Y-m-d" }}{% include "admin/elements/review/editor_request_dropdown.html" %}
    No users requested
    +
    +
    + {% endif %} +

    Add Editors

    diff --git a/src/templates/admin/review/withdraw_editor_assignment.html b/src/templates/admin/review/withdraw_editor_assignment.html new file mode 100644 index 0000000000..f46eec8c9d --- /dev/null +++ b/src/templates/admin/review/withdraw_editor_assignment.html @@ -0,0 +1,44 @@ +{% extends "admin/core/base.html" %} +{% load settings %} + +{% block title %}Withdraw Editor Assignment Request{% endblock title %} +{% block title-section %}Withdraw Editor Assignment Request{% endblock %} +{% block title-sub %}#{{ article.pk }} / {{ article.correspondence_author.last_name }} / {{ article.safe_title }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/unassigned_base.html" %} + {% if article %}
  • {{ article.safe_title }}
  • {% endif %} +
  • Withdraw Editor Assignment Request
  • +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +

    You are withdrawing an editor assignment by {{ assignment.editor.full_name }} from {{ article.safe_title }}. It was due + on {{ assignment.date_due }}.

    +

    If you select Skip, the editor will not be notified.

    + +
    +
    +

    To {{ assignment.editor.full_name }}

    +
    From {{ request.user.full_name }}
    +
    + {% url 'review_unassigned_article' article.pk as cancel_url %} + {% include 'admin/elements/email_form.html' with form=form skip=1 cancel_url=cancel_url %} +
    +
    +
    + +
    + +{% endblock body %} + +{% block js %} + {{ block.super}} + + {{ form.media.js }} + +{% endblock js %} \ No newline at end of file diff --git a/src/utils/install/journal_defaults.json b/src/utils/install/journal_defaults.json index 4601c628ea..3d83b3507c 100644 --- a/src/utils/install/journal_defaults.json +++ b/src/utils/install/journal_defaults.json @@ -5132,5 +5132,290 @@ "value": { "default": "" } + }, + { + "group": { + "name": "general" + }, + "setting": { + "description": "If enabled, Editors can be assigned to an article in a custom way.", + "is_translatable": false, + "name": "enable_custom_editor_assignment", + "pretty_name": "Enable Custom Editor Assignment", + "type": "boolean" + }, + "value": { + "default": "" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "general" + }, + "setting": { + "description": "If enabled, Editors can invite other Editors or Section Editors to be assigned to an article.", + "is_translatable": false, + "name": "enable_invite_editor", + "pretty_name": "Enable Invite Editor", + "type": "boolean" + }, + "value": { + "default": "" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "general" + }, + "setting": { + "description": "The default number of days before an editor assignment is due.", + "is_translatable": false, + "name": "default_editor_assignment_request_days", + "pretty_name": "Default Number of Days for Editor Assignment", + "type": "number" + }, + "value": { + "default": "56" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "value": { + "default": "Editor Assignment Request" + }, + "setting": { + "type": "text", + "pretty_name": "Subject Editor Assignment Request", + "is_translatable": true, + "description": "Subject for Email sent to editors to request an editor assignment.", + "name": "subject_editor_assignment_request" + }, + "group": { + "name": "email_subject" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "value": { + "default": "Editor Assignment Acknowledgement" + }, + "setting": { + "type": "text", + "pretty_name": "Editor Assignment Accepted", + "is_translatable": true, + "description": "Subject for Email sent to editors when they agree to be assigned to an article.", + "name": "subject_editor_assignment_accept_acknowledgement" + }, + "group": { + "name": "email_subject" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "value": { + "default": "Editor Assignment Updated" + }, + "setting": { + "type": "text", + "pretty_name": "Editor Assignment Acknowledgement", + "is_translatable": true, + "description": "Subject for Email sent to editors when an editor or section editor accepts or declines an editor asignment request.", + "name": "subject_editor_assignment_acknowledgement" + }, + "group": { + "name": "email_subject" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "value": { + "default": "Editor Assignment Declined" + }, + "setting": { + "type": "text", + "pretty_name": "Editor Assignment Declination Acknowledgement", + "is_translatable": true, + "description": "Subject for Email sent to editors when they decline to be assigned to an article.", + "name": "subject_editor_assignment_decline_acknowledgement" + }, + "group": { + "name": "email_subject" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to editors to request an editor assignment.", + "is_translatable": true, + "name": "editor_assignment_request", + "pretty_name": "Editor Assignment Request", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ editor_assignment.editor.full_name }},

    We are requesting that you undertake an editor assignment of \"{{ article.safe_title }}\" in {{ article.journal.name }}.

    We would be most grateful for your time as the feedback from our editors is of the utmost importance to our editorial decision-making processes.

    You can let us know your decision or decline to undertake the assignment: {{ review_unassigned_url }}

    {{ article_details }}

    Regards,
    {{ request.user.signature|safe }}" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to editors when another editor or section editor accepts or declines an editor assignment request.", + "is_translatable": true, + "name": "editor_assignment_acknowledgement", + "pretty_name": "Editor Assignment Acknowledgement", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ editor_assignment.requesting_editor.full_name }},

    This is a notification that the editor {{ editor_assignment.editor.full_name }} has responded to your editor assignment request for \" #{{ article.pk }}: {{ article.safe_title }}\" in {{ article.journal.name }} and have {{ editor_assignment_decision }} to perform the review. You can view more information on the journal site: {{ review_unassigned_url }}" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to editors when they agree to be assigned in an article.", + "is_translatable": true, + "name": "editor_assignment_accept_acknowledgement", + "pretty_name": "Editor Assignment Acceptance Acknowledgement", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ editor_assignment.editor.full_name }},

    Thank you for agreeing to be assigned in \"{{ article.safe_title }}\" in {{ article.journal.name }}.

    You can now access the manuscript and the review process at: {{ review_unassigned_url }}

    Regards,
    {{ editor_assignment.requesting_editor.signature|safe }}" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to editors when they decline to be assigned in an article.", + "is_translatable": true, + "name": "editor_assignment_decline_acknowledgement", + "pretty_name": "Editor Assignment Decline Acknowledgement", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ editor_assignment.editor.full_name }}, \n\nThank you for letting us know that you are unable to participate in the editorial process of \"{{ article.safe_title }}\" in {{ article.journal.name }}.\n\n We are most grateful for your time.\n\nRegards,\n{{ editor_assignment.requesting_editor.signature|safe }}" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email to remind editors of a new assignment request.", + "is_translatable": true, + "name": "editor_assignment_reminder", + "pretty_name": "Editor Assignment Reminder", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ editor_assignment.editor.full_name }},

    We recently sent you an email requesting if you can participate in the review of \"{{ article.safe_title }}\" in {{ article.journal.name }}.

    We would be most grateful for your time as the feedback from our editors is of the utmost importance to our editorial decision-making processes.

    We would appreciate it if you could let us know your decision or decline to participate in the review process

    Regards,
    {{ request.user.signature|safe }}" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "value": { + "default": "Editor Assignment Request Reminder" + }, + "setting": { + "type": "text", + "pretty_name": "Subject Editor Assignment Reminder", + "is_translatable": true, + "description": "Subject for Email sent to editors to remind an editor assignment request.", + "name": "subject_editor_assignment_reminder" + }, + "group": { + "name": "email_subject" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to editors when an editor assignment request is withdrawn.", + "is_translatable": true, + "name": "editor_assignment_withdrawl", + "pretty_name": "Editor Assignment Withdrawl", + "type": "rich-text" + }, + "value": { + "default": "

    Dear {{ editor_assignment.editor.full_name }},

    We are writing to let you know that the editor assignment request for \"{{ article.safe_title }}\" in {{ article.journal.name }} has been cancelled.

    We thank you for your time but your participation is no longer required.

    Regards,

    {{ request.user.signature|safe }}" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "value": { + "default": "Editor Assignment Request Withdrawn" + }, + "setting": { + "type": "char", + "pretty_name": "Subject Editor Assignment Withdrawl", + "is_translatable": true, + "description": "Subject for Email sent to editors when an editor assignment request is withdrawn.", + "name": "subject_editor_assignment_withdrawl" + }, + "group": { + "name": "email_subject" + }, + "editable_by": [ + "editor", + "journal-manager" + ] } ] diff --git a/src/utils/transactional_emails.py b/src/utils/transactional_emails.py index cddb57e3b7..19389b1638 100644 --- a/src/utils/transactional_emails.py +++ b/src/utils/transactional_emails.py @@ -171,6 +171,41 @@ def send_editor_manually_assigned(**kwargs): notify_helpers.send_slack(request, description, ['slack_editors']) +def send_editor_assignment_requested(**kwargs): + """ + This function is called via the event handling framework and it notifies that a editor has been requested. + It is wired up in core/urls.py. + :param kwargs: a list of kwargs that includes editor_assignment, email_data, skip (boolean) and request + :return: None + """ + email_data = kwargs["email_data"] + editor_assignment = kwargs['editor_assignment'] + article = editor_assignment.article + request = kwargs['request'] + skip = kwargs.get("skip", True) + + description = 'An editor assignment request was added to "{0}" for user {1}'.format( + article.title, + editor_assignment.editor.full_name(), + ) + + log_dict = {'level': 'Info', + 'action_text': description, + 'types': 'Editor Assignment Request', + 'target': article} + + if not skip: + core_email.send_email( + editor_assignment.editor, + email_data, + request, + article=article, + log_dict=log_dict, + ) + + notify_helpers.send_slack(request, description, ['slack_editors']) + + def send_reviewer_requested(**kwargs): """ This function is called via the event handling framework and it notifies that a reviewer has been requested. @@ -205,6 +240,69 @@ def send_reviewer_requested(**kwargs): notify_helpers.send_slack(request, description, ['slack_editors']) + +def send_editor_assignment_reminder(**kwargs): + """ + This function is called via the event handling framework and it reminds that a editor has been requested. + It is wired up in core/urls.py. + :param kwargs: a list of kwargs that includes editor_assignment, email_data, skip (boolean) and request + :return: None + """ + email_data = kwargs["email_data"] + editor_assignment = kwargs['editor_assignment'] + article = editor_assignment.article + request = kwargs['request'] + + description = 'An editor assignment request to "{0}" for user {1} was reminded'.format( + article.title, + editor_assignment.editor.full_name(), + ) + + log_dict = {'level': 'Info', + 'action_text': description, + 'types': 'Editor Assignment Reminder', + 'target': article} + + core_email.send_email( + editor_assignment.editor, + email_data, + request, + article=article, + log_dict=log_dict, + ) + + notify_helpers.send_slack(request, description, ['slack_editors']) + + +def send_editor_assignment_withdrawl(**kwargs): + editor_assignment = kwargs['editor_assignment'] + request = kwargs['request'] + email_data = kwargs['email_data'] + article = editor_assignment.article + skip = kwargs.get('skip', True) + + description = '{0}\'s editor assignment of "{1}" has been withdrawn by {2}'.format( + editor_assignment.editor.full_name(), + editor_assignment.article.title, + request.user.full_name(), + ) + log_dict = { + 'level': 'Info', 'action_text': description, + 'types': 'Editor Assignment Withdrawl', 'target': editor_assignment.article, + } + + if not skip: + core_email.send_email( + editor_assignment.editor, + email_data, + request, + article=article, + log_dict=log_dict, + ) + + notify_helpers.send_slack(request, description, ['slack_editors']) + + def send_reviewer_requested_acknowledgements(**kwargs): """ This function is called via the event handling framework and it notifies that a reviewer has been requested. @@ -413,6 +511,84 @@ def send_reviewer_accepted_or_decline_acknowledgements(**kwargs): ) +def send_editor_assign_accepted_or_decline_acknowledgements(**kwargs): + """ + This function is called via the event handling framework and it notifies that an editor has either accepted or + declined to assign request. It is wired up in core/urls.py. + :param kwargs: a list of kwargs that includes editor_assignment, accepted and request + :return: None + """ + editor_assignment = kwargs['editor_assignment'] + article = editor_assignment.article + request = kwargs['request'] + accepted = kwargs['accepted'] + + description = '{0} {1} to editor request {2}'.format( + editor_assignment.editor.full_name(), + ('accepted' if accepted else 'declined'), + article.title, + ) + + util_models.LogEntry.add_entry( + types='Editor assignment request {0}'.format(('accepted' if accepted else 'declined')), + description=description, + level='Info', + actor=request.user, + target=article, + request=request, + ) + + review_unassigned_url = request.journal.site_url(path=reverse( + 'review_unassigned_article', kwargs={'article_id': article.id} + )) + + context = { + 'article': article, + 'request': request, + 'editor_assignment': editor_assignment, + } + + requested_editor_context = context + requested_editor_context['review_unassigned_url'] = review_unassigned_url + requesting_editor_context = context + requesting_editor_context['review_unassigned_url'] = review_unassigned_url + + # send to slack + notify_helpers.send_slack(request, description, ['slack_editors']) + + # send to requested editor + if accepted: + context["editor_assignment_decision"] = _("accepted") + notify_helpers.send_email_with_body_from_setting_template( + request, + 'editor_assignment_accept_acknowledgement', + 'subject_editor_assignment_accept_acknowledgement', + editor_assignment.editor.email, + requested_editor_context, + ) + + else: + context["editor_assignment_decision"] = _("declined") + notify_helpers.send_email_with_body_from_setting_template( + request, + 'editor_assignment_decline_acknowledgement', + 'subject_editor_assignment_decline_acknowledgement', + editor_assignment.editor.email, + requested_editor_context, + ) + + # send to requesting editor + requesting_editors = get_assignment_request_editors(editor_assignment) + for editor in requesting_editors: + notify_helpers.send_email_with_body_from_setting_template( + request, + 'editor_assignment_acknowledgement', + 'subject_editor_assignment_acknowledgement', + editor.email, + requesting_editor_context, + ) + + def send_submission_acknowledgement(**kwargs): """ This function is called via the event handling framework and it @@ -1719,6 +1895,28 @@ def get_assignment_editors(assignment): return editors + +def get_assignment_request_editors(assignment_request): + """ Get requesting editors relevant to a editor assignment + This is a helper function to retrieve the editors that should be + notified of changes in a editor assignment request. + It exists to handle edge-cases where anassignment might not have an editor + assigned (e.g.: migrated submissions from another system) + :param assignment: an instance of ReviewAssignment or RevisionRequest + :return: A list of Account objects + """ + article = assignment_request.article + if assignment_request.requesting_editor: + requesting_editors = [assignment_request.editor] + elif article.editorassignmentrequest_set.exists(): + # Try article assignment + requesting_editors = [ass.requesting_editor for ass in article.editorassignmentrequest_set.all()] + else: + # Fallback to all editors + requesting_editors = [e for e in assignment_request.article.journal.editors()] + return requesting_editors + + def send_draft_decision_declined(**kwargs): request = kwargs.get('request') article = kwargs.get('article')