__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" import datetime import feedparser import uuid from django.contrib.postgres.fields import JSONField from django.contrib.contenttypes.fields import GenericRelation from django.utils import timezone from django.db import models 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 ( ASSIGNMENT_REFUSAL_REASONS, ASSIGNMENT_NULLBOOL, SUBMISSION_TYPE, STATUS_PREASSIGNED, ED_COMM_CHOICES, REFEREE_QUALIFICATION, QUALITY_SPEC, RANKING_CHOICES, STATUS_INVITED, SUBMISSION_STATUS, SUBMISSION_UNDER_CONSIDERATION, 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 .refereeing_cycles import ShortCycle, DirectCycle, RegularCycle from comments.behaviors import validate_file_extension, validate_max_file_size 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. """ 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) refereeing_cycle = models.CharField( max_length=30, choices=SUBMISSION_CYCLES, default=CYCLE_DEFAULT, blank=True) fellows = models.ManyToManyField('colleges.Fellowship', blank=True, related_name='pool') 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 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) 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() return obj def __str__(self): """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 def touch(self): """Update latest activity timestamp.""" Submission.objects.filter(id=self.id).update(latest_activity=timezone.now()) def comments_set_complete(self): """Return Comments on Submissions, Reports and other Comments.""" return Comment.objects.filter( Q(submissions=self) | Q(reports__submission=self) | Q(comments__reports__submission=self) | Q(comments__submissions=self)).distinct() @property def cycle(self): """Get cycle object that's relevant for the Submission.""" if not hasattr(self, '_cycle'): self.set_cycle() return self._cycle def set_cycle(self): """Set cycle to the Submission on request.""" if self.refereeing_cycle == CYCLE_SHORT: self._cycle = ShortCycle(self) elif self.refereeing_cycle == CYCLE_DIRECT_REC: self._cycle = DirectCycle(self) else: self._cycle = RegularCycle(self) def get_absolute_url(self): """Return url of the Submission detail page.""" return reverse('submissions:submission', args=(self.preprint.identifier_w_vn_nr,)) def get_notification_url(self, url_code): """Return url related to the Submission by the `url_code` meant for Notifications.""" if url_code == 'editorial_page': return reverse('submissions:editorial_page', args=(self.preprint.identifier_w_vn_nr,)) return self.get_absolute_url() @property def is_resubmission(self): return self.is_resubmission_of is not None @property def notification_name(self): """Return string representation of this Submission as shown in Notifications.""" return self.preprint.identifier_w_vn_nr @property def eic_recommendation_required(self): """Return if Submission 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 @property def original_submission_date(self): """Return the submission_date of the first Submission in the thread.""" return Submission.objects.filter( thread_hash=self.thread_hash, is_resubmission_of__isnull=True).first().submission_date @property def in_refereeing_phase(self): """Check if Submission is in active refereeing phase. This is not meant for functional logic, rather for explanatory functionality to the user. """ if self.eicrecommendations.active().exists(): # Editorial Recommendation is formulated! return False if self.refereeing_cycle == CYCLE_DIRECT_REC: # There's no refereeing in this cycle at all. return False if self.referee_invitations.in_process().exists(): # Some unfinished invitations exist still. return True if self.referee_invitations.awaiting_response().exists(): # Some invitations have been sent out without a response. return True # Maybe: Check for unvetted Reports? return self.status == 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 def thread(self): """Return all (public) Submissions in the database in this ArXiv identifier series.""" return Submission.objects.public().filter(thread_hash=self.thread_hash).order_by( '-preprint__vn_nr', '-submission_date') @cached_property def other_versions(self): """Return other Submissions in the database in this ArXiv identifier series.""" return self.get_other_versions().order_by('-preprint__vn_nr') 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 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.""" 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'] def __str__(self): """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.""" return self.submission.preprint.identifier_w_vn_nr @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) 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) 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'] def __str__(self): """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.""" return self.submission.preprint.identifier_w_vn_nr @property def related_report(self): """Return the Report that's been created for this invitation.""" 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 ########### # 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') qualification = models.PositiveSmallIntegerField( 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') # Qualities: 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, verbose_name="Quality of paper formatting") grammar = models.SmallIntegerField(choices=QUALITY_SPEC, null=True, blank=True, 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') 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() class Meta: unique_together = ('submission', 'report_nr') default_related_name = 'reports' ordering = ['-date_submitted'] 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]) def save(self, *args, **kwargs): """Update report number before saving on creation.""" if not self.report_nr: 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 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) 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, '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 @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.""" return self.submission.preprint.identifier_w_vn_nr @property def doi_string(self): """Return the doi with the registrant identifier prefix.""" if self.doi_label: return '10.21468/' + self.doi_label return '' @cached_property def title(self): """Return the submission's title. This property is (mainly) used to let Comments get the title of the Submission without overcomplicated logic. """ 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. """ return (self.author.reports.accepted().filter( 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 @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( 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.""" citation = '' 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') 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( 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]) 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') 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) # 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) objects = EICRecommendationQuerySet.as_manager() class Meta: unique_together = ('submission', 'version') ordering = ['version'] def __str__(self): """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.""" return self.submission.preprint.identifier_w_vn_nr @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 @property def may_be_reformulated(self): """Check if this EICRecommdation is allowed to be reformulated in a new version.""" if self.status == DEPRECATED: # Already reformulated before; please use the latest version return self.submission.eicrecommendations.last() == self return self.status != DECISION_FIXED def get_other_versions(self): """Return other versions of EICRecommendations for this Submission.""" return self.submission.eicrecommendations.exclude(id=self.id) def get_full_status_display(self): """Return `status` field display plus possible `recommendation` display.""" _str = self.get_status_display() if self.status == DECISION_FIXED and self.submission.status == STATUS_EIC_ASSIGNED: return '{} ({})'.format(_str, self.get_recommendation_display()) return _str class iThenticateReport(TimeStampedModel): """iThenticate report registration. iThenticateReport is the SciPost register of an iThenticate report saving basic information coming from iThenticate into the SciPost database for easy access. """ uploaded_time = models.DateTimeField(null=True, blank=True) processed_time = models.DateTimeField(null=True, blank=True) doc_id = models.IntegerField(primary_key=True) part_id = models.IntegerField(null=True, blank=True) percent_match = models.IntegerField(null=True, blank=True) status = models.CharField(max_length=16, choices=PLAGIARISM_STATUSES, default=STATUS_WAITING) class Meta: verbose_name = 'iThenticate Report' verbose_name_plural = 'iThenticate Reports' def __str__(self): """Summerize the iThenticateReport's meta information.""" _str = 'Report {doc_id}'.format(doc_id=self.doc_id) if hasattr(self, 'to_submission'): _str += ' on Submission {arxiv}'.format( arxiv=self.to_submission.preprint.identifier_w_vn_nr) return _str def save(self, *args, **kwargs): """Update the Submission's latest update timestamp on update.""" obj = super().save(*args, **kwargs) if hasattr(self, 'to_submission') and kwargs.get('commit', True): self.to_submission.touch() return obj def get_absolute_url(self): """Return url of the plagiarism detail page.""" if hasattr(self, 'to_submission'): return reverse( 'submissions:plagiarism', kwargs={'identifier_w_vn_nr': self.to_submission.preprint.identifier_w_vn_nr}) return '' def get_report_url(self): """Request and return new read-only url from the iThenticate API. Note: The read-only link is valid for only 15 minutes, saving may be worthless """ if not self.part_id: return '' from .plagiarism import iThenticate plagiarism = iThenticate() return plagiarism.get_url(self.part_id) @property def score(self): """Return the iThenticate score returned by their API as saved in the database.""" return self.percent_match