SciPost Code Repository

Skip to content
Snippets Groups Projects
assignment.py 12.8 KiB
Newer Older
__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
__license__ = "AGPL v3"


from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from common.utils.text import space_uppercase
from journals.models.journal import Journal
from mails.utils import DirectMailUtil
from submissions.constants import CYCLE_DEFAULT
from submissions.managers.assignment import ConditionalAssignmentOfferQuerySet

from ..behaviors import SubmissionRelatedObjectMixin
from ..managers import EditorialAssignmentQuerySet

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from submissions.models import Submission
    from scipost.models import Contributor


class EditorialAssignment(SubmissionRelatedObjectMixin, models.Model):
    """
    Consideration of a Fellow to become Editor-in-Charge of a Submission.
    """

    REFUSE_OUTSIDE_EXPERTISE = "OFE"
    REFUSE_TOO_BUSY = "BUS"
    REFUSE_ON_VACATION = "VAC"
    REFUSE_COI_COAUTHOR = "COI"
    REFUSE_COI_COLLEAGUE = "CCC"
    REFUSE_COI_COMPETITOR = "CCM"
    REFUSE_COI_OTHER = "COT"
    REFUSE_NOT_IMPARTIAL = "NIR"
    REFUSE_NOT_INTERESTED = "NIE"
    REFUSE_DESK_REJECT = "DNP"
    REFUSAL_REASONS = (
        (REFUSE_OUTSIDE_EXPERTISE, "Outside of my field of expertise"),
        (REFUSE_TOO_BUSY, "Too busy"),
        (REFUSE_ON_VACATION, "Away on vacation"),
        (REFUSE_COI_COAUTHOR, "Conflict of interest: coauthor in last 5 years"),
        (REFUSE_COI_COLLEAGUE, "Conflict of interest: close colleague"),
        (REFUSE_COI_COMPETITOR, "Conflict of interest: close competitor"),
        (REFUSE_COI_OTHER, "Conflict of interest: other"),
        (REFUSE_NOT_IMPARTIAL, "Cannot give an impartial assessment"),
        (REFUSE_NOT_INTERESTED, "Not interested enough"),
        (
            REFUSE_DESK_REJECT,
            "SciPost should desk reject this paper",
        ),
        (REFUSE_OTHER, "Other"),
    )

    STATUS_PREASSIGNED = "preassigned"
    STATUS_INVITED = "invited"
    STATUS_ACCEPTED = "accepted"
    STATUS_ACCEPT_IF_NOBODY_ELSE = "accifnobodyelse"
    STATUS_ACCEPT_IF_TRANSFERRED = "acciftransferred"
    STATUS_PERHAPS_LATER = "askagainlater"
    STATUS_DECLINED = "declined"
    STATUS_COMPLETED = "completed"
    STATUS_DEPRECATED = "deprecated"
    STATUS_REPLACED = "replaced"
    ASSIGNMENT_STATUSES = (
        (STATUS_PREASSIGNED, "Preassigned"),
        (STATUS_INVITED, "Invited"),
        (STATUS_ACCEPTED, "Accepted"),
        (STATUS_ACCEPT_IF_NOBODY_ELSE, "Accept (if nobody else does)"),
        (
            STATUS_ACCEPT_IF_TRANSFERRED,
            "Accept (if transferred to non-flagship journal)",
        ),
        (STATUS_PERHAPS_LATER, "Perhaps; ask again later"),
        (STATUS_DECLINED, "Declined"),
        (STATUS_COMPLETED, "Completed"),
        (STATUS_DEPRECATED, "Deprecated"),
        (STATUS_REPLACED, "Replaced"),
    )

    submission = models.ForeignKey(
        "submissions.Submission",
        on_delete=models.CASCADE,
    )
    to = models.ForeignKey("scipost.Contributor", on_delete=models.CASCADE)
        max_length=16, choices=ASSIGNMENT_STATUSES, default=STATUS_PREASSIGNED
    )
    refusal_reason = models.CharField(
        max_length=3, choices=REFUSAL_REASONS, blank=True, null=True
    invitation_order = models.PositiveSmallIntegerField(default=0)

    date_created = models.DateTimeField(default=timezone.now)
    date_invited = models.DateTimeField(blank=True, null=True)
    date_answered = models.DateTimeField(blank=True, null=True)

    objects = EditorialAssignmentQuerySet.as_manager()

    class Meta:
        default_related_name = "editorial_assignments"
        ordering = ["-date_created"]
Jean-Sébastien Caux's avatar
Jean-Sébastien Caux committed
        """Summarize the EditorialAssignment's basic information."""
        return (
            self.to.user.first_name
            + " "
            + self.to.user.last_name
            + " to become EIC of "
            + self.submission.title[:30]
            + " by "
            + self.submission.author_list[:30]
            + ", requested on "
            + self.date_created.strftime("%Y-%m-%d")
        )

    def get_absolute_url(self):
        """Return the url of the assignment's processing page."""
        return reverse("submissions:pool:assignment_request", args=(self.id,))
        return self.status == self.STATUS_PREASSIGNED
        return self.status == self.STATUS_INVITED
        return self.status == self.STATUS_REPLACED
        return self.status == self.STATUS_ACCEPTED
        return self.status == self.STATUS_DEPRECATED
        return self.status == self.STATUS_COMPLETED

    def send_invitation(self):
        """Send invitation and update status."""
        if self.status != self.STATUS_PREASSIGNED:
            # Only send if status is appropriate to prevent double sending
            return False

        # Send mail
        mail_sender = DirectMailUtil(
            mail_code="eic/assignment_request", assignment=self
        )
        EditorialAssignment.objects.filter(id=self.id).update(
            date_invited=timezone.now(), status=self.STATUS_INVITED


class BaseAssignmentCondition(abc.ABC):
    """
    Base class for conditions that can be attached to ConditionalAssignments.
    """

    @abc.abstractmethod
    def is_met(self, offer: "ConditionalAssignmentOffer") -> bool:
        """
        Check if the condition is met for the given offer.
        """
        raise NotImplementedError

    def accept(self, offer: "ConditionalAssignmentOffer"):
        """
        Accept the offer, potentially modifying the offer in the process.
        """
        offer.submission.add_event_for_eic(
            "Offer by " + str(offer.offered_by) + " accepted: " + str(self)
        )
        offer.submission.add_event_for_author(
            "Accepted offer with condition: " + str(self)
        )


class JournalTransferCondition(BaseAssignmentCondition):
    """
    Condition that offers assignment if the submission is transferred to a different journal.

    Needs to specify the alternative journal in the condition_details
    - `alternative_journal_id`: the journal to which the submission should be transferred.
    """

    def __init__(self, alternative_journal_id: int):
        self.alternative_journal_id = alternative_journal_id

    def __eq__(self, other):
        return (
            isinstance(other, JournalTransferCondition)
            and self.alternative_journal_id == other.alternative_journal_id
        )

    def __hash__(self):
        return hash(self.alternative_journal_id)

    def __str__(self):
        return f"Transfer to {self.alternative_journal}"

    def __repr__(self):
        return str(self)

    @cached_property
    def alternative_journal(self) -> Journal | None:
        try:
            return Journal.objects.get(id=self.alternative_journal_id)
        except Journal.DoesNotExist:
            return None

    def is_met(self, offer: "ConditionalAssignmentOffer") -> bool:
        """
        Check if the submission is transferred to the alternative journal.
        """
        return offer.submission.submitted_to == self.alternative_journal

    def accept(self, offer: "ConditionalAssignmentOffer"):
        """
        Accept the offer, transferring the submission to the alternative journal.
        """
        if self.alternative_journal is None:
            raise ValueError("The journal for this transfer is not found.")

        if (
            self.alternative_journal
            not in offer.submission.submitted_to.alternative_journals.all()
        ):
            raise ValueError(
                "The alternative journal is not valid for the current journal."
            )

        offer.submission.submitted_to = self.alternative_journal
        offer.submission.save()

        super().accept(offer)


class ConditionalAssignmentOffer(models.Model):
    """
    Represents an EditorialAssignment that is offered conditionally.

    Valid condition_type strings are the class names of the subclasses of AssignmentCondition.
    """

    STATUS_OFFERED = "offered"
    STATUS_ACCEPTED = "accepted"
    STATUS_DECLINED = "declined"
    STATUS_FULFILLED = "fulfilled"
    STATUS_CHOICES = (
        (STATUS_OFFERED, "Offered"),
        (STATUS_ACCEPTED, "Accepted"),
        (STATUS_DECLINED, "Declined"),
        (STATUS_FULFILLED, "Fulfilled"),
    )

    CONDITION_CHOICES = [
        (condition, space_uppercase(condition))
        for condition in [
            cls.__name__.replace("Condition", "")
            for cls in BaseAssignmentCondition.__subclasses__()
        ]
    ]

    submission = models.ForeignKey["Submission"](
        "submissions.Submission",
        on_delete=models.CASCADE,
    )
    offered_by = models.ForeignKey["Contributor"](
        "scipost.Contributor",
        on_delete=models.CASCADE,
        related_name="conditional_assignments_offered",
    )
    offered_on = models.DateTimeField(auto_now_add=True, editable=False)
    offered_until = models.DateTimeField(blank=True, null=True)

    accepted_by = models.ForeignKey["Contributor"](
        "scipost.Contributor",
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        related_name="conditional_assignments_accepted",
    )
    accepted_on = models.DateTimeField(blank=True, null=True)

    status = models.CharField(
        max_length=16,
        choices=STATUS_CHOICES,
        default=STATUS_OFFERED,
    )

    condition_type = models.CharField(
        max_length=32,
        choices=CONDITION_CHOICES,
    )
    condition_details = models.JSONField(default=dict)

    objects = ConditionalAssignmentOfferQuerySet.as_manager()

    class Meta:
        default_related_name = "conditional_assignment_offers"
        ordering = ["-offered_on"]
        constraints = [
            models.UniqueConstraint(
                fields=["submission", "offered_by", "condition_type"],
                name="unique_offer_type_per_submission_fellow",
            )
        ]

    @cached_property
    def condition(self) -> BaseAssignmentCondition:
        """
        Return the condition object for this offer.
        """
        try:
            condition_class = globals()[self.condition_type + "Condition"]
        except KeyError:
            raise ValueError(f"Unknown condition type: {self.condition_type}")

        return condition_class(**self.condition_details)

    def accept(self, by: "Contributor"):
        """
        Accept the offer, potentially modifying the offer in the process.
        """
        if self.offered_until and timezone.now() > self.offered_until:
            raise ValueError("The offer has expired.")

        if by is None:
            raise ValueError("The offer must be accepted by a Contributor.")

        if self.status != self.STATUS_OFFERED:
            raise ValueError("The offer has already been processed.")

        self.condition.accept(offer=self)

        self.status = self.STATUS_ACCEPTED
        self.accepted_by = by
        self.accepted_on = timezone.now()
        self.save()

    def finalize(self) -> EditorialAssignment | None:
        """
        Check if all conditions are met. If so:
        - Create the EditorialAssignment
        - Set the submission's editor_in_charge to the offering fellow
        - Invalidate all other offers for this submission

        Returns the created EditorialAssignment or None if the conditions are not met.
        """
        from submissions.models import Submission

        # Check that all conditions are met before finalizing
        if not self.condition.is_met(offer=self):
            return

        assignment = EditorialAssignment.objects.create(
            submission=self.submission,
            to=self.offered_by,
            status=EditorialAssignment.STATUS_ACCEPTED,
        )

        Submission.objects.filter(id=self.submission.id).update(
            refereeing_cycle=CYCLE_DEFAULT,
            status=Submission.IN_REFEREEING,
            editor_in_charge=self.offered_by,
            reporting_deadline=None,
            assignment_deadline=None,
            open_for_reporting=True,
            open_for_commenting=True,
            visible_public=True,
            latest_activity=timezone.now(),
        )

        # Invalidate all other offers for this submission
        self.submission.conditional_assignment_offers.exclude(id=self.id).update(
            status=self.STATUS_DECLINED
        )
        self.status = self.STATUS_FULFILLED
        self.save()

        return assignment

    def __str__(self):
        return f"{self.offered_by} to be assigned for {self.submission} with condition: {self.condition}"