SciPost Code Repository

Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
submission.py 24.13 KiB
__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
__license__ = "AGPL v3"


import datetime
import feedparser
import uuid

from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.db.models import Q
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property

from guardian.models import UserObjectPermissionBase
from guardian.models import GroupObjectPermissionBase

from scipost.behaviors import TimeStampedModel
from scipost.constants import SCIPOST_APPROACHES
from scipost.fields import ChoiceArrayField
from scipost.models import Contributor

from comments.models import Comment

from ..behaviors import SubmissionRelatedObjectMixin
from ..constants import (
    STATUS_PREASSIGNED,
    SUBMISSION_CYCLES,
    CYCLE_DEFAULT,
    CYCLE_SHORT,
    CYCLE_DIRECT_REC,
    EVENT_TYPES,
    EVENT_GENERAL,
    EVENT_FOR_EDADMIN,
    EVENT_FOR_AUTHOR,
    EVENT_FOR_EIC,
    SUBMISSION_TIERS,
)
from ..managers import SubmissionQuerySet, SubmissionEventQuerySet
from ..refereeing_cycles import ShortCycle, DirectCycle, RegularCycle


class Submission(models.Model):
    """
    A Submission is a preprint sent to SciPost for consideration.
    """

    # Possible statuses
    INCOMING = "incoming"
    PRESCREENING = "prescreening"
    FAILED_PRESCREENING = "failed_pre"
    PRESCREENING_FAILED = "prescreening_failed"
    UNASSIGNED = "unassigned"
    SCREENING = "screening"
    SCREENING_FAILED = "screening_failed"
    EIC_ASSIGNED = "assigned"
    ASSIGNMENT_FAILED = "assignment_failed"
    REFEREEING_IN_PREPARATION = "refereeing_in_preparation"
    IN_REFEREEING = "in_refereeing"
    REFEREEING_CLOSED = "refereeing_closed"
    AWAITING_RESUBMISSION = "awaiting_resubmission"
    RESUBMITTED = "resubmitted"
    VOTING_IN_PREPARATION = "voting_in_preparation"
    IN_VOTING = "in_voting"
    AWAITING_DECISION = "awaiting_decision"
    ACCEPTED = "accepted"
    ACCEPTED_IN_TARGET = "accepted_in_target"
    ACCEPTED_AWAITING_PUBOFFER_ACCEPTANCE = "puboffer_waiting"
    ACCEPTED_IN_ALTERNATIVE_AWAITING_PUBOFFER_ACCEPTANCE = "accepted_alt_puboffer_waiting"
    ACCEPTED_IN_ALTERNATIVE = "accepted_alt"
    REJECTED = "rejected"
    WITHDRAWN = "withdrawn"
    PUBLISHED = "published"

    SUBMISSION_STATUSES = (
        (INCOMING, "Submission incoming, awaiting EdAdmin"), ## descriptor rephrased
        (PRESCREENING, "Undergoing pre-screening"), ## new
        (FAILED_PRESCREENING, "Failed pre-screening"), ## rename: PRESCREENING_FAILED
        (PRESCREENING_FAILED, "Pre-screening failed"), ## new
        (UNASSIGNED, "Unassigned, awaiting editor assignment"),  ## rename: SCREENING
        (SCREENING, "Undergoing screening"), ## new, replacement for UNASSIGNED
        (SCREENING_FAILED, "Screening failed"), ## new
        (EIC_ASSIGNED, "Editor-in-charge assigned"), ## shift to IN_REFEREEING if ref round open
        (
            ASSIGNMENT_FAILED, ## rename: SCREENING_FAILED
            "Failed to assign Editor-in-charge; manuscript rejected",
        ),
        (REFEREEING_IN_PREPARATION, "Refereeing in preparation"), ## new
        (IN_REFEREEING, "In refereeing"), ## new
        (REFEREEING_CLOSED, "Refereeing closed (awaiting author replies and EdRec)"), ## new
        (AWAITING_RESUBMISSION, "Awaiting resubmission"), ## new
        (RESUBMITTED, "Has been resubmitted"),
        (VOTING_IN_PREPARATION, "Voting in preparation"), ## new
        (IN_VOTING, "In voting"), ## new
        (AWAITING_DECISION, "Awaiting decision"), ## new
        (ACCEPTED, "Publication decision taken: accept"), ## rename: ACCEPTED_IN_TARGET
        (ACCEPTED_IN_TARGET, "Accepted in target Journal"), ## new
        (
            ACCEPTED_AWAITING_PUBOFFER_ACCEPTANCE, ## rename: ACCEPTED_IN_ALTERNATIVE_AWAITING_PUBOFFER_ACCEPTANCE
            "Accepted in other journal; awaiting puboffer acceptance",
        ),
        (
            ACCEPTED_IN_ALTERNATIVE_AWAITING_PUBOFFER_ACCEPTANCE, ## new
            "Accepted in alternative Journal; awaiting puboffer acceptance",
        ),
        (ACCEPTED_IN_ALTERNATIVE, "Accepted in alternative Journal"), ## new
        (REJECTED, "Publication decision taken: reject"),
        (WITHDRAWN, "Withdrawn by the Authors"),
        (PUBLISHED, "Published"),
    )

    # Submissions which are currently under consideration
    UNDER_CONSIDERATION = [
        INCOMING,
        PRESCREENING,
        UNASSIGNED, # remove
        SCREENING,
        EIC_ASSIGNED, # remove
        REFEREEING_IN_PREPARATION,
        IN_REFEREEING,
        REFEREEING_CLOSED,
        AWAITING_RESUBMISSION,
        RESUBMITTED,
        VOTING_IN_PREPARATION,
        IN_VOTING,
        AWAITING_DECISION,
        ACCEPTED_AWAITING_PUBOFFER_ACCEPTANCE, # remove
        ACCEPTED_IN_ALTERNATIVE_AWAITING_PUBOFFER_ACCEPTANCE, # remove
    ]

    # Fields
    preprint = models.OneToOneField(
        "preprints.Preprint", on_delete=models.CASCADE, related_name="submission"
    )

    author_comments = models.TextField(blank=True)
    author_list = models.CharField(max_length=10000, verbose_name="author list")

    # Ontology-based semantic linking
    acad_field = models.ForeignKey(
        "ontology.AcademicField", on_delete=models.PROTECT, related_name="submissions"
    )
    specialties = models.ManyToManyField(
        "ontology.Specialty", related_name="submissions"
    )
    topics = models.ManyToManyField("ontology.Topic", blank=True)

    approaches = ChoiceArrayField(
        models.CharField(max_length=24, choices=SCIPOST_APPROACHES),
        blank=True,
        null=True,
        verbose_name="approach(es) [optional]",
    )
    editor_in_charge = models.ForeignKey(
        "scipost.Contributor",
        related_name="EIC",
        blank=True,
        null=True,
        on_delete=models.CASCADE,
    )

    list_of_changes = models.TextField(blank=True)
    open_for_commenting = models.BooleanField(default=False)
    open_for_reporting = models.BooleanField(default=False)
    referees_flagged = models.TextField(blank=True)
    referees_suggested = models.TextField(blank=True)
    remarks_for_editors = models.TextField(blank=True)
    reporting_deadline = models.DateTimeField(default=timezone.now)

    # Submission status fields
    status = models.CharField(
        max_length=30, choices=SUBMISSION_STATUSES, default=INCOMING
    )
    is_current = models.BooleanField(default=True)
    visible_public = models.BooleanField("Is publicly visible", default=False)
    visible_pool = models.BooleanField("Is visible in the Pool", default=False)
    is_resubmission_of = models.ForeignKey(
        "self",
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        related_name="successor",
    )
    thread_hash = models.UUIDField(default=uuid.uuid4)
    refereeing_cycle = models.CharField(
        max_length=30, choices=SUBMISSION_CYCLES, default=CYCLE_DEFAULT, blank=True
    )

    fellows = models.ManyToManyField(
        "colleges.Fellowship", blank=True, related_name="pool"
    )

    submitted_by = models.ForeignKey(
        "scipost.Contributor",
        on_delete=models.CASCADE,
        related_name="submitted_submissions",
    )
    submitted_to = models.ForeignKey("journals.Journal", on_delete=models.CASCADE)
    proceedings = models.ForeignKey(
        "proceedings.Proceedings",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="submissions",
        help_text=(
            "Don't find the Proceedings you are looking for? "
            "Ask the conference organizers to contact our admin "
            "to set things up."
        ),
    )
    title = models.CharField(max_length=300)

    # Authors which have been mapped to contributors:
    authors = models.ManyToManyField(
        "scipost.Contributor", blank=True, related_name="submissions"
    )
    authors_claims = models.ManyToManyField(
        "scipost.Contributor", blank=True, related_name="claimed_submissions"
    )
    authors_false_claims = models.ManyToManyField(
        "scipost.Contributor", blank=True, related_name="false_claimed_submissions"
    )
    abstract = models.TextField()

    # Links to associated code and data
    code_repository_url = models.URLField(
        blank=True, help_text="Link to a code repository pertaining to your manuscript"
    )
    data_repository_url = models.URLField(
        blank=True, help_text="Link to a data repository pertaining to your manuscript"
    )

    # Comments can be added to a Submission
    comments = GenericRelation("comments.Comment", related_query_name="submissions")

    # iThenticate and conflicts
    needs_conflicts_update = models.BooleanField(default=True)
    plagiarism_report = models.OneToOneField(
        "submissions.iThenticateReport",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="to_submission",
    )
    internal_plagiarism_matches = models.JSONField(
        default=dict,
        blank=True,
        null=True,
    )

    pdf_refereeing_pack = models.FileField(
        upload_to="UPLOADS/REFEREE/%Y/%m/", max_length=200, blank=True
    )

    # Metadata
    metadata = models.JSONField(default=dict, blank=True, null=True)
    submission_date = models.DateTimeField(
        verbose_name="submission date", default=timezone.now
    )
    acceptance_date = models.DateField(
        verbose_name="acceptance date", null=True, blank=True
    )
    latest_activity = models.DateTimeField(auto_now=True)
    update_search_index = models.BooleanField(default=True)

    objects = SubmissionQuerySet.as_manager()

    # Temporary
    invitation_order = models.IntegerField(default=0)

    class Meta:
        app_label = "submissions"
        ordering = ["-submission_date"]
        permissions = [
            ("take_edadmin_actions", "Take editorial admin actions"),
            ("view_edadmin_info", "View editorial admin information"),
        ]

    def save(self, *args, **kwargs):
        """Prefill some fields before saving."""
        obj = super().save(*args, **kwargs)
        if hasattr(self, "cycle"):
            self.set_cycle()
        return obj

    def __str__(self):
        """Summarize the Submission in a string."""
        header = "{identifier}, {title} by {authors}".format(
            identifier=self.preprint.identifier_w_vn_nr,
            title=self.title[:30],
            authors=self.author_list[:30],
        )
        if self.is_current:
            header += " (current version)"
        else:
            header += " (deprecated version " + str(self.thread_sequence_order) + ")"
        if hasattr(self, "publication") and self.publication.is_published:
            header += " (published as %s (%s))" % (
                self.publication.doi_string,
                self.publication.publication_date.strftime("%Y"),
            )
        return header

    @property
    def authors_as_list(self):
        """Returns a python list of the authors, extracted from author_list field."""
        # Start by separating in comma's
        comma_separated = self.author_list.split(",")
        authors_as_list = []
        for entry in comma_separated:
            and_separated = entry.split(" and ")
            for subentry in and_separated:
                authors_as_list.append(subentry.lstrip().rstrip())
        return authors_as_list

    def touch(self):
        """Update latest activity timestamp."""
        Submission.objects.filter(id=self.id).update(latest_activity=timezone.now())

    def comments_set_complete(self):
        """Return Comments on Submissions, Reports and other Comments."""
        qs = Comment.objects.filter(
            Q(submissions=self)
            | Q(reports__submission=self)
            | Q(comments__reports__submission=self)
            | Q(comments__submissions=self)
        )
        # Add recursive comments:
        for c in qs:
            if c.nested_comments:
                qs = qs | c.all_nested_comments().all()
        return qs.distinct()

    @property
    def cycle(self):
        """Get cycle object relevant for the Submission."""
        if not hasattr(self, "_cycle"):
            self.set_cycle()
        return self._cycle

    def set_cycle(self):
        """Set cycle to the Submission on request."""
        if self.refereeing_cycle == CYCLE_SHORT:
            self._cycle = ShortCycle(self)
        elif self.refereeing_cycle == CYCLE_DIRECT_REC:
            self._cycle = DirectCycle(self)
        else:
            self._cycle = RegularCycle(self)

    def get_absolute_url(self):
        """Return url of the Submission detail page."""
        return reverse(
            "submissions:submission", args=(self.preprint.identifier_w_vn_nr,)
        )

    def get_notification_url(self, url_code):
        """Return url related to the Submission by the `url_code` meant for Notifications."""
        if url_code == "editorial_page":
            return reverse(
                "submissions:editorial_page", args=(self.preprint.identifier_w_vn_nr,)
            )
        return self.get_absolute_url()

    @property
    def is_resubmission(self):
        return self.is_resubmission_of is not None

    @property
    def notification_name(self):
        """Return string representation of this Submission as shown in Notifications."""
        return self.preprint.identifier_w_vn_nr

    @property
    def eic_recommendation_required(self):
        """Return if Submission requires a EICRecommendation to be formulated."""
        return not self.eicrecommendations.active().exists()

    @property
    def revision_requested(self):
        """Check if Submission has fixed EICRecommendation asking for revision."""
        return self.eicrecommendations.fixed().asking_revision().exists()

    @property
    def under_consideration(self):
        """
        Check if the Submission is currently under consideration
        (in other words: is undergoing editorial processing).
        """
        return self.status in self.UNDER_CONSIDERATION

    @property
    def open_for_resubmission(self):
        """Check if Submission has fixed EICRecommendation asking for revision."""
        if self.status != self.EIC_ASSIGNED:
            return False
        return self.eicrecommendations.fixed().asking_revision().exists()

    @property
    def reporting_deadline_has_passed(self):
        """Check if Submission has passed its reporting deadline."""
        if self.status in [self.INCOMING, self.UNASSIGNED]:
            # These statuses do not have a deadline
            return False

        return timezone.now() > self.reporting_deadline

    @property
    def reporting_deadline_approaching(self):
        """Check if reporting deadline is within 7 days from now but not passed yet."""
        if self.status in [self.INCOMING, self.UNASSIGNED]:
            # These statuses do not have a deadline
            return False

        if self.reporting_deadline_has_passed:
            return False
        return timezone.now() > self.reporting_deadline - datetime.timedelta(days=7)

    @property
    def is_open_for_reporting_within_deadline(self):
        """Check if Submission is open for reporting and within deadlines."""
        return self.open_for_reporting and not self.reporting_deadline_has_passed

    @property
    def submission_date_ymd(self):
        """Return the submission date in YYYY-MM-DD format."""
        return self.submission_date.date()

    @property
    def original_submission_date_ymd(self):
        """Return the submission date in YYYY-MM-DD format."""
        return self.original_submission_date.date()

    @property
    def original_submission_date(self):
        """Return the submission_date of the first Submission in the thread."""
        return (
            Submission.objects.filter(
                thread_hash=self.thread_hash, is_resubmission_of__isnull=True
            )
            .first()
            .submission_date
        )

    @property
    def in_prescreening(self):
        return self.status == self.INCOMING

    @property
    def in_refereeing_phase(self):
        """Check if Submission is in active refereeing phase.

        This is not meant for functional logic, rather for explanatory functionality to the user.
        """
        if self.eicrecommendations.active().exists():
            # Editorial Recommendation is formulated!
            return False

        if self.refereeing_cycle == CYCLE_DIRECT_REC:
            # There's no refereeing in this cycle at all.
            return False

        if self.referee_invitations.in_process().exists():
            # Some unfinished invitations exist still.
            return True

        if self.referee_invitations.awaiting_response().exists():
            # Some invitations have been sent out without a response.
            return True

        # Maybe: Check for unvetted Reports?
        return (
            self.status == self.EIC_ASSIGNED
            and self.is_open_for_reporting_within_deadline
        )

    @property
    def can_reset_reporting_deadline(self):
        """Check if reporting deadline is allowed to be reset."""
        blocked_statuses = [
            self.FAILED_PRESCREENING,
            self.RESUBMITTED,
            self.ACCEPTED,
            self.REJECTED,
            self.WITHDRAWN,
            self.PUBLISHED,
        ]
        if self.status in blocked_statuses:
            return False

        if self.refereeing_cycle == CYCLE_DIRECT_REC:
            # This cycle doesn't have a formal refereeing round.
            return False

        return self.editor_in_charge is not None

    @property
    def thread_full(self):
        """Return all Submissions in the database in this thread."""
        return Submission.objects.filter(thread_hash=self.thread_hash).order_by(
            "-submission_date", "-preprint"
        )

    @property
    def thread(self):
        """Return all (public) Submissions in the database in this thread."""
        return (
            Submission.objects.public()
            .filter(thread_hash=self.thread_hash)
            .order_by("-submission_date", "-preprint")
        )

    @cached_property
    def thread_sequence_order(self):
        """Return the ordering of this Submission within its thread."""
        return self.thread.filter(submission_date__lt=self.submission_date).count() + 1

    @cached_property
    def other_versions(self):
        """Return other Submissions in the database in this thread."""
        return self.get_other_versions().order_by("-submission_date", "-preprint")

    def get_other_versions(self):
        """Return queryset of other Submissions with this thread."""
        return Submission.objects.filter(thread_hash=self.thread_hash).exclude(
            pk=self.id
        )

    def get_latest_version(self):
        """Return the latest version in the thread of this Submission."""
        return self.thread_full.first()

    def get_latest_public_version(self):
        """Return the latest publicly-visible version in the thread of this Submission."""
        return self.thread.first()

    def _add_event(self, sort, message):
        event = SubmissionEvent(submission=self, event=sort, text=message)
        event.save()

    def add_general_event(self, message):
        """Generate message meant for EdAdmin, EIC and authors."""
        self._add_event(EVENT_GENERAL, message)

    def add_event_for_edadmin(self, message):
        """Generate message meant for EdAdmin only."""
        self._add_event(EVENT_FOR_EDADMIN, message)

    def add_event_for_eic(self, message):
        """Generate message meant for EIC and Editorial Administration only."""
        self._add_event(EVENT_FOR_EIC, message)

    def add_event_for_author(self, message):
        """Generate message meant for authors only."""
        self._add_event(EVENT_FOR_AUTHOR, message)

    def flag_coauthorships_arxiv(self, fellows):
        """Identify coauthorships from arXiv, using author surname matching."""
        coauthorships = {}
        if self.metadata and "entries" in self.metadata:
            author_last_names = []
            for author in self.metadata["entries"][0]["authors"]:
                # Gather author data to do conflict-of-interest queries with
                author_last_names.append(author["name"].split()[-1])
            authors_last_names_str = "+OR+".join(author_last_names)

            for fellow in fellows:
                # For each fellow found, so a query with the authors to check for conflicts
                search_query = "au:({fellow}+AND+({authors}))".format(
                    fellow=fellow.contributor.user.last_name,
                    authors=authors_last_names_str,
                )
                queryurl = (
                    "https://export.arxiv.org/api/query?search_query={sq}".format(
                        sq=search_query
                    )
                )
                queryurl += "&sortBy=submittedDate&sortOrder=descending&max_results=5"
                queryurl = queryurl.replace(
                    " ", "+"
                )  # Fallback for some last names with spaces
                queryresults = feedparser.parse(queryurl)
                if queryresults.entries:
                    coauthorships[
                        fellow.contributor.user.last_name
                    ] = queryresults.entries
        return coauthorships

    def is_sending_editorial_invitations(self):
        """Return whether editorial assignments are being send out."""
        if self.status != self.UNASSIGNED:
            # Only if status is unassigned.
            return False

        return self.editorial_assignments.filter(status=STATUS_PREASSIGNED).exists()

    def has_inadequate_pool_composition(self):
        """
        Check whether the EIC actually in the pool of the Submission.

        (Could happen on resubmission or reassignment after wrong Journal selection)
        """
        if not self.editor_in_charge:
            # None assigned yet.
            return False

        pool_contributors_ids = Contributor.objects.filter(
            fellowships__pool=self
        ).values_list("id", flat=True)
        return self.editor_in_charge.id not in pool_contributors_ids

    @property
    def editorial_decision(self):
        """Returns the latest EditorialDecision (if it exists)."""
        if self.editorialdecision_set.nondeprecated().exists():
            return self.editorialdecision_set.nondeprecated().latest_version()
        return None


# The next two models are for optimization of django guardian object-level permissions
# using direct foreign keys instead of generic ones
# (see https://django-guardian.readthedocs.io/en/stable/userguide/performance.html)

class SubmissionUserObjectPermission(UserObjectPermissionBase):
    content_object = models.ForeignKey(Submission, on_delete=models.CASCADE)

class SubmissionGroupObjectPermission(GroupObjectPermissionBase):
    content_object = models.ForeignKey(Submission, on_delete=models.CASCADE)



class SubmissionEvent(SubmissionRelatedObjectMixin, TimeStampedModel):
    """Private message directly related to a Submission.

    The SubmissionEvent's goal is to act as a messaging model for the Submission cycle.
    Its main audience will be the author(s) and the Editor-in-charge of a Submission.

    Be aware that both the author and editor-in-charge will read the submission event.
    Make sure the right text is given to the appropriate event-type, to protect
    the fellow's identity.
    """

    submission = models.ForeignKey(
        "submissions.Submission", on_delete=models.CASCADE, related_name="events"
    )
    event = models.CharField(max_length=4, choices=EVENT_TYPES, default=EVENT_GENERAL)
    text = models.TextField()

    objects = SubmissionEventQuerySet.as_manager()

    class Meta:
        ordering = ["-created"]

    def __str__(self):
        """Summarize the SubmissionEvent's meta information."""
        return "%s: %s" % (str(self.submission), self.get_event_display())


class SubmissionTiering(models.Model):
    """A Fellow's quality tiering of a Submission for a given Journal, given during voting."""

    submission = models.ForeignKey(
        "submissions.Submission", on_delete=models.CASCADE, related_name="tierings"
    )
    fellow = models.ForeignKey("scipost.Contributor", on_delete=models.CASCADE)
    for_journal = models.ForeignKey("journals.Journal", on_delete=models.CASCADE)
    tier = models.SmallIntegerField(choices=SUBMISSION_TIERS)