SciPost Code Repository

Skip to content
Snippets Groups Projects
models.py 45.7 KiB
Newer Older
__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
Jorran de Wit's avatar
Jorran de Wit committed
import uuid
Jorran de Wit's avatar
Jorran de Wit committed
from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericRelation
from django.utils import timezone
from django.db import models
Jorran de Wit's avatar
Jorran de Wit committed
from django.db.models import Q
from django.urls import reverse
from django.utils.functional import cached_property
from .behaviors import SubmissionRelatedObjectMixin
from .constants import (
Jorran de Wit's avatar
Jorran de Wit committed
    ASSIGNMENT_REFUSAL_REASONS, ASSIGNMENT_NULLBOOL, SUBMISSION_TYPE, STATUS_PREASSIGNED,
    ED_COMM_CHOICES, REFEREE_QUALIFICATION, QUALITY_SPEC, RANKING_CHOICES, STATUS_INVITED,
Jorran de Wit's avatar
Jorran de Wit committed
    SUBMISSION_STATUS, SUBMISSION_UNDER_CONSIDERATION,
    REPORT_STATUSES, STATUS_UNVETTED, STATUS_INCOMING, STATUS_EIC_ASSIGNED,
Jorran de Wit's avatar
Jorran de Wit committed
    SUBMISSION_CYCLES, CYCLE_DEFAULT, CYCLE_SHORT, DECISION_FIXED, ASSIGNMENT_STATUSES,
    CYCLE_DIRECT_REC, EVENT_GENERAL, EVENT_TYPES, EVENT_FOR_AUTHOR, EVENT_FOR_EIC, REPORT_TYPES,
    REPORT_NORMAL, STATUS_DRAFT, STATUS_VETTED, EIC_REC_STATUSES, VOTING_IN_PREP, STATUS_UNASSIGNED,
    STATUS_INCORRECT, STATUS_UNCLEAR, STATUS_NOT_USEFUL, STATUS_NOT_ACADEMIC, DEPRECATED,
    STATUS_FAILED_PRESCREENING, STATUS_RESUBMITTED, STATUS_REJECTED, STATUS_WITHDRAWN, REPORT_REC,
    STATUS_PUBLISHED, STATUS_REPLACED, STATUS_ACCEPTED, STATUS_DEPRECATED, STATUS_COMPLETED,
    PLAGIARISM_STATUSES, STATUS_WAITING)
from .managers import (
    SubmissionQuerySet, EditorialAssignmentQuerySet, EICRecommendationQuerySet, ReportQuerySet,
    SubmissionEventQuerySet, RefereeInvitationQuerySet, EditorialCommunicationQueryset)
Jorran de Wit's avatar
..  
Jorran de Wit committed
from .refereeing_cycles import ShortCycle, DirectCycle, RegularCycle
from comments.behaviors import validate_file_extension, validate_max_file_size
Jorran de Wit's avatar
Jorran de Wit committed
from comments.models import Comment
from scipost.behaviors import TimeStampedModel
from scipost.constants import TITLE_CHOICES
from scipost.constants import SCIPOST_DISCIPLINES, SCIPOST_SUBJECT_AREAS
from scipost.fields import ChoiceArrayField
from scipost.models import Contributor
from scipost.storage import SecureFileStorage
from journals.constants import SCIPOST_JOURNALS_DOMAINS
from journals.models import Publication
from mails.utils import DirectMailUtil
class Submission(models.Model):
    """SciPost register of an preprint (ArXiv articles only for now).

    A Submission is a centralized information package used in the refereeing cycle of a preprint.
    It collects information about authors, referee reports, editorial recommendations,
    college decisions, etc. etc. After an 'acceptance editorial recommendation', the Publication
    will directly be related to the latest Submission in the thread.
Jorran de Wit's avatar
Jorran de Wit committed
    """
    preprint = models.OneToOneField('preprints.Preprint', related_name='submission')

    author_comments = models.TextField(blank=True)
    author_list = models.CharField(max_length=10000, verbose_name="author list")
    discipline = models.CharField(max_length=20, choices=SCIPOST_DISCIPLINES, default='physics')
    domain = models.CharField(max_length=3, choices=SCIPOST_JOURNALS_DOMAINS)
    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)
    secondary_areas = ChoiceArrayField(
        models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS),
        blank=True, null=True)
Jorran de Wit's avatar
Jorran de Wit committed

    # Submission status fields
Jorran de Wit's avatar
Jorran de Wit committed
    status = models.CharField(max_length=30, choices=SUBMISSION_STATUS, default=STATUS_INCOMING)
    is_current = models.BooleanField(default=True)
    visible_public = models.BooleanField("Is publicly visible", default=False)
Jorran de Wit's avatar
Jorran de Wit committed
    visible_pool = models.BooleanField("Is visible in the Pool", default=False)
    is_resubmission_of = models.ForeignKey('self', blank=True, null=True, related_name='successor')
Jorran de Wit's avatar
Jorran de Wit committed
    thread_hash = models.UUIDField(default=uuid.uuid4)
    _is_resubmission = models.BooleanField(default=False)
Jorran de Wit's avatar
Jorran de Wit committed
    refereeing_cycle = models.CharField(
Jorran de Wit's avatar
3.  
Jorran de Wit committed
        max_length=30, choices=SUBMISSION_CYCLES, default=CYCLE_DEFAULT, blank=True)
Jorran de Wit's avatar
Jorran de Wit committed
    fellows = models.ManyToManyField('colleges.Fellowship', blank=True,
Jorran de Wit's avatar
Jorran de Wit committed
                                     related_name='pool')
Jorran de Wit's avatar
Jorran de Wit committed

    subject_area = models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS,
                                    verbose_name='Primary subject area', default='Phys:QP')
Jorran de Wit's avatar
Jorran de Wit committed
    submission_type = models.CharField(max_length=10, choices=SUBMISSION_TYPE, blank=True)
    submitted_by = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE,
                                     related_name='submitted_submissions')
Jorran de Wit's avatar
Jorran de Wit committed
    voting_fellows = models.ManyToManyField('colleges.Fellowship', blank=True,
                                            related_name='voting_pool')
Jorran de Wit's avatar
Jorran de Wit committed

    submitted_to = models.ForeignKey('journals.Journal', on_delete=models.CASCADE)
Jorran de Wit's avatar
Jorran de Wit committed
    proceedings = models.ForeignKey('proceedings.Proceedings', null=True, blank=True,
                                    related_name='submissions')
    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()
Jorran de Wit's avatar
Jorran de Wit committed
    # Comments can be added to a Submission
    comments = GenericRelation('comments.Comment', related_query_name='submissions')

Jorran de Wit's avatar
Jorran de Wit committed
    # iThenticate and conflicts
    needs_conflicts_update = models.BooleanField(default=True)
Jorran de Wit's avatar
Jorran de Wit committed
    plagiarism_report = models.OneToOneField(
        'submissions.iThenticateReport', on_delete=models.SET_NULL,
        null=True, blank=True, related_name='to_submission')
    # Arxiv identifiers with/without version number
    arxiv_identifier_w_vn_nr = models.CharField(max_length=15, default='0000.00000v0')
    arxiv_identifier_wo_vn_nr = models.CharField(max_length=10, default='0000.00000')
    arxiv_vn_nr = models.PositiveSmallIntegerField(default=1)
    arxiv_link = models.URLField(verbose_name='arXiv link (including version nr)')
Jorran de Wit's avatar
Jorran de Wit committed
    pdf_refereeing_pack = models.FileField(upload_to='UPLOADS/REFEREE/%Y/%m/',
                                           max_length=200, blank=True)

    metadata = JSONField(default={}, blank=True, null=True)
    submission_date = models.DateField(verbose_name='submission date', default=datetime.date.today)
    acceptance_date = models.DateField(verbose_name='acceptance date', null=True, blank=True)
    latest_activity = models.DateTimeField(auto_now=True)
    # Topics for semantic linking
    topics = models.ManyToManyField('ontology.Topic', blank=True)

    objects = SubmissionQuerySet.as_manager()
    # Temporary
    needs_conflicts_update = models.BooleanField(default=False)
    invitation_order = models.IntegerField(default=0)
Jorran de Wit's avatar
Jorran de Wit committed
    class Meta:
        app_label = 'submissions'

    def save(self, *args, **kwargs):
        """Prefill some fields before saving."""
        obj = super().save(*args, **kwargs)
        if hasattr(self, 'cycle'):
            self.set_cycle()
        """Summerize the Submission in a string."""
Jorran de Wit's avatar
Jorran de Wit committed
        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:
Jorran de Wit's avatar
Jorran de Wit committed
            header += ' (deprecated version ' + str(self.preprint.vn_nr) + ')'
        if hasattr(self, 'publication') and self.publication.is_published:
Jorran de Wit's avatar
Jorran de Wit committed
            header += ' (published as %s (%s))' % (
                self.publication.doi_string, self.publication.publication_date.strftime('%Y'))
        """Update latest activity timestamp."""
        Submission.objects.filter(id=self.id).update(latest_activity=timezone.now())
Jorran de Wit's avatar
Jorran de Wit committed
    def comments_set_complete(self):
        """Return Comments on Submissions, Reports and other Comments."""
Jorran de Wit's avatar
..  
Jorran de Wit committed
        return Comment.objects.filter(
            Q(submissions=self) | Q(reports__submission=self) |
            Q(comments__reports__submission=self) | Q(comments__submissions=self)).distinct()
Jorran de Wit's avatar
Jorran de Wit committed

Jorran de Wit's avatar
Jorran de Wit committed
    @property
Jorran de Wit's avatar
Jorran de Wit committed
    def cycle(self):
Jorran de Wit's avatar
Jorran de Wit committed
        """Get cycle object that's relevant for the Submission."""
        if not hasattr(self, '_cycle'):
Jorran de Wit's avatar
..  
Jorran de Wit committed
        return self._cycle

    def set_cycle(self):
        """Set cycle to the Submission on request."""
        if self.refereeing_cycle == CYCLE_SHORT:
Jorran de Wit's avatar
..  
Jorran de Wit committed
            self._cycle = ShortCycle(self)
        elif self.refereeing_cycle == CYCLE_DIRECT_REC:
Jorran de Wit's avatar
..  
Jorran de Wit committed
            self._cycle = DirectCycle(self)
Jorran de Wit's avatar
..  
Jorran de Wit committed
            self._cycle = RegularCycle(self)
    def get_absolute_url(self):
        """Return url of the Submission detail page."""
Jorran de Wit's avatar
Jorran de Wit committed
        return reverse('submissions:submission', args=(self.preprint.identifier_w_vn_nr,))
Jorran de Wit's avatar
Jorran de Wit committed
    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()

Jorran de Wit's avatar
Jorran de Wit committed
    @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."""
Jorran de Wit's avatar
Jorran de Wit committed
        return self.preprint.identifier_w_vn_nr

    @property
    def eic_recommendation_required(self):
        """Return if Submission needs a EICRecommendation to be formulated."""
        return not self.eicrecommendations.active().exists()
Jorran de Wit's avatar
Jorran de Wit committed

    @property
    def revision_requested(self):
        """Check if Submission has fixed EICRecommendation asking for revision."""
Jorran de Wit's avatar
Jorran de Wit committed
        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 SUBMISSION_UNDER_CONSIDERATION

Jorran de Wit's avatar
Jorran de Wit committed
    @property
    def open_for_resubmission(self):
        """Check if Submission has fixed EICRecommendation asking for revision."""
        if self.status != STATUS_EIC_ASSIGNED:
Jorran de Wit's avatar
Jorran de Wit committed
            return False
        return self.eicrecommendations.fixed().asking_revision().exists()
    @property
    def reporting_deadline_has_passed(self):
        """Check if Submission has passed it's reporting deadline."""
Jorran de Wit's avatar
Jorran de Wit committed
        if self.status in [STATUS_INCOMING, STATUS_UNASSIGNED]:
            # These statuses do not have a deadline
            return False

        return timezone.now() > self.reporting_deadline
Jorran de Wit's avatar
Jorran de Wit committed
    @property
    def reporting_deadline_approaching(self):
        """Check if reporting deadline is within 7 days from now but not passed yet."""
        if self.status in [STATUS_INCOMING, STATUS_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)

Jorran de Wit's avatar
Jorran de Wit committed
    @property
    def is_open_for_reporting(self):
        """Check if Submission is open for reporting and within deadlines."""
        return self.open_for_reporting and not self.reporting_deadline_has_passed

Jorran de Wit's avatar
Jorran de Wit committed
    @property
    def original_submission_date(self):
        """Return the submission_date of the first Submission in the thread."""
Jorran de Wit's avatar
Jorran de Wit committed
        return Submission.objects.filter(
Jorran de Wit's avatar
Jorran de Wit committed
            thread_hash=self.thread_hash, is_resubmission_of__isnull=True).first().submission_date
Jorran de Wit's avatar
Jorran de Wit committed
    @property
    def in_refereeing_phase(self):
        """Check if Submission is in active refereeing phase.

Jorran de Wit's avatar
Jorran de Wit committed
        This is not meant for functional logic, rather for explanatory functionality to the user.
Jorran de Wit's avatar
Jorran de Wit committed
        """
        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 == STATUS_EIC_ASSIGNED and self.is_open_for_reporting

    @property
    def can_reset_reporting_deadline(self):
        """Check if reporting deadline is allowed to be reset."""
        blocked_statuses = [
            STATUS_FAILED_PRESCREENING, STATUS_RESUBMITTED, STATUS_ACCEPTED,
            STATUS_REJECTED, STATUS_WITHDRAWN, STATUS_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
Jorran de Wit's avatar
Jorran de Wit committed
    def thread(self):
        """Return all (public) Submissions in the database in this ArXiv identifier series."""
Jorran de Wit's avatar
Jorran de Wit committed
        return Submission.objects.public().filter(thread_hash=self.thread_hash).order_by(
Jorran de Wit's avatar
Jorran de Wit committed
                '-preprint__vn_nr', '-submission_date')
Jorran de Wit's avatar
Jorran de Wit committed

    def other_versions(self):
        """Return other Submissions in the database in this ArXiv identifier series."""
Jorran de Wit's avatar
Jorran de Wit committed
        return self.get_other_versions().order_by('-preprint__vn_nr')
Jorran de Wit's avatar
Jorran de Wit committed

    def get_other_versions(self):
        """Return queryset of other Submissions with this ArXiv identifier series."""
Jorran de Wit's avatar
Jorran de Wit committed
        return Submission.objects.filter(thread_hash=self.thread_hash).exclude(pk=self.id)
Jorran de Wit's avatar
Jorran de Wit committed
    def get_latest_version(self):
        """Return the latest known version in the thread of this Submission."""
        return self.thread.first()
    def add_general_event(self, message):
        """Generate message meant for EIC and authors."""
        event = SubmissionEvent(
            submission=self,
            event=EVENT_GENERAL,
            text=message,
        )
        event.save()

    def add_event_for_author(self, message):
        """Generate message meant for authors only."""
        event = SubmissionEvent(
            submission=self,
            event=EVENT_FOR_AUTHOR,
            text=message,
        )
        event.save()

    def add_event_for_eic(self, message):
        """Generate message meant for EIC and Editorial Administration only."""
        event = SubmissionEvent(
            submission=self,
            event=EVENT_FOR_EIC,
            text=message,
        )
        event.save()
    def flag_coauthorships_arxiv(self, fellows):
Jorran de Wit's avatar
Jorran de Wit committed
        """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 != STATUS_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


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):
        """Summerize the SubmissionEvent's meta information."""
Jorran de Wit's avatar
Jorran de Wit committed
        return '%s: %s' % (str(self.submission), self.get_event_display())
######################
# Editorial workflow #
######################

class EditorialAssignment(SubmissionRelatedObjectMixin, models.Model):
    """Unique Fellow assignment to a Submission as Editor-in-Charge.

    An EditorialAssignment could be an invitation to be the Editor-in-Charge for a Submission,
    containing either its acceptance or rejection, or it is an immediate accepted assignment. In
    addition is registers whether the Fellow's duties are fullfilled or still ongoing.
Jorran de Wit's avatar
Jorran de Wit committed
    """
    submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE)
    to = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE)
Jorran de Wit's avatar
Jorran de Wit committed

Jorran de Wit's avatar
Jorran de Wit committed
    status = models.CharField(
        max_length=16, choices=ASSIGNMENT_STATUSES, default=STATUS_PREASSIGNED)
    refusal_reason = models.CharField(
        max_length=3, choices=ASSIGNMENT_REFUSAL_REASONS, blank=True, null=True)
    invitation_order = models.PositiveSmallIntegerField(default=0)

    date_created = models.DateTimeField(default=timezone.now)
Jorran de Wit's avatar
Jorran de Wit committed
    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']
        """Summerize 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 url of the assignment's processing page."""
        return reverse('submissions:assignment_request', args=(self.id,))

    @property
    def notification_name(self):
        """Return string representation of this EditorialAssigment as shown in Notifications."""
Jorran de Wit's avatar
Jorran de Wit committed
        return self.submission.preprint.identifier_w_vn_nr
Jorran de Wit's avatar
Jorran de Wit committed
    @property
    def preassigned(self):
        return self.status == STATUS_PREASSIGNED

    @property
    def invited(self):
        return self.status == STATUS_INVITED

Jorran de Wit's avatar
Jorran de Wit committed
    @property
    def replaced(self):
        return self.status == STATUS_REPLACED

    @property
    def accepted(self):
        return self.status == STATUS_ACCEPTED

    @property
    def deprecated(self):
        return self.status == STATUS_DEPRECATED

    @property
    def completed(self):
        return self.status == STATUS_COMPLETED

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

        # Send mail
        mail_sender = DirectMailUtil(mail_code='eic/assignment_request', instance=self)
        mail_sender.send()

        EditorialAssignment.objects.filter(
            id=self.id).update(date_invited=timezone.now(), status=STATUS_INVITED)

        return True
class RefereeInvitation(SubmissionRelatedObjectMixin, models.Model):
    """Invitation to a scientist to referee a Submission.

    A RefereeInvitation will invite a Contributor or a non-registered scientist to send
    a Report for a specific Submission. It will register its response to the invitation and
    the current status its refereeing duty if the invitation has been accepted.
    """
    profile = models.ForeignKey('profiles.Profile', on_delete=models.SET_NULL,
                                blank=True, null=True)
    submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE,
                                   related_name='referee_invitations')
    referee = models.ForeignKey('scipost.Contributor', related_name='referee_invitations',
                                blank=True, null=True, on_delete=models.CASCADE)
    title = models.CharField(max_length=4, choices=TITLE_CHOICES)
Jorran de Wit's avatar
Jorran de Wit committed
    first_name = models.CharField(max_length=30)
    last_name = models.CharField(max_length=30)
    email_address = models.EmailField()
    # if Contributor not found, person is invited to register
    invitation_key = models.CharField(max_length=40, blank=True)
    date_invited = models.DateTimeField(default=timezone.now)
    invited_by = models.ForeignKey('scipost.Contributor', related_name='referee_invited_by',
                                   blank=True, null=True, on_delete=models.CASCADE)
    auto_reminders_allowed = models.BooleanField(default=True)
    nr_reminders = models.PositiveSmallIntegerField(default=0)
    date_last_reminded = models.DateTimeField(blank=True, null=True)
    accepted = models.NullBooleanField(choices=ASSIGNMENT_NULLBOOL, default=None)
    date_responded = models.DateTimeField(blank=True, null=True)
    refusal_reason = models.CharField(max_length=3, choices=ASSIGNMENT_REFUSAL_REASONS,
    fulfilled = models.BooleanField(default=False)  # True if a Report has been submitted
    cancelled = models.BooleanField(default=False)  # True if EIC has deactivated invitation
    objects = RefereeInvitationQuerySet.as_manager()

Jorran de Wit's avatar
Jorran de Wit committed
    class Meta:
        ordering = ['cancelled', 'date_invited']

        """Summerize the RefereeInvitation's basic information."""
        return (self.first_name + ' ' + self.last_name + ' to referee ' +
                self.submission.title[:30] + ' by ' + self.submission.author_list[:30] +
                ', invited on ' + self.date_invited.strftime('%Y-%m-%d'))
    def get_absolute_url(self):
        """Return url of the invitation's processing page."""
        return reverse('submissions:accept_or_decline_ref_invitations', args=(self.id,))

    @property
    def referee_str(self):
        """Return the most up-to-date name of the Referee."""
        if self.referee:
            return str(self.referee)
        return self.last_name + ', ' + self.first_name

    @property
    def notification_name(self):
        """Return string representation of this RefereeInvitation as shown in Notifications."""
Jorran de Wit's avatar
Jorran de Wit committed
        return self.submission.preprint.identifier_w_vn_nr
    @property
    def related_report(self):
        """Return the Report that's been created for this invitation."""
Jorran de Wit's avatar
Jorran de Wit committed
        return self.submission.reports.filter(author=self.referee).last()

    @property
    def needs_response(self):
        """Check if invitation has no response in more than three days."""
        if not self.cancelled and self.accepted is None:
            if self.date_last_reminded:
                # No reponse in over three days since last reminder
                return timezone.now() - self.date_last_reminded > datetime.timedelta(days=3)

            # No reponse in over three days since original invite
            return timezone.now() - self.date_invited > datetime.timedelta(days=3)

        return False

    @property
    def needs_fulfillment_reminder(self):
        """Check if isn't fullfilled but deadline is closing in."""
        if self.accepted and not self.cancelled and not self.fulfilled:
            # Refereeing deadline closing in/overdue, but invitation isn't fulfilled yet.
            return (self.submission.reporting_deadline - timezone.now()).days < 7
        return False

    @property
    def is_overdue(self):
        """Check if isn't fullfilled but deadline has expired."""
        if self.accepted and not self.cancelled and not self.fulfilled:
            # Refereeing deadline closing in/overdue, but invitation isn't fulfilled yet.
            return (self.submission.reporting_deadline - timezone.now()).days < 0
        return False

    @property
    def needs_attention(self):
        """Check if invitation needs attention by the editor."""
        return self.needs_response or self.needs_fulfillment_reminder

    @property
    def get_status_display(self):
        """Get status: a combination between different boolean fields."""
        if self.cancelled:
            return 'Cancelled'
        if self.fulfilled:
            return 'Fulfilled'
        if self.accepted is None:
            return 'Awaiting response'
        elif self.accepted:
            return 'Accepted'
        else:
            return 'Declined ({})'.format(self.get_refusal_reason_display())
    def reset_content(self):
        """Reset the invitation's information as a new invitation."""
        self.nr_reminders = 0
        self.date_last_reminded = None
        self.accepted = None
        self.refusal_reason = None
        self.fulfilled = False
        self.cancelled = False

class Report(SubmissionRelatedObjectMixin, models.Model):
    """Report on a Submission written by a Contributor.
    The refereeing Report has evaluation (text) fields for different categories. In general,
    the Report shall have all of these fields filled. In case the Contributor has already written
    a Report on a earlier version of the Submission, he will be able to write a 'follow-up report'.
    A follow-up report is a Report with only the general `report` evaluation field being required.
    status = models.CharField(max_length=16, choices=REPORT_STATUSES, default=STATUS_UNVETTED)
    report_type = models.CharField(max_length=32, choices=REPORT_TYPES, default=REPORT_NORMAL)
    submission = models.ForeignKey('submissions.Submission', related_name='reports',
                                   on_delete=models.CASCADE)
    report_nr = models.PositiveSmallIntegerField(default=0,
                                                 help_text='This number is a unique number '
                                                           'refeering to the Report nr. of '
                                                           'the Submission')
    vetted_by = models.ForeignKey('scipost.Contributor', related_name="report_vetted_by",
                                  blank=True, null=True, on_delete=models.CASCADE)
    # `invited' filled from RefereeInvitation objects at moment of report submission
    invited = models.BooleanField(default=False)
    # `flagged' if author of report has been flagged by submission authors (surname check only)
    flagged = models.BooleanField(default=False)
    author = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE,
                               related_name='reports')
Jean-Sébastien Caux's avatar
Jean-Sébastien Caux committed
    qualification = models.PositiveSmallIntegerField(
        null=True, blank=True, choices=REFEREE_QUALIFICATION,
        verbose_name="Qualification to referee this: I am")
    strengths = models.TextField(blank=True)
    weaknesses = models.TextField(blank=True)
    report = models.TextField(blank=True)
    requested_changes = models.TextField(verbose_name="requested changes", blank=True)

Jorran de Wit's avatar
Jorran de Wit committed
    # Comments can be added to a Submission
    comments = GenericRelation('comments.Comment', related_query_name='reports')

    validity = models.PositiveSmallIntegerField(choices=RANKING_CHOICES,
                                                null=True, blank=True)
    significance = models.PositiveSmallIntegerField(choices=RANKING_CHOICES,
                                                    null=True, blank=True)
    originality = models.PositiveSmallIntegerField(choices=RANKING_CHOICES,
                                                   null=True, blank=True)
    clarity = models.PositiveSmallIntegerField(choices=RANKING_CHOICES,
                                               null=True, blank=True)
    formatting = models.SmallIntegerField(choices=QUALITY_SPEC, null=True, blank=True,
Jean-Sébastien Caux's avatar
Jean-Sébastien Caux committed
                                          verbose_name="Quality of paper formatting")
    grammar = models.SmallIntegerField(choices=QUALITY_SPEC, null=True, blank=True,
Jean-Sébastien Caux's avatar
Jean-Sébastien Caux committed
                                       verbose_name="Quality of English grammar")
    recommendation = models.SmallIntegerField(null=True, blank=True, choices=REPORT_REC)
    remarks_for_editors = models.TextField(blank=True,
                                           verbose_name='optional remarks for the Editors only')
    needs_doi = models.NullBooleanField(default=None)
    doideposit_needs_updating = models.BooleanField(default=False)
    genericdoideposit = GenericRelation('journals.GenericDOIDeposit',
                                        related_query_name='genericdoideposit')
Jorran de Wit's avatar
Jorran de Wit committed
    doi_label = models.CharField(max_length=200, blank=True)
Jean-Sébastien Caux's avatar
Jean-Sébastien Caux committed
    anonymous = models.BooleanField(default=True, verbose_name='Publish anonymously')
    pdf_report = models.FileField(upload_to='UPLOADS/REPORTS/%Y/%m/', max_length=200, blank=True)
Jorran de Wit's avatar
Jorran de Wit committed

Jorran de Wit's avatar
Jorran de Wit committed
    date_submitted = models.DateTimeField('date submitted')
    created = models.DateTimeField(auto_now_add=True)
    modified = models.DateTimeField(auto_now=True)

    # Attachment
    file_attachment = models.FileField(
        upload_to='uploads/reports/%Y/%m/%d/', blank=True,
        validators=[validate_file_extension, validate_max_file_size],
        storage=SecureFileStorage())
    objects = ReportQuerySet.as_manager()
        unique_together = ('submission', 'report_nr')
        default_related_name = 'reports'
        ordering = ['-date_submitted']

        """Summerize the RefereeInvitation's basic information."""
        return (self.author.user.first_name + ' ' + self.author.user.last_name + ' on ' +
                self.submission.title[:50] + ' by ' + self.submission.author_list[:50])

Jorran de Wit's avatar
Jorran de Wit committed
    def save(self, *args, **kwargs):
        """Update report number before saving on creation."""
Jorran de Wit's avatar
Jorran de Wit committed
        if not self.report_nr:
Jorran de Wit's avatar
Jorran de Wit committed
            new_report_nr = self.submission.reports.aggregate(
                models.Max('report_nr')).get('report_nr__max')
            if new_report_nr:
                new_report_nr += 1
            else:
                new_report_nr = 1
            self.report_nr = new_report_nr
Jorran de Wit's avatar
Jorran de Wit committed
        return super().save(*args, **kwargs)

    def get_absolute_url(self):
        """Return url of the Report on the Submission detail page."""
        return self.submission.get_absolute_url() + '#report_' + str(self.report_nr)

Jorran de Wit's avatar
Jorran de Wit committed
    def get_notification_url(self, url_code):
        """Return url related to the Report by the `url_code` meant for Notifications."""
        if url_code == 'report_form':
            return reverse(
                'submissions:submit_report', args=(self.submission.preprint.identifier_w_vn_nr,))
        elif url_code == 'editorial_page':
            return reverse(
                'submissions:editorial_page', args=(self.submission.preprint.identifier_w_vn_nr,))
        return self.get_absolute_url()

    def get_attachment_url(self):
        """Return url of the Report its attachment if exists."""
        return reverse('submissions:report_attachment', kwargs={
Jorran de Wit's avatar
Jorran de Wit committed
            'identifier_w_vn_nr': self.submission.preprint.identifier_w_vn_nr,
            'report_nr': self.report_nr})

    @property
    def is_in_draft(self):
        """Return if Report is in draft."""
        return self.status == STATUS_DRAFT

    @property
    def is_vetted(self):
        """Return if Report is publicly available."""
        return self.status == STATUS_VETTED

Jorran de Wit's avatar
Jorran de Wit committed
    @property
    def is_unvetted(self):
        """Return if Report is awaiting vetting."""
        return self.status == STATUS_UNVETTED

    @property
    def is_rejected(self):
        """Return if Report is rejected."""
        return self.status in [
            STATUS_INCORRECT, STATUS_UNCLEAR, STATUS_NOT_USEFUL, STATUS_NOT_ACADEMIC]

    @property
    def notification_name(self):
        """Return string representation of this Report as shown in Notifications."""
Jorran de Wit's avatar
Jorran de Wit committed
        return self.submission.preprint.identifier_w_vn_nr
Jorran de Wit's avatar
Jorran de Wit committed
    @property
    def doi_string(self):
        """Return the doi with the registrant identifier prefix."""
Jorran de Wit's avatar
Jorran de Wit committed
        if self.doi_label:
            return '10.21468/' + self.doi_label
        return ''
Jorran de Wit's avatar
Jorran de Wit committed

Jorran de Wit's avatar
Jorran de Wit committed
    @cached_property
    def title(self):
        """Return the submission's title.

Jorran de Wit's avatar
Jorran de Wit committed
        This property is (mainly) used to let Comments get the title of the Submission without
        overcomplicated logic.
Jorran de Wit's avatar
Jorran de Wit committed
        """
        return self.submission.title
    @property
    def is_followup_report(self):
        """Return if Report is a follow-up Report instead of a regular Report.

        This property is used in the ReportForm, but will be candidate to become a database
        field if this information will become necessary in more general information representation.
Jorran de Wit's avatar
Jorran de Wit committed
        return (self.author.reports.accepted().filter(
Jorran de Wit's avatar
Jorran de Wit committed
            submission__preprint__identifier_wo_vn_nr=self.submission.preprint.identifier_wo_vn_nr,
            submission__preprint__vn_nr__lt=self.submission.preprint.vn_nr).exists())
    @property
    def associated_published_doi(self):
        """Return the related Publication doi.

        Check if the Report relates to a SciPost-published object. If it is, return the doi
        of the published object.
        """
        try:
            publication = Publication.objects.get(
Jorran de Wit's avatar
Jorran de Wit committed
                accepted_submission__preprint__identifier_wo_vn_nr=self.submission.preprint.identifier_wo_vn_nr)
        except Publication.DoesNotExist:
            return None
        return publication.doi_string

Jorran de Wit's avatar
Jorran de Wit committed
    @property
    def relation_to_published(self):
        """Return dictionary with published object information.

        Check if the Report relates to a SciPost-published object. If it is, return a dict with
        info on relation to the published object, based on Crossref's peer review content type.
        try:
            publication = Publication.objects.get(
Jorran de Wit's avatar
Jorran de Wit committed
                accepted_submission__preprint__identifier_wo_vn_nr=self.submission.preprint.identifier_wo_vn_nr)
        except Publication.DoesNotExist:
            return None

        relation = {
            'isReviewOfDOI': publication.doi_string,
            'stage': 'pre-publication',
            'type': 'referee-report',
Jorran de Wit's avatar
Jorran de Wit committed
            'title': 'Report on ' + self.submission.preprint.identifier_w_vn_nr,
            'contributor_role': 'reviewer',
        }
        return relation
    @property
    def citation(self):
        """Return the proper citation format for this Report."""
        citation = ''
        if self.doi_string:
            if self.anonymous:
                citation += 'Anonymous, '
            else:
                citation += '%s %s, ' % (self.author.user.first_name, self.author.user.last_name)
Jorran de Wit's avatar
Jorran de Wit committed
            citation += 'Report on arXiv:%s, ' % self.submission.preprint.identifier_w_vn_nr
Jorran de Wit's avatar
Jorran de Wit committed
            citation += 'delivered %s, ' % self.date_submitted.strftime('%Y-%m-%d')
Jorran de Wit's avatar
Jorran de Wit committed
            citation += 'doi: %s' % self.doi_string
        return citation
    def create_doi_label(self):
        """Create a doi in the default format."""
        Report.objects.filter(id=self.id).update(doi_label='SciPost.Report.{}'.format(self.id))

    def latest_report_from_thread(self):
        """Get latest Report of this Report's author for the Submission thread."""
        return self.author.reports.accepted().filter(
Jorran de Wit's avatar
Jorran de Wit committed
            submission__preprint__identifier_wo_vn_nr=self.submission.preprint.identifier_wo_vn_nr
        ).order_by('submission__preprint__identifier_wo_vn_nr').last()
##########################
# EditorialCommunication #
##########################
class EditorialCommunication(SubmissionRelatedObjectMixin, models.Model):
    """Message between two of the EIC, referees, Editorial Administration and/or authors."""

    submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE)
    referee = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE,
                                blank=True, null=True)
    comtype = models.CharField(max_length=4, choices=ED_COMM_CHOICES)
    timestamp = models.DateTimeField(default=timezone.now)
    text = models.TextField()

    objects = EditorialCommunicationQueryset.as_manager()

    class Meta:
        ordering = ['timestamp']
        default_related_name = 'editorial_communications'
    def __str__(self):
        """Summerize the EditorialCommunication's meta information."""
        output = self.comtype
        if self.referee is not None:
            output += ' ' + self.referee.user.first_name + ' ' + self.referee.user.last_name
        output += ' for submission {title} by {authors}'.format(
            title=self.submission.title[:30],
            authors=self.submission.author_list[:30])
Jorran de Wit's avatar
Jorran de Wit committed
    def get_absolute_url(self):
        """Return url of the related Submission detail page."""
        return self.submission.get_absolute_url()

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


class EICRecommendation(SubmissionRelatedObjectMixin, models.Model):
Jorran de Wit's avatar
Jorran de Wit committed
    """
    The recommendation formulated for a specific Submission, formulated by the EIC.

    The EICRecommendation is the recommendation of a Submission written by the Editor-in-charge
    formulated at the end of the refereeing cycle. It can be voted for by a subset of Fellows and
    should contain the actual publication decision.
Jorran de Wit's avatar
Jorran de Wit committed
    """
    submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE,
                                   related_name='eicrecommendations')
    date_submitted = models.DateTimeField('date submitted', default=timezone.now)
    remarks_for_authors = models.TextField(blank=True, null=True)
    requested_changes = models.TextField(verbose_name="requested changes", blank=True, null=True)
    remarks_for_editorial_college = models.TextField(blank=True,
                                                     verbose_name='optional remarks for the'
                                                                  ' Editorial College')
    recommendation = models.SmallIntegerField(choices=REPORT_REC)
    status = models.CharField(max_length=32, choices=EIC_REC_STATUSES, default=VOTING_IN_PREP)
    version = models.SmallIntegerField(default=1)
    active = models.BooleanField(default=True)
Jorran de Wit's avatar
Jorran de Wit committed
    # status = models.CharField(default='', max_length=180)
    # Editorial Fellows who have assessed this recommendation:
    eligible_to_vote = models.ManyToManyField('scipost.Contributor', blank=True,
                                              related_name='eligible_to_vote')
    voted_for = models.ManyToManyField('scipost.Contributor', blank=True, related_name='voted_for')
    voted_against = models.ManyToManyField('scipost.Contributor', blank=True,
                                           related_name='voted_against')
    voted_abstain = models.ManyToManyField('scipost.Contributor', blank=True,
                                           related_name='voted_abstain')
    voting_deadline = models.DateTimeField('date submitted', default=timezone.now)
Jorran de Wit's avatar
Jorran de Wit committed
    objects = EICRecommendationQuerySet.as_manager()
    class Meta:
        unique_together = ('submission', 'version')
        ordering = ['version']

        """Summerize the EICRecommendation's meta information."""
        return '{title} by {author}, {recommendation} version {version}'.format(
            title=self.submission.title[:20],
            author=self.submission.author_list[:30],
            recommendation=self.get_recommendation_display(),
            version=self.version,
        )
    def get_absolute_url(self):
        """Return url of the Submission detail page.

        Note that the EICRecommendation is not publicly visible, so the use of this url
        is limited.
        """
        return self.submission.get_absolute_url()

    @property
    def notification_name(self):
        """Return string representation of this EICRecommendation as shown in Notifications."""
Jorran de Wit's avatar
Jorran de Wit committed
        return self.submission.preprint.identifier_w_vn_nr
        """Return the number of votes 'for'."""
        return self.voted_for.count()
    @property
    def nr_against(self):
        """Return the number of votes 'against'."""
        return self.voted_against.count()
    @property
    def nr_abstained(self):
        """Return the number of votes 'abstained'."""
        return self.voted_abstain.count()
Jorran de Wit's avatar
Jorran de Wit committed
    @property
    def is_deprecated(self):
        """Check if Recommendation is deprecated."""
        return self.status == DEPRECATED
Jorran de Wit's avatar
Jorran de Wit committed
    @property