From 992f65b7113d73dda53becc5e6c1865097202ab0 Mon Sep 17 00:00:00 2001 From: George Katsikas <giorgakis.katsikas@gmail.com> Date: Wed, 17 Jul 2024 15:53:49 +0300 Subject: [PATCH] create referee indication model and form fixes #213 --- scipost_django/submissions/admin.py | 51 ++++++---- scipost_django/submissions/forms/__init__.py | 81 ++++++++++++++++ .../migrations/0156_refereeindication.py | 78 ++++++++++++++++ scipost_django/submissions/models/__init__.py | 2 + .../submissions/models/referee_indication.py | 92 +++++++++++++++++++ .../submissions/models/submission.py | 5 + 6 files changed, 290 insertions(+), 19 deletions(-) create mode 100644 scipost_django/submissions/migrations/0156_refereeindication.py create mode 100644 scipost_django/submissions/models/referee_indication.py diff --git a/scipost_django/submissions/admin.py b/scipost_django/submissions/admin.py index 6786a4143..558d75c0f 100644 --- a/scipost_django/submissions/admin.py +++ b/scipost_django/submissions/admin.py @@ -27,6 +27,7 @@ from submissions.models import ( Qualification, Readiness, PreprintServer, + RefereeIndication, ) from scipost.models import Contributor from colleges.models import Fellowship @@ -46,8 +47,6 @@ class PreprintServerAdmin(admin.ModelAdmin): autocomplete_fields = ["acad_fields"] - - @admin.register(iThenticateReport) class iThenticateReportAdmin(admin.ModelAdmin): list_display = ["doc_id", "to_submission", "status"] @@ -57,8 +56,6 @@ class iThenticateReportAdmin(admin.ModelAdmin): ] - - class InternalPlagiarismAssessmentInline(admin.StackedInline): model = InternalPlagiarismAssessment @@ -211,7 +208,7 @@ class SubmissionAdmin(GuardedModelAdmin): "specialties", "approaches", "proceedings", - "code_metadata" + "code_metadata", ), }, ), @@ -285,8 +282,6 @@ class SubmissionAdmin(GuardedModelAdmin): ) - - @admin.register(EditorialAssignment) class EditorialAssignmentAdmin(admin.ModelAdmin): search_fields = [ @@ -311,8 +306,6 @@ class EditorialAssignmentAdmin(admin.ModelAdmin): ] - - @admin.register(RefereeInvitation) class RefereeInvitationAdmin(admin.ModelAdmin): search_fields = [ @@ -339,8 +332,6 @@ class RefereeInvitationAdmin(admin.ModelAdmin): ] - - @admin.register(Report) class ReportAdmin(admin.ModelAdmin): search_fields = ["author__user__last_name", "submission__title"] @@ -362,16 +353,12 @@ class ReportAdmin(admin.ModelAdmin): ] - - @admin.register(EditorialCommunication) class EditorialCommunicationAdmin(admin.ModelAdmin): search_fields = ["submission__title", "referee__user__last_name", "text"] autocomplete_fields = ["submission", "referee"] - - class AlternativeRecommendationInline(admin.StackedInline): model = AlternativeRecommendation extra = 0 @@ -408,8 +395,6 @@ class EICRecommendationAdmin(admin.ModelAdmin): ] - - @admin.register(EditorialDecision) class EditorialDecisionAdmin(admin.ModelAdmin): search_fields = [ @@ -436,8 +421,6 @@ class EditorialDecisionAdmin(admin.ModelAdmin): ] - - @admin.register(SubmissionEvent) class SubmissionEventAdmin(admin.ModelAdmin): autocomplete_fields = [ @@ -445,3 +428,33 @@ class SubmissionEventAdmin(admin.ModelAdmin): ] +@admin.register(RefereeIndication) +class RefereeIndicationAdmin(admin.ModelAdmin): + search_fields = [ + "submission__title", + "submission__preprint__identifier_w_vn_nr", + "referee__first_name", + "referee__last_name", + "first_name", + "last_name", + "email_address", + ] + list_display = ( + "submission", + "indicated_by", + "indication", + "referee_name", + ) + list_filter = ("indication",) + autocomplete_fields = [ + "submission", + "indicated_by", + "referee", + ] + + def referee_name(self, obj): + return ( + obj.referee.full_name + if obj.referee + else f"{obj.first_name} {obj.last_name}" + ) diff --git a/scipost_django/submissions/forms/__init__.py b/scipost_django/submissions/forms/__init__.py index 52309f3dc..9f59a2977 100644 --- a/scipost_django/submissions/forms/__init__.py +++ b/scipost_django/submissions/forms/__init__.py @@ -72,6 +72,7 @@ from ..models import ( PlagiarismAssessment, iThenticateReport, EditorialCommunication, + RefereeIndication, ) from ..regexes import CHEMRXIV_DOI_PATTERN @@ -3736,3 +3737,83 @@ class iThenticateReportForm(forms.ModelForm): self.add_error(None, msg) return None return data + + +class RefereeIndicationForm(forms.ModelForm): + class Meta: + model = RefereeIndication + exclude = ["submission", "indicated_by"] + + referee = forms.ModelChoiceField( + queryset=Profile.objects.all(), + widget=autocomplete.ModelSelect2(url="/profiles/profile-autocomplete"), + required=False, + help_text="Preferably select a referee from the list. If not found, fill in the other fields.", + ) + reason = forms.CharField( + widget=forms.Textarea(attrs={"rows": 4, "maxlength": 255}), + help_text="Short reason for this indication; <strong>mandatory when advising against</strong>.", + required=False, + ) + + def __init__(self, *args, **kwargs): + self.submission = kwargs.pop("submission") + self.user = kwargs.pop("user") + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + # self.helper.form_tag = False + self.helper.layout = Layout( + Div( + Div( + Div( + Div(Field("indication"), css_class="col-2"), + Div(Field("referee"), css_class="col-10"), + Div(Field("first_name"), css_class="col-6 col-md-2"), + Div(Field("last_name"), css_class="col-6 col-md-2"), + Div(Field("email_address"), css_class="col-6 col-md-4"), + Div(Field("affiliation"), css_class="col-6 col-md-4"), + css_class="row", + ), + css_class="col", + ), + Div(Field("reason"), css_class="col-12 col-xl-3 h-100"), + css_class="row", + ) + ) + + def clean(self): + cleaned_data = super().clean() + + referee_info_fields = [ + cleaned_data.get("first_name"), + cleaned_data.get("last_name"), + cleaned_data.get("email_address"), + ] + + if cleaned_data.get("referee") is None and not all(referee_info_fields): + self.add_error( + None, + "If you don't select a referee, you must provide all the necessary information.", + ) + + if cleaned_data.get("indication") == RefereeIndication.INDICATION_AGAINST: + if reason := cleaned_data.get("reason"): + self.add_error( + "reason", + "You must provide a reason when indicating against a referee.", + ) + elif len(reason) < 10: + self.add_error( + "reason", + "The reason is too short, please provide a more detailed explanation.", + ) + + return cleaned_data + + def save(self): + indication = super().save(commit=False) + indication.submission = self.submission + indication.indicated_by = self.user.profile + indication.save() + return indication diff --git a/scipost_django/submissions/migrations/0156_refereeindication.py b/scipost_django/submissions/migrations/0156_refereeindication.py new file mode 100644 index 000000000..af1b24ab1 --- /dev/null +++ b/scipost_django/submissions/migrations/0156_refereeindication.py @@ -0,0 +1,78 @@ +# Generated by Django 4.2.10 on 2024-06-21 11:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("profiles", "0040_profile_first_name_original_and_more"), + ("submissions", "0155_alter_editorialcommunication_comtype"), + ] + + operations = [ + migrations.CreateModel( + name="RefereeIndication", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "indication", + models.CharField( + choices=[ + ("suggest", "Suggest"), + ("advise_against", "Advise against"), + ], + max_length=256, + ), + ), + ("first_name", models.CharField(blank=True, max_length=64, null=True)), + ("last_name", models.CharField(blank=True, max_length=64, null=True)), + ( + "email_address", + models.EmailField(blank=True, max_length=256, null=True), + ), + ( + "affiliation", + models.CharField(blank=True, max_length=256, null=True), + ), + ("reason", models.TextField(blank=True, null=True)), + ( + "indicated_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="referee_indications_made", + to="profiles.profile", + ), + ), + ( + "referee", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="referee_indications_received", + to="profiles.profile", + ), + ), + ( + "submission", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="referee_indications", + to="submissions.submission", + ), + ), + ], + options={ + "unique_together": {("submission", "referee")}, + }, + ), + ] diff --git a/scipost_django/submissions/models/__init__.py b/scipost_django/submissions/models/__init__.py index 778b3ad83..a6dce4b50 100644 --- a/scipost_django/submissions/models/__init__.py +++ b/scipost_django/submissions/models/__init__.py @@ -34,3 +34,5 @@ from .report import Report from .recommendation import EICRecommendation, AlternativeRecommendation from .decision import EditorialDecision + +from .referee_indication import RefereeIndication diff --git a/scipost_django/submissions/models/referee_indication.py b/scipost_django/submissions/models/referee_indication.py new file mode 100644 index 000000000..5af0c17c9 --- /dev/null +++ b/scipost_django/submissions/models/referee_indication.py @@ -0,0 +1,92 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + +from typing import TYPE_CHECKING, Literal +from django.db import models + +if TYPE_CHECKING: + from .submission import Submission + from ...profiles.models import Profile + + +class RefereeIndication(models.Model): + """ + Indication of a professional scientist to referee a Submission. + + The indication may not always be positive, i.e. to suggest a scientist + but also to suggest *not* to invite a scientist to referee a Submission. + + The indication must refer to either an existing Profile or a + collection of `first_name`, `last_name`, `affiliation` and `email_address` fields. + """ + + INDICATION_SUGGEST = "suggest" + INDICATION_AGAINST = "advise_against" + INDICATION_CHOICES = [ + (INDICATION_SUGGEST, "Suggest"), + (INDICATION_AGAINST, "Advise against"), + ] + + submission = models.ForeignKey["Submission"]( + "submissions.Submission", + on_delete=models.CASCADE, + related_name="referee_indications", + ) + indicated_by = models.ForeignKey["Profile"]( + "profiles.Profile", + related_name="referee_indications_made", + on_delete=models.CASCADE, + ) + indication = models.CharField( + max_length=256, + choices=INDICATION_CHOICES, + ) + + # Preferable to specify an existing Profile if possible + referee = models.ForeignKey["Profile"]( + "profiles.Profile", + related_name="referee_indications_received", + on_delete=models.CASCADE, + null=True, + blank=True, + ) + # if Profile does not exist, their details are stored in the following fields + first_name = models.CharField(max_length=64, blank=True, null=True) + last_name = models.CharField(max_length=64, blank=True, null=True) + email_address = models.EmailField(max_length=256, blank=True, null=True) + affiliation = models.CharField(max_length=256, blank=True, null=True) + + # If the indication is negative, it is best to provide a reason + reason = models.TextField(blank=True, null=True) + + class Meta: + unique_together = ("submission", "referee") + + def __str__(self): + referee_name = ( + self.referee.full_name + if self.referee + else f"{self.first_name} {self.last_name}" + ) + return f"{self.indicated_by.full_name} to {self.get_indication_display().lower()} {referee_name} for {self.submission}" + + @property + def indicated_by_role(self): + """ + Return the role of the Profile that made the indication. + - "editor" if the Profile is the submission's Editor + - "fellow" if the Profile is a Fellow + - "author" if the Profile is an Author + - "referee" if the Profile is another (invited) Referee + - "other" if the Profile is not any of the above + """ + if self.indicated_by == self.submission.editor_in_charge: + return "editor" + elif self.indicated_by in self.submission.authors.all(): + return "author" + elif self.indicated_by in self.submission.referee_invitations.all(): + return "referee" + elif self.indicated_by in self.submission.fellows.all(): + return "fellow" + else: + return "other" diff --git a/scipost_django/submissions/models/submission.py b/scipost_django/submissions/models/submission.py index e21d69ac5..e37668203 100644 --- a/scipost_django/submissions/models/submission.py +++ b/scipost_django/submissions/models/submission.py @@ -44,12 +44,14 @@ from ..managers import SubmissionQuerySet, SubmissionEventQuerySet from ..refereeing_cycles import ShortCycle, DirectCycle, RegularCycle if TYPE_CHECKING: + from django.db.models.manager import RelatedManager from submissions.models import EditorialDecision from scipost.models import Contributor from journals.models import Journal, Publication from proceedings.models import Proceedings from iThenticate_report import iThenticateReport from ontology.models import AcademicField, Specialty, Topic + from ..models.referee_invitation import RefereeInvitation class SubmissionAuthorProfile(models.Model): @@ -247,6 +249,9 @@ class Submission(models.Model): ) STAGE_IN_REFEREEING_COMPLETED_STATUSES = STAGE_DECISIONMAKING + STAGE_DECIDED + # Related managers + referee_invitations: "RelatedManager[RefereeInvitation]" + # Fields preprint = models.OneToOneField( "preprints.Preprint", on_delete=models.CASCADE, related_name="submission" -- GitLab