Newer
Older
__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)"
__license__ = "AGPL v3"
import feedparser
from django.contrib.postgres.fields import JSONField
from django.contrib.contenttypes.fields import GenericRelation
from django.utils import timezone
from django.utils.functional import cached_property
from .behaviors import SubmissionRelatedObjectMixin
ASSIGNMENT_REFUSAL_REASONS, ASSIGNMENT_NULLBOOL, SUBMISSION_TYPE, STATUS_PREASSIGNED,
ED_COMM_CHOICES, REFEREE_QUALIFICATION, QUALITY_SPEC, RANKING_CHOICES, STATUS_INVITED,
REPORT_STATUSES, STATUS_UNVETTED, STATUS_INCOMING, STATUS_EIC_ASSIGNED,
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)
from comments.behaviors import validate_file_extension, validate_max_file_size
from scipost.constants import TITLE_CHOICES
from scipost.constants import SCIPOST_DISCIPLINES, SCIPOST_SUBJECT_AREAS
from scipost.fields import ChoiceArrayField
from journals.constants import SCIPOST_JOURNALS_DOMAINS
from journals.models import Publication
"""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.
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)
# Submission status fields
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)
visible_pool = models.BooleanField("Is visible in the Pool", default=False)
is_resubmission_of = models.ForeignKey('self', blank=True, null=True, related_name='successor')
thread_hash = models.UUIDField(default=uuid.uuid4)
_is_resubmission = models.BooleanField(default=False)
max_length=30, choices=SUBMISSION_CYCLES, default=CYCLE_DEFAULT, blank=True)
fellows = models.ManyToManyField('colleges.Fellowship', blank=True,
subject_area = models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS,
verbose_name='Primary subject area', default='Phys:QP')
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')
voting_fellows = models.ManyToManyField('colleges.Fellowship', blank=True,
related_name='voting_pool')
submitted_to = models.ForeignKey('journals.Journal', on_delete=models.CASCADE)
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()
# 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')
# 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)')
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)
# Temporary
needs_conflicts_update = models.BooleanField(default=False)
invitation_order = models.IntegerField(default=0)
"""Prefill some fields before saving."""
obj = super().save(*args, **kwargs)
if hasattr(self, 'cycle'):
self.set_cycle()
"""Summerize 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.preprint.vn_nr) + ')'
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
"""Update latest activity timestamp."""
Submission.objects.filter(id=self.id).update(latest_activity=timezone.now())
"""Return Comments on Submissions, Reports and other Comments."""
return Comment.objects.filter(
Q(submissions=self) | Q(reports__submission=self) |
Q(comments__reports__submission=self) | Q(comments__submissions=self)).distinct()
"""Get cycle object that's relevant for the Submission."""
if not hasattr(self, '_cycle'):
def set_cycle(self):
"""Set cycle to the Submission on request."""
if self.refereeing_cycle == CYCLE_SHORT:
elif self.refereeing_cycle == CYCLE_DIRECT_REC:
"""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
"""Return string representation of this Submission as shown in Notifications."""
def eic_recommendation_required(self):
"""Return if Submission needs 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 SUBMISSION_UNDER_CONSIDERATION
@property
def open_for_resubmission(self):
"""Check if Submission has fixed EICRecommendation asking for revision."""
if self.status != STATUS_EIC_ASSIGNED:
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."""
if self.status in [STATUS_INCOMING, STATUS_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 [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)
@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
"""Return the submission_date of the first Submission in the thread."""
thread_hash=self.thread_hash, is_resubmission_of__isnull=True).first().submission_date
@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.
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
"""
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
"""Return all (public) Submissions in the database in this ArXiv identifier series."""
return Submission.objects.public().filter(thread_hash=self.thread_hash).order_by(
def other_versions(self):
"""Return other Submissions in the database in this ArXiv identifier series."""
def get_other_versions(self):
"""Return queryset of other Submissions with this ArXiv identifier series."""
return Submission.objects.filter(thread_hash=self.thread_hash).exclude(pk=self.id)
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):
"""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
submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE,
related_name='events')
event = models.CharField(max_length=4, choices=EVENT_TYPES, default=EVENT_GENERAL)
objects = SubmissionEventQuerySet.as_manager()
class Meta:
ordering = ['-created']
"""Summerize the SubmissionEvent's meta information."""
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.
submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE)
to = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE)
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)
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'))
"""Return url of the assignment's processing page."""
return reverse('submissions:assignment_request', args=(self.id,))
"""Return string representation of this EditorialAssigment as shown in Notifications."""
@property
def preassigned(self):
return self.status == STATUS_PREASSIGNED
@property
def invited(self):
return self.status == STATUS_INVITED
@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)
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)
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,
blank=True, null=True)
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()
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'))
"""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
"""Return string representation of this RefereeInvitation as shown in Notifications."""
@property
def related_report(self):
"""Return the Report that's been created for this invitation."""
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
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())
"""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
###########
# Reports:
###########
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')
null=True, blank=True, choices=REFEREE_QUALIFICATION,
verbose_name="Qualification to referee this: I am")
# Text-based reporting
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)
# Comments can be added to a Submission
comments = GenericRelation('comments.Comment', related_query_name='reports')
validity = models.PositiveSmallIntegerField(choices=RANKING_CHOICES,
significance = models.PositiveSmallIntegerField(choices=RANKING_CHOICES,
originality = models.PositiveSmallIntegerField(choices=RANKING_CHOICES,
clarity = models.PositiveSmallIntegerField(choices=RANKING_CHOICES,
null=True, blank=True)
formatting = models.SmallIntegerField(choices=QUALITY_SPEC, null=True, blank=True,
grammar = models.SmallIntegerField(choices=QUALITY_SPEC, null=True, blank=True,
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')
doi_label = models.CharField(max_length=200, blank=True)
anonymous = models.BooleanField(default=True, verbose_name='Publish anonymously')
pdf_report = models.FileField(upload_to='UPLOADS/REPORTS/%Y/%m/', max_length=200, blank=True)
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')
def __str__(self):
"""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])
"""Update report number before saving on creation."""
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
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)
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={
'identifier_w_vn_nr': self.submission.preprint.identifier_w_vn_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
@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]
"""Return string representation of this Report as shown in Notifications."""
"""Return the doi with the registrant identifier prefix."""
if self.doi_label:
return '10.21468/' + self.doi_label
"""Return the submission's title.
This property is (mainly) used to let Comments get the title of the Submission without
"""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.
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(
accepted_submission__preprint__identifier_wo_vn_nr=self.submission.preprint.identifier_wo_vn_nr)
except Publication.DoesNotExist:
return None
return publication.doi_string
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(
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',
'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."""
if self.doi_string:
if self.anonymous:
citation += 'Anonymous, '
else:
citation += '%s %s, ' % (self.author.user.first_name, self.author.user.last_name)
citation += 'Report on arXiv:%s, ' % self.submission.preprint.identifier_w_vn_nr
citation += 'delivered %s, ' % self.date_submitted.strftime('%Y-%m-%d')
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(
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()
default_related_name = 'editorial_communications'
"""Summerize the EditorialCommunication's meta information."""
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])
return output
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):
"""
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.
submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE,
related_name='eicrecommendations')

Jean-Sébastien Caux
committed
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)
# 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)
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,
)
"""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()
"""Return string representation of this EICRecommendation as shown in Notifications."""
@property
def nr_for(self):
"""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()
@property
def is_deprecated(self):
"""Check if Recommendation is deprecated."""
return self.status == DEPRECATED