From b885d461552e49b89ca02821c72f360b271511ae Mon Sep 17 00:00:00 2001 From: George Katsikas <giorgakis.katsikas@gmail.com> Date: Mon, 4 Mar 2024 16:14:00 +0100 Subject: [PATCH] make various improvements to referee invitations fixed #191 --- scipost_django/profiles/forms.py | 1 - scipost_django/submissions/forms/__init__.py | 83 +++++++--- .../_hx_select_referee_table_row.html | 52 +++--- .../templates/submissions/select_referee.html | 12 +- scipost_django/submissions/urls/__init__.py | 7 +- scipost_django/submissions/views/__init__.py | 150 +++++++++++++++++- 6 files changed, 250 insertions(+), 55 deletions(-) diff --git a/scipost_django/profiles/forms.py b/scipost_django/profiles/forms.py index a464b79b8..1a461b96e 100644 --- a/scipost_django/profiles/forms.py +++ b/scipost_django/profiles/forms.py @@ -48,7 +48,6 @@ class ProfileForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields["email"].initial = self.instance.email self.fields["instance_from_type"].widget = forms.HiddenInput() self.fields["instance_pk"].widget = forms.HiddenInput() diff --git a/scipost_django/submissions/forms/__init__.py b/scipost_django/submissions/forms/__init__.py index e192349e5..12b8839fc 100644 --- a/scipost_django/submissions/forms/__init__.py +++ b/scipost_django/submissions/forms/__init__.py @@ -12,7 +12,15 @@ import datetime from django import forms from django.conf import settings from django.db import transaction -from django.db.models import Q, Count, Exists, OuterRef, Subquery, Value +from django.db.models import ( + Q, + Count, + Exists, + OuterRef, + Value, + BooleanField, + ExpressionWrapper, +) from django.db.models.functions import Concat from django.shortcuts import get_object_or_404 from django.forms.formsets import ORDERING_FIELD_NAME @@ -2255,10 +2263,12 @@ class InviteRefereeSearchFrom(forms.Form): show_unavailable = forms.BooleanField( required=False, + initial=True, label="Show unavailable", ) show_with_CI = forms.BooleanField( required=False, + initial=True, label="Include those with competing interests", ) show_email_unknown = forms.BooleanField( @@ -2389,6 +2399,34 @@ class InviteRefereeSearchFrom(forms.Form): """ self.save_fields_to_session() + profiles = ( + Profile.objects.all() + .annot_has_competing_interests_with_submission_authors(self.submission) + .annotate( + is_unavailable=Exists( + UnavailabilityPeriod.objects.today().filter( + contributor=OuterRef("contributor") + ) + ) + ) + .annotate( + last_name_matches=Exists( + Submission.objects.filter( + id=self.submission.id, + author_list__unaccent__icontains=OuterRef("last_name"), + ) + ) + ) + .annotate( + has_accepted_previous_invitation=Exists( + RefereeInvitation.objects.filter( + referee=OuterRef("contributor"), + submission__thread_hash=self.submission.thread_hash, + accepted=True, + ).exclude(submission=self.submission) + ) + ) + ) if text := self.cleaned_data.get("text"): profiles = profiles.search(text) @@ -2400,22 +2438,10 @@ class InviteRefereeSearchFrom(forms.Form): # Filter to only those without competing interests, unless the option is selected if not self.cleaned_data.get("show_with_CI"): - profiles = ( - profiles.without_competing_interests_against_submission_authors_of( - self.submission - ) - ) + profiles = profiles.exclude(has_any_competing_interest_with_submission=True) # Filter to only those available, unless the option is selected if not self.cleaned_data.get("show_unavailable"): - current_unavailability_periods = Subquery( - UnavailabilityPeriod.objects.today().filter( - contributor=OuterRef("contributor") - ) - ) - profiles = profiles.annotate( - is_unavailable=Exists(current_unavailability_periods) - ).exclude(is_unavailable=True) - + profiles = profiles.exclude(is_unavailable=True) # Exclude those without email, if the option is selected if not self.cleaned_data.get("show_email_unknown"): profiles = profiles.exclude(emails__isnull=True) @@ -2440,6 +2466,21 @@ class InviteRefereeSearchFrom(forms.Form): ] ) + profiles = profiles.annotate( + can_be_sent_invitation=ExpressionWrapper( + Q(emails__isnull=False) + & Q(accepts_refereeing_requests=True) + & ~Q(has_any_competing_interest_with_submission=True) + & (Q(is_unavailable=False) | Q(has_accepted_previous_invitation=True)), + output_field=BooleanField(), + ), + warned_against_invitation=ExpressionWrapper( + (Q(is_submission_author=False) & Q(last_name_matches=True)) + | (Q(is_unavailable=True) & Q(has_accepted_previous_invitation=True)), + output_field=BooleanField(), + ), + ) + return profiles @@ -2452,7 +2493,7 @@ class ConfigureRefereeInvitationForm(forms.Form): has_auto_reminders = forms.ChoiceField( widget=forms.RadioSelect, choices=((True, "Yes"), (False, "No")), - initial=False, + initial=True, required=False, label="Send automatic reminders?", ) @@ -2510,16 +2551,6 @@ class ConfigureRefereeInvitationForm(forms.Form): ) ) - def clean(self): - if ( - contributor := getattr(self.profile, "contributor", None) - ) and not contributor.is_currently_available: - self.add_error( - None, - "This Contributor is marked as currently unavailable. " - "Please cancel and select another referee.", - ) - class ConsiderRefereeInvitationForm(forms.Form): accept = forms.ChoiceField( diff --git a/scipost_django/submissions/templates/submissions/_hx_select_referee_table_row.html b/scipost_django/submissions/templates/submissions/_hx_select_referee_table_row.html index e55ec8164..2f6d097ce 100644 --- a/scipost_django/submissions/templates/submissions/_hx_select_referee_table_row.html +++ b/scipost_django/submissions/templates/submissions/_hx_select_referee_table_row.html @@ -38,31 +38,47 @@ </td> <td style="min-width:330px;"> + <div class="d-flex"> + <button id="ref-inv-{{ profile.id }}-send-btn" + type="button" + class="btn btn-sm btn-light me-2" + hx-get="{% url 'submissions:_hx_add_referee_profile_email' profile_id=profile.id %}" + hx-target="closest tr" + hx-swap="afterend">Add Email</button> + + <button id="ref-inv-{{ profile.id }}-send-btn" type="button" class="me-2 btn btn-sm btn-{% if profile.warned_against_invitation %}warning{% else %}light{% endif %}" + {% if profile.warned_against_invitation %}hx-confirm="Do you want to send an invitation to this referee despite the warning?"{% endif %} + hx-get="{% url 'submissions:_hx_configure_refereeing_invitation' identifier_w_vn_nr=submission.preprint.identifier_w_vn_nr profile_id=profile.id %}" hx-target="closest tr" + {% if not profile.can_be_sent_invitation %}disabled{% endif %} + hx-swap="afterend">Configure</button> + <button id="ref-inv-{{ profile.id }}-send-btn" type="button" class="ms-auto btn btn-sm btn-{% if profile.warned_against_invitation %}warning{% else %}primary{% endif %}" + {% if profile.warned_against_invitation %}hx-confirm="Do you want to send an invitation to this referee despite the warning?"{% endif %} + hx-get="{% url 'submissions:_hx_quick_invite_referee' identifier_w_vn_nr=submission.preprint.identifier_w_vn_nr profile_id=profile.id %}" hx-target="closest td" + {% if not profile.can_be_sent_invitation %}disabled{% endif %} + hx-swap="outerHTML">Quick Send</button> + </div> {% if not profile.accepts_refereeing_requests %} - <span class="text-danger">This person does not accept refereeing requests</span> - {% elif not profile.emails.all %} - <div class="text-danger">Cannot send an invitation without an email.</div> - {% elif profile.contributor and not profile.contributor.is_currently_available %} - <div class="text-danger"> + <div class="text-danger">This person does not accept refereeing requests</div> + {% endif %} + + {% if profile.has_submission_competing_interests %} + <div class="text-danger">This person has a competing interest with the submission</div> + {% endif %} + + {% if profile.is_submission_author %} + <div class="text-danger">This person is an author of the submission</div> + {% elif profile.last_name_matches %} + <div class="text-warning">This person could be an author of the submission (last name matches)</div> + {% endif %} + + {% if profile.contributor and not profile.contributor.is_currently_available %} + <div class="text-warning"> This person is not currently available, but will be after <time datetime="{{ profile.contributor.available_again_after_date }}"> {{ profile.contributor.available_again_after_date }} </time> </div> - {% else %} - <button id="ref-inv-{{ profile.id }}-send-btn" - type="button" - class="btn btn-sm btn-light" - hx-get="{% url 'submissions:_hx_configure_refereeing_invitation' identifier_w_vn_nr=submission.preprint.identifier_w_vn_nr profile_id=profile.id %}" - hx-target="closest tr" - hx-swap="afterend">Select</button> - <button id="ref-inv-{{ profile.id }}-send-btn" - type="button" - class="btn btn-sm btn-light" - hx-get="{% url 'submissions:_hx_add_referee_profile_email' profile_id=profile.id %}" - hx-target="closest tr" - hx-swap="afterend">Add Email</button> {% endif %} </td> diff --git a/scipost_django/submissions/templates/submissions/select_referee.html b/scipost_django/submissions/templates/submissions/select_referee.html index 178cd4af6..d08b075a0 100644 --- a/scipost_django/submissions/templates/submissions/select_referee.html +++ b/scipost_django/submissions/templates/submissions/select_referee.html @@ -45,7 +45,17 @@ </div> </div> - <h2 class="highlight">Invite an additional Referee</h2> + <h2 class="highlight">Invite a Referee</h2> + + <p> + You can invite a referee to review this submission by selecting a referee from the list below. If the referee is not in the list, you can add them to our database by filling the form at the bottom of this page. + There are three actions you can perform on a referee: + <ul> + <li><strong>Add</strong> an <strong>Email</strong> to the referee's profile.</li> + <li><strong>Configure</strong> the invitation by selecting an alternative email address, whether auto-reminders are sent, and the exact email content.</li> + <li><strong>Quick Send</strong> an invitation to the primary email, with auto-reminders, and the default email content.</li> + </ul> + </p> {% if workdays_left_to_report < 15 %} <div class="mb-3 p-3 border border-danger border-2"> diff --git a/scipost_django/submissions/urls/__init__.py b/scipost_django/submissions/urls/__init__.py index 41a68e189..78d80a3b9 100644 --- a/scipost_django/submissions/urls/__init__.py +++ b/scipost_django/submissions/urls/__init__.py @@ -319,10 +319,15 @@ urlpatterns = [ name="add_referee_profile", ), path( - "invite_referee/<identifier:identifier_w_vn_nr>/<int:profile_id>/<str:profile_email>/auto_remind/<str:auto_reminders_allowed>", + "invite_referee/<identifier:identifier_w_vn_nr>/<int:profile_id>/custom/<str:profile_email>/auto_remind_<str:auto_reminders_allowed>", views.invite_referee, name="invite_referee", ), + path( + "invite_referee/<identifier:identifier_w_vn_nr>/<int:profile_id>/quick", + views._hx_quick_invite_referee, + name="_hx_quick_invite_referee", + ), path( "refereeing/_hx_configure_invitation/<identifier:identifier_w_vn_nr>/<int:profile_id>", views._hx_configure_refereeing_invitation, diff --git a/scipost_django/submissions/views/__init__.py b/scipost_django/submissions/views/__init__.py index e3d1855c6..98ca6a159 100644 --- a/scipost_django/submissions/views/__init__.py +++ b/scipost_django/submissions/views/__init__.py @@ -35,7 +35,11 @@ from django.views.generic.list import ListView from dal import autocomplete -from scipost.permissions import permission_required_htmx +from scipost.permissions import ( + HTMXPermissionsDenied, + HTMXResponse, + permission_required_htmx, +) from ..constants import ( STATUS_VETTED, @@ -1352,9 +1356,22 @@ def invite_referee( profile = get_object_or_404(Profile, pk=profile_id) auto_reminders_allowed = auto_reminders_allowed == "True" + # Guard against profiles who don't want to referee + if not profile.accepts_refereeing_requests: + messages.error( + request, + "This person has indicated that they do not want to be invited to referee.", + ) + return redirect( + reverse( + "submissions:editorial_page", + kwargs={"identifier_w_vn_nr": identifier_w_vn_nr}, + ) + ) + # Guard against profiles with competing interests if not ( - Profile.objects.filter(profile=profile) + Profile.objects.filter(pk=profile.pk) .without_competing_interests_against_submission_authors_of(submission) .exists() ): @@ -1398,13 +1415,22 @@ def invite_referee( referee_invitation.save() registration_invitation = None + has_agreed_to_previous_invitation = RefereeInvitation.objects.filter( + profile=profile, submission__thread_hash=submission.thread_hash, accepted=True + ).exists() + if contributor: - if not profile.contributor.is_currently_available: - errormessage = ( - "This Contributor is marked as currently unavailable. " - "Please go back and select another referee." + if ( + not contributor.is_currently_available + and not has_agreed_to_previous_invitation + ): + error_message = ( + "This referee is not currently available, " + "and has not accepted a previous invitation for this submission." + ) + return render( + request, "scipost/error.html", {"errormessage": error_message} ) - return render(request, "scipost/error.html", {"errormessage": errormessage}) mail_request = MailEditorSubview( request, @@ -1443,7 +1469,6 @@ def invite_referee( submission.add_event_for_author("A referee has been invited.") submission.add_event_for_eic("Referee %s has been invited." % profile.last_name) messages.success(request, "Invitation sent") - mail_request.send_mail() return redirect( reverse( "submissions:editorial_page", @@ -1454,6 +1479,115 @@ def invite_referee( return mail_request.interrupt() +@transaction.atomic +def _hx_quick_invite_referee(request, identifier_w_vn_nr, profile_id): + submission = get_object_or_404( + Submission, preprint__identifier_w_vn_nr=identifier_w_vn_nr + ) + profile = get_object_or_404(Profile, pk=profile_id) + + # Guard against non-admin and non-EIC users + is_eic_for_submission = submission.editor_in_charge == request.user.contributor + can_oversee_refereeing = request.user.has_perm("scipost.can_oversee_refereeing") + if not (is_eic_for_submission or can_oversee_refereeing): + return HTMXPermissionsDenied("You do not have permission to invite referees.") + + # Guard against profiles who don't want to referee + if not profile.accepts_refereeing_requests: + return HTMXResponse( + "This person has indicated that they do not want to be invited to referee.", + tag="danger", + ) + + # Guard against profiles with competing interests + if not ( + Profile.objects.filter(pk=profile.pk) + .without_competing_interests_against_submission_authors_of(submission) + .exists() + ): + return HTMXResponse( + "This Profile has a competing interest with the authors of the Submission.", + tag="danger", + ) + + contributor = None + if hasattr(profile, "contributor") and profile.contributor: + contributor = profile.contributor + + referee_invitation, created = RefereeInvitation.objects.get_or_create( + profile=profile, + referee=contributor, + submission=submission, + title=profile.title if profile.title else TITLE_DR, + first_name=profile.first_name, + last_name=profile.last_name, + email_address=profile.email, + auto_reminders_allowed=True, + invited_by=request.user.contributor, + ) + + key = "" + if created: + key = get_new_secrets_key() + referee_invitation.invitation_key = key + referee_invitation.save() + + registration_invitation = None + has_agreed_to_previous_invitation = RefereeInvitation.objects.filter( + profile=profile, submission__thread_hash=submission.thread_hash, accepted=True + ).exists() + + if contributor: + if ( + not contributor.is_currently_available + and not has_agreed_to_previous_invitation + ): + return HTMXResponse( + "This referee is not currently available, " + "and has not accepted a previous invitation for this submission.", + tag="danger", + ) + + mail_request = DirectMailUtil( + "referees/invite_contributor_to_referee", + invitation=referee_invitation, + ) + else: # no Contributor, so registration invitation + registration_invitation, reginv_created = ( + RegistrationInvitation.objects.get_or_create( + profile=profile, + title=profile.title if profile.title else TITLE_DR, + first_name=profile.first_name, + last_name=profile.last_name, + email=profile.email, + invitation_type=INVITATION_REFEREEING, + created_by=request.user, + invited_by=request.user, + invitation_key=referee_invitation.invitation_key, + ) + ) + mail_request = DirectMailUtil( + mail_code="referees/invite_unregistered_to_referee", + invitation=referee_invitation, + ) + + mail_request.send_mail() + + referee_invitation.date_invited = timezone.now() + referee_invitation.save() + if registration_invitation: + registration_invitation.status = STATUS_SENT + registration_invitation.key_expires = timezone.now() + datetime.timedelta( + days=365 + ) + registration_invitation.save() + + submission.add_event_for_author("A referee has been invited.") + submission.add_event_for_eic("Referee %s has been invited." % profile.last_name) + + return HTMXResponse("Invitation sent", tag="success") + + @login_required @fellowship_or_admin_required() def set_refinv_auto_reminder(request, invitation_id, auto_reminders): -- GitLab