From 3215a2137b0bed8fd748195a2241fc3a4c2d3970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20S=C3=A1nchez?= Date: Tue, 3 Dec 2024 05:00:37 -0300 Subject: [PATCH 1/8] add enable_custom_editor_assignment option --- src/core/logic.py | 4 ++++ .../admin/elements/forms/group_review.html | 1 + src/utils/install/journal_defaults.json | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/src/core/logic.py b/src/core/logic.py index 98268f3ae..2ab3d4924 100755 --- a/src/core/logic.py +++ b/src/core/logic.py @@ -424,6 +424,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/templates/admin/elements/forms/group_review.html b/src/templates/admin/elements/forms/group_review.html index 77e9e51ef..c179b95ac 100644 --- a/src/templates/admin/elements/forms/group_review.html +++ b/src/templates/admin/elements/forms/group_review.html @@ -9,6 +9,7 @@

General Review Settings

{% 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_suggested_reviewers %} diff --git a/src/utils/install/journal_defaults.json b/src/utils/install/journal_defaults.json index 4601c628e..c5ab9beb8 100644 --- a/src/utils/install/journal_defaults.json +++ b/src/utils/install/journal_defaults.json @@ -5132,5 +5132,24 @@ "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" + ] } ] From 6f0652926e8460877fe5b6fa629b2a4048caf690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20S=C3=A1nchez?= Date: Tue, 3 Dec 2024 05:56:46 -0300 Subject: [PATCH 2/8] add new custom editor assignment view --- src/review/forms.py | 38 ++++++ src/review/logic.py | 77 +++++++++++ src/review/urls.py | 1 + src/review/views.py | 99 ++++++++++++++ .../review/add_editor_table_custom_row.html | 15 +++ .../admin/review/add_editor_assignment.html | 122 ++++++++++++++++++ .../admin/review/unassigned_article.html | 3 + 7 files changed, 355 insertions(+) create mode 100644 src/templates/admin/elements/review/add_editor_table_custom_row.html create mode 100644 src/templates/admin/review/add_editor_assignment.html diff --git a/src/review/forms.py b/src/review/forms.py index 344886edd..339389b4a 100755 --- a/src/review/forms.py +++ b/src/review/forms.py @@ -132,6 +132,44 @@ 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') + + super(EditorAssignmentForm, self).__init__(*args, **kwargs) + + if self.editors: + self.fields['editor'].queryset = self.editors + + def clean(self): + cleaned_data = super().clean() + return cleaned_data + + def save(self, commit=True, request=None): + editor = self.cleaned_data['editor'] + + if request: + 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 BulkReviewAssignmentForm(forms.ModelForm): template = forms.CharField( widget=TinyMCE, diff --git a/src/review/logic.py b/src/review/logic.py index b9c1e32c5..dc33eb68e 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,71 @@ 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 + """ + + editors = article.editorassignment_set.all() + 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', @@ -626,6 +694,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/urls.py b/src/review/urls.py index 692d4569c..ef2e93070 100755 --- a/src/review/urls.py +++ b/src/review/urls.py @@ -32,6 +32,7 @@ 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+)/crosscheck/$', views.view_ithenticate_report, name='review_crosscheck'), re_path(r'^article/(?P\d+)/move/(?Paccept|decline|undecline)/$', views.review_decision, name='review_decision'), diff --git a/src/review/views.py b/src/review/views.py index aa13351be..c551407cb 100755 --- a/src/review/views.py +++ b/src/review/views.py @@ -229,6 +229,105 @@ 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'} + + 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.""" 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 000000000..6a6ad2410 --- /dev/null +++ b/src/templates/admin/elements/review/add_editor_table_custom_row.html @@ -0,0 +1,15 @@ + + + + + {{ 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 %} + + \ 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 000000000..7252e8963 --- /dev/null +++ b/src/templates/admin/review/add_editor_assignment.html @@ -0,0 +1,122 @@ +{% 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 %} + {% 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_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/unassigned_article.html b/src/templates/admin/review/unassigned_article.html index 536cc480d..9218dada0 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 %}
    From 4c76fd62dedc2d507b8c35cf5b4f7fde55d0378e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20S=C3=A1nchez?= Date: Sun, 15 Dec 2024 08:08:31 -0300 Subject: [PATCH 3/8] update custom editor assignment view --- .../admin/review/add_editor_assignment.html | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/templates/admin/review/add_editor_assignment.html b/src/templates/admin/review/add_editor_assignment.html index 7252e8963..f1fa8730f 100644 --- a/src/templates/admin/review/add_editor_assignment.html +++ b/src/templates/admin/review/add_editor_assignment.html @@ -119,4 +119,34 @@

     Add New Editor

    {% 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 From 9ed8480b804d4075b16461b21d29b61fced4e884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20S=C3=A1nchez?= Date: Wed, 4 Dec 2024 05:43:51 -0300 Subject: [PATCH 4/8] add enable_invite_editor option --- src/core/logic.py | 4 ++++ .../admin/elements/forms/group_review.html | 1 + src/utils/install/journal_defaults.json | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/src/core/logic.py b/src/core/logic.py index 2ab3d4924..c64fbc76b 100755 --- a/src/core/logic.py +++ b/src/core/logic.py @@ -363,6 +363,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), diff --git a/src/templates/admin/elements/forms/group_review.html b/src/templates/admin/elements/forms/group_review.html index c179b95ac..eadd51709 100644 --- a/src/templates/admin/elements/forms/group_review.html +++ b/src/templates/admin/elements/forms/group_review.html @@ -10,6 +10,7 @@

    General Review Settings

    {% 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/utils/install/journal_defaults.json b/src/utils/install/journal_defaults.json index c5ab9beb8..fbacee832 100644 --- a/src/utils/install/journal_defaults.json +++ b/src/utils/install/journal_defaults.json @@ -5151,5 +5151,24 @@ "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" + ] } ] From 06ec8bd3d535d979ef6ba6e78f7f858681934dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20S=C3=A1nchez?= Date: Wed, 4 Dec 2024 05:53:47 -0300 Subject: [PATCH 5/8] add EditorAssignmentRequest model --- .../0024_editorassignmentrequest.py | 36 ++++++++++++++++++ src/review/models.py | 37 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 src/review/migrations/0024_editorassignmentrequest.py diff --git a/src/review/migrations/0024_editorassignmentrequest.py b/src/review/migrations/0024_editorassignmentrequest.py new file mode 100644 index 000000000..de25eac19 --- /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 397102c14..c02004f9d 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', From cc42f55c0c072217d3c70713d53951366f6bdef3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20S=C3=A1nchez?= Date: Wed, 4 Dec 2024 06:40:02 -0300 Subject: [PATCH 6/8] add editor assigment request (invite editor) --- src/core/logic.py | 4 + src/events/logic.py | 3 + src/events/registration.py | 2 + src/review/forms.py | 32 +++- src/review/logic.py | 31 +++- src/review/urls.py | 2 + src/review/views.py | 103 ++++++++++- src/submission/models.py | 6 + .../admin/elements/forms/group_review.html | 1 + .../admin/review/add_editor_assignment.html | 21 +++ .../admin/review/notify_invite_editor.html | 48 +++++ .../admin/review/unassigned_article.html | 41 +++++ src/utils/install/journal_defaults.json | 171 ++++++++++++++++++ src/utils/transactional_emails.py | 35 ++++ 14 files changed, 491 insertions(+), 9 deletions(-) create mode 100644 src/templates/admin/review/notify_invite_editor.html diff --git a/src/core/logic.py b/src/core/logic.py index c64fbc76b..acfa714a6 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), diff --git a/src/events/logic.py b/src/events/logic.py index 13dfa72e8..0675dc71c 100755 --- a/src/events/logic.py +++ b/src/events/logic.py @@ -27,6 +27,9 @@ 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' # 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 ea9955351..e0de4a0a4 100755 --- a/src/events/registration.py +++ b/src/events/registration.py @@ -21,6 +21,8 @@ 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_ARTICLE_UNASSIGNED, transactional_emails.send_editor_unassigned_notice) diff --git a/src/review/forms.py b/src/review/forms.py index 339389b4a..a158c133b 100755 --- a/src/review/forms.py +++ b/src/review/forms.py @@ -140,24 +140,48 @@ 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: - editor_assignment = models.EditorAssignment( - article=self.article, - editor=editor, - ) + 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' diff --git a/src/review/logic.py b/src/review/logic.py index dc33eb68e..4dbc61110 100755 --- a/src/review/logic.py +++ b/src/review/logic.py @@ -88,9 +88,15 @@ def get_editors_candidates(article, user=None, editors_to_exclude=None): :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 editors] + 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: @@ -293,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, diff --git a/src/review/urls.py b/src/review/urls.py index ef2e93070..21e824359 100755 --- a/src/review/urls.py +++ b/src/review/urls.py @@ -33,6 +33,8 @@ 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'), diff --git a/src/review/views.py b/src/review/views.py index c551407cb..c5baf024a 100755 --- a/src/review/views.py +++ b/src/review/views.py @@ -140,19 +140,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) @@ -284,6 +303,23 @@ def add_editor_assignment(request, article_id): 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, @@ -313,7 +349,7 @@ def add_editor_assignment(request, article_id): 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})) + return redirect(reverse('review_unassigned_article', kwargs={'article_id': article_id})) template = 'admin/review/add_editor_assignment.html' @@ -1515,6 +1551,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/submission/models.py b/src/submission/models.py index 9a544f929..e4bb3071c 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -267,6 +267,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, diff --git a/src/templates/admin/elements/forms/group_review.html b/src/templates/admin/elements/forms/group_review.html index eadd51709..85c10e057 100644 --- a/src/templates/admin/elements/forms/group_review.html +++ b/src/templates/admin/elements/forms/group_review.html @@ -6,6 +6,7 @@

    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 %} diff --git a/src/templates/admin/review/add_editor_assignment.html b/src/templates/admin/review/add_editor_assignment.html index f1fa8730f..9f09ccf63 100644 --- a/src/templates/admin/review/add_editor_assignment.html +++ b/src/templates/admin/review/add_editor_assignment.html @@ -29,6 +29,13 @@

    Select Editor

    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 @@ -77,6 +84,20 @@

    Select Editor

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

    Set Options

    +
    +
    +
    {{ form.date_due|foundation }}
    +
    + +   +
    +
    +
    + {% endif %}    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 000000000..ebe604039 --- /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/unassigned_article.html b/src/templates/admin/review/unassigned_article.html index 9218dada0..44a00ebf7 100644 --- a/src/templates/admin/review/unassigned_article.html +++ b/src/templates/admin/review/unassigned_article.html @@ -220,6 +220,9 @@

    Editors

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

    Editors

    + {% if journal_settings.general.enable_invite_editor %} + + {% endif %} @@ -239,6 +245,41 @@

    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" }}
    No users requested
    +
    +
    + {% endif %} +

    Add Editors

    diff --git a/src/utils/install/journal_defaults.json b/src/utils/install/journal_defaults.json index fbacee832..1261cbd9b 100644 --- a/src/utils/install/journal_defaults.json +++ b/src/utils/install/journal_defaults.json @@ -5170,5 +5170,176 @@ "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" + ] } ] diff --git a/src/utils/transactional_emails.py b/src/utils/transactional_emails.py index cddb57e3b..debc690af 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. From e061ed994174c74883bf3173236b67a1e6a02972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20S=C3=A1nchez?= Date: Mon, 9 Dec 2024 07:49:49 -0300 Subject: [PATCH 7/8] add management of editor invitation requests from the requester --- src/events/logic.py | 4 + src/events/registration.py | 4 + src/review/forms.py | 13 ++ src/review/urls.py | 4 + src/review/views.py | 197 ++++++++++++++++++ .../review/editor_request_dropdown.html | 26 +++ .../admin/review/edit_editor_assignment.html | 41 ++++ .../admin/review/notify_remind_editor.html | 49 +++++ .../admin/review/unassigned_article.html | 2 + .../review/withdraw_editor_assignment.html | 44 ++++ src/utils/install/journal_defaults.json | 76 +++++++ src/utils/transactional_emails.py | 63 ++++++ 12 files changed, 523 insertions(+) create mode 100644 src/templates/admin/elements/review/editor_request_dropdown.html create mode 100644 src/templates/admin/review/edit_editor_assignment.html create mode 100644 src/templates/admin/review/notify_remind_editor.html create mode 100644 src/templates/admin/review/withdraw_editor_assignment.html diff --git a/src/events/logic.py b/src/events/logic.py index 0675dc71c..887dc1a1c 100755 --- a/src/events/logic.py +++ b/src/events/logic.py @@ -30,6 +30,10 @@ class Events: # 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: 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 e0de4a0a4..c4b6d95fc 100755 --- a/src/events/registration.py +++ b/src/events/registration.py @@ -23,6 +23,10 @@ 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) diff --git a/src/review/forms.py b/src/review/forms.py index a158c133b..64fbe5ac8 100755 --- a/src/review/forms.py +++ b/src/review/forms.py @@ -194,6 +194,19 @@ def save(self, commit=True, request=None): 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/urls.py b/src/review/urls.py index 21e824359..9957832b9 100755 --- a/src/review/urls.py +++ b/src/review/urls.py @@ -104,6 +104,10 @@ views.upload_review_file, name='upload_review_file'), + 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 c5baf024a..dacde349c 100755 --- a/src/review/views.py +++ b/src/review/views.py @@ -917,6 +917,203 @@ def decline_review_request(request, assignment_id): 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): """ 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 000000000..9ac55c2a3 --- /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/edit_editor_assignment.html b/src/templates/admin/review/edit_editor_assignment.html new file mode 100644 index 000000000..5c9887280 --- /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/notify_remind_editor.html b/src/templates/admin/review/notify_remind_editor.html new file mode 100644 index 000000000..0b5c5498b --- /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 44a00ebf7..946d15b6d 100644 --- a/src/templates/admin/review/unassigned_article.html +++ b/src/templates/admin/review/unassigned_article.html @@ -257,6 +257,7 @@

    Editor Requests

    Email Type Date Due + {% for assignment in requested_editors %} @@ -269,6 +270,7 @@

    Editor Requests

    {{ assignment.editor.email }} {{ assignment.editor_type|capfirst }} {{ assignment.date_due|date:"Y-m-d" }} + {% include "admin/elements/review/editor_request_dropdown.html" %} {% empty %} 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 000000000..f46eec8c9 --- /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 1261cbd9b..3d83b3507 100644 --- a/src/utils/install/journal_defaults.json +++ b/src/utils/install/journal_defaults.json @@ -5341,5 +5341,81 @@ "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 debc690af..4ea12ea83 100644 --- a/src/utils/transactional_emails.py +++ b/src/utils/transactional_emails.py @@ -240,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. From 90ac584656d6bcd1ae3e81bcdd61bd8c140fb726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20S=C3=A1nchez?= Date: Tue, 10 Dec 2024 06:35:17 -0300 Subject: [PATCH 8/8] add management of editor invitation requests from the requested --- src/events/logic.py | 4 + src/events/registration.py | 7 +- src/review/urls.py | 3 + src/review/views.py | 109 +++++++++++++++++- src/security/decorators.py | 29 +++++ src/submission/models.py | 15 +++ src/templates/admin/core/dashboard.html | 3 + .../breadcrumbs/editor_assign_base.html | 2 + .../core/editor_assign_request_alert.html | 8 ++ .../editor_assignment_list_element.html | 33 ++++++ .../editor_assignment_request_metadata.html | 84 ++++++++++++++ .../review/editor_assignment_decline.html | 20 ++++ .../review/editor_assignment_requests.html | 45 ++++++++ src/utils/transactional_emails.py | 100 ++++++++++++++++ 14 files changed, 459 insertions(+), 3 deletions(-) create mode 100644 src/templates/admin/elements/breadcrumbs/editor_assign_base.html create mode 100644 src/templates/admin/elements/core/editor_assign_request_alert.html create mode 100644 src/templates/admin/elements/review/editor_assignment_list_element.html create mode 100644 src/templates/admin/elements/review/editor_assignment_request_metadata.html create mode 100644 src/templates/admin/review/editor_assignment_decline.html create mode 100644 src/templates/admin/review/editor_assignment_requests.html diff --git a/src/events/logic.py b/src/events/logic.py index 887dc1a1c..d4128cc5a 100755 --- a/src/events/logic.py +++ b/src/events/logic.py @@ -34,6 +34,10 @@ class Events: # 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 c4b6d95fc..20b22b3be 100755 --- a/src/events/registration.py +++ b/src/events/registration.py @@ -28,8 +28,11 @@ 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/urls.py b/src/review/urls.py index 9957832b9..57165cf4a 100755 --- a/src/review/urls.py +++ b/src/review/urls.py @@ -104,6 +104,9 @@ 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'), diff --git a/src/review/views.py b/src/review/views.py index dacde349c..1678cf2e1 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 @@ -917,6 +918,89 @@ 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): """ @@ -1200,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): """ diff --git a/src/security/decorators.py b/src/security/decorators.py index 7b53afee4..1e89a5add 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 e4bb3071c..5e40c1d0e 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 @@ -1347,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')] @@ -1356,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 d37ad01c7..745c40fd1 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 000000000..227e0263f --- /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 000000000..34ff358d3 --- /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/review/editor_assignment_list_element.html b/src/templates/admin/elements/review/editor_assignment_list_element.html new file mode 100644 index 000000000..93e550ab3 --- /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 000000000..e42a08a39 --- /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 }}

    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + +
    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/review/editor_assignment_decline.html b/src/templates/admin/review/editor_assignment_decline.html new file mode 100644 index 000000000..4d597c065 --- /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 000000000..21c6300fa --- /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/utils/transactional_emails.py b/src/utils/transactional_emails.py index 4ea12ea83..19389b163 100644 --- a/src/utils/transactional_emails.py +++ b/src/utils/transactional_emails.py @@ -511,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 @@ -1817,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')