import datetime 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, ED_COMM_CHOICES, REFEREE_QUALIFICATION, QUALITY_SPEC,\ RANKING_CHOICES, REPORT_REC, SUBMISSION_STATUS, STATUS_UNASSIGNED,\ REPORT_STATUSES, STATUS_UNVETTED, SUBMISSION_EIC_RECOMMENDATION_REQUIRED,\ SUBMISSION_CYCLES, CYCLE_DEFAULT, CYCLE_SHORT, CYCLE_DIRECT_REC,\ EVENT_GENERAL, EVENT_TYPES, EVENT_FOR_AUTHOR, EVENT_FOR_EIC from .managers import SubmissionQuerySet, EditorialAssignmentQuerySet, EICRecommendationQuerySet,\ ReportQuerySet, SubmissionEventQuerySet, RefereeInvitationQuerySet from .utils import ShortSubmissionCycle, DirectRecommendationSubmissionCycle,\ GeneralSubmissionCycle from comments.models import Comment from scipost.behaviors import TimeStampedModel from scipost.constants import TITLE_CHOICES from scipost.fields import ChoiceArrayField from scipost.constants import SCIPOST_DISCIPLINES, SCIPOST_SUBJECT_AREAS from journals.constants import SCIPOST_JOURNALS_SUBMIT, SCIPOST_JOURNALS_DOMAINS from journals.models import Publication class Submission(models.Model): """ Submission is a SciPost register of an ArXiv article. This object is the central instance for every action, recommendation, communication, etc. etc. that is related to the refereeing cycle of a Submission. A possible Publication object is later directly related to this Submission instance. """ author_comments = models.TextField(blank=True) author_list = models.CharField(max_length=1000, 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) is_current = models.BooleanField(default=True) is_resubmission = models.BooleanField(default=False) 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) # Refereeing fields status = models.CharField(max_length=30, choices=SUBMISSION_STATUS, default=STATUS_UNASSIGNED) refereeing_cycle = models.CharField(max_length=30, choices=SUBMISSION_CYCLES, default=CYCLE_DEFAULT) 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, null=True, default=None) 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') # Replace this by foreignkey? submitted_to_journal = models.CharField(max_length=30, choices=SCIPOST_JOURNALS_SUBMIT, verbose_name="Journal to be submitted to") 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 Reports 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) objects = SubmissionQuerySet.as_manager() class Meta: app_label = 'submissions' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._update_cycle() def save(self, *args, **kwargs): # Fill `arxiv_identifier_w_vn_nr` as a dummy field for convenience arxiv_w_vn = '{arxiv}v{version}'.format( arxiv=self.arxiv_identifier_wo_vn_nr, version=self.arxiv_vn_nr) self.arxiv_identifier_w_vn_nr = arxiv_w_vn super().save(*args, **kwargs) self._update_cycle() def __str__(self): header = (self.arxiv_identifier_w_vn_nr + ', ' + self.title[:30] + ' by ' + self.author_list[:30]) if self.is_current: header += ' (current version)' else: header += ' (deprecated version ' + str(self.arxiv_vn_nr) + ')' try: header += ' (published as %s (%s))' % ( self.publication.doi_string, self.publication.publication_date.strftime('%Y')) except Publication.DoesNotExist: pass return header def touch(self): """ Update latest activity as a service """ self.latest_activity = timezone.now() self.save() def comments_set_complete(self): """ Return comments to Submission, comments on Reports of Submission and nested comments related to this Submission. """ return Comment.objects.filter(Q(submissions=self) | Q(reports__submission=self) | Q(comments__reports__submission=self) | Q(comments__submissions=self)).distinct() def _update_cycle(self): """ Append the specific submission cycle to the instance to eventually handle the complete submission cycle outside the submission instance itself. """ if self.refereeing_cycle == CYCLE_SHORT: self.cycle = ShortSubmissionCycle(self) elif self.refereeing_cycle == CYCLE_DIRECT_REC: self.cycle = DirectRecommendationSubmissionCycle(self) else: self.cycle = GeneralSubmissionCycle(self) def get_absolute_url(self): return reverse('submissions:submission', args=[self.arxiv_identifier_w_vn_nr]) @property def notification_name(self): return self.arxiv_identifier_w_vn_nr @property def eic_recommendation_required(self): return self.status in SUBMISSION_EIC_RECOMMENDATION_REQUIRED @property def reporting_deadline_has_passed(self): return timezone.now() > self.reporting_deadline @property def original_submission_date(self): return Submission.objects.filter( arxiv_identifier_wo_vn_nr=self.arxiv_identifier_wo_vn_nr).first().submission_date @cached_property def other_versions(self): """ Return all other versions of the Submission that are publicly accessible. """ return Submission.objects.public().filter( arxiv_identifier_wo_vn_nr=self.arxiv_identifier_wo_vn_nr ).exclude(pk=self.id).order_by('-arxiv_vn_nr') @cached_property def other_versions_pool(self): """ Return all other versions of the Submission. """ return Submission.objects.filter( arxiv_identifier_wo_vn_nr=self.arxiv_identifier_wo_vn_nr ).exclude(pk=self.id).order_by('-arxiv_vn_nr') # Underneath: All very inefficient methods as they initiate a new query def count_accepted_invitations(self): return self.referee_invitations.filter(accepted=True).count() def count_declined_invitations(self): return self.referee_invitations.filter(accepted=False).count() def count_pending_invitations(self): return self.referee_invitations.filter(accepted=None).count() def count_invited_reports(self): return self.reports.accepted().filter(invited=True).count() def count_contrib_reports(self): return self.reports.accepted().filter(invited=False).count() def count_obtained_reports(self): return self.reports.accepted().filter(invited__isnull=False).count() def add_general_event(self, message): event = SubmissionEvent( submission=self, event=EVENT_GENERAL, text=message, ) event.save() def add_event_for_author(self, message): event = SubmissionEvent( submission=self, event=EVENT_FOR_AUTHOR, text=message, ) event.save() def add_event_for_eic(self, message): event = SubmissionEvent( submission=self, event=EVENT_FOR_EIC, text=message, ) event.save() class SubmissionEvent(SubmissionRelatedObjectMixin, TimeStampedModel): """ The SubmissionEvent's goal is to act as a messaging/logging model for the Submission cycle. Its main audience will be the author(s) and the Editor-in-charge of a Submission. Be aware! Both the author and editor-in-charge will read the submission event. Make sure the right text is given to the right 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): return '%s: %s' % (str(self.submission), self.get_event_display()) ###################### # Editorial workflow # ###################### class EditorialAssignment(SubmissionRelatedObjectMixin, models.Model): """ EditorialAssignment is a registration for Fellows of their duties of being a Editor-in-charge for a specific Submission. This model could start as a invitation only, which should then be accepted or declined by the invited. """ submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE) to = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE) accepted = models.NullBooleanField(choices=ASSIGNMENT_NULLBOOL, default=None) # attribute `deprecated' becomes True if another Fellow becomes Editor-in-charge deprecated = models.BooleanField(default=False) completed = models.BooleanField(default=False) refusal_reason = models.CharField(max_length=3, choices=ASSIGNMENT_REFUSAL_REASONS, blank=True, null=True) date_created = models.DateTimeField(default=timezone.now) 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): 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 reverse('submissions:assignment_request', args=(self.id,)) @property def notification_name(self): return self.submission.arxiv_identifier_w_vn_nr class RefereeInvitation(SubmissionRelatedObjectMixin, models.Model): 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, default='') last_name = models.CharField(max_length=30, default='') email_address = models.EmailField() # if Contributor not found, person is invited to register invitation_key = models.CharField(max_length=40, default='') 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) 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() def __str__(self): 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 reverse('submissions:accept_or_decline_ref_invitations', args=(self.id,)) @property def referee_str(self): if self.referee: return str(self.referee) return self.last_name + ', ' + self.first_name @property def notification_name(self): return self.submission.arxiv_identifier_w_vn_nr def reset_content(self): 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): """ Both types of reports, invited or contributed. This Report model acts as both a regular `Report` and a `FollowupReport`; A normal Report should have all fields required, whereas a FollowupReport only has the `report` field as a required field. Important note! Due to the construction of the two different types within a single model, it is important to explicitly implement the perticular differences in for example the form used. """ status = models.CharField(max_length=16, choices=REPORT_STATUSES, default=STATUS_UNVETTED) 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) date_submitted = models.DateTimeField('date submitted') author = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE, related_name='reports') qualification = models.PositiveSmallIntegerField( 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() 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(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) objects = ReportQuerySet.as_manager() class Meta: unique_together = ('submission', 'report_nr') default_related_name = 'reports' ordering = ['-date_submitted'] def __str__(self): return (self.author.user.first_name + ' ' + self.author.user.last_name + ' on ' + self.submission.title[:50] + ' by ' + self.submission.author_list[:50]) def get_absolute_url(self): return self.submission.get_absolute_url() + '#report_' + str(self.report_nr) @property def notification_name(self): return self.submission.arxiv_identifier_w_vn_nr @property def doi_string(self): if self.doi_label: return '10.21468/' + self.doi_label return '' @cached_property def title(self): """ This property is (mainly) used to let Comments get the title of the Submission without annoying logic. """ return self.submission.title @property def is_followup_report(self): """ Check if current Report is a `FollowupReport`. A Report is a `FollowupReport` if the author of the report already has a vetted report in the series of the specific Submission. """ return (self.author.reports.accepted().filter( submission__arxiv_identifier_wo_vn_nr=self.submission.arxiv_identifier_wo_vn_nr, submission__arxiv_vn_nr__lt=self.submission.arxiv_vn_nr).exists()) def save(self, *args, **kwargs): # Control Report count per Submission. if not self.report_nr: self.report_nr = self.submission.reports.count() + 1 return super().save(*args, **kwargs) def create_doi_label(self): self.doi_label = 'SciPost.Report.' + str(self.id) self.save() def latest_report_from_series(self): """ Get latest Report from the same author for the Submission series. """ return (self.author.reports.accepted().filter( submission__arxiv_identifier_wo_vn_nr=self.submission.arxiv_identifier_wo_vn_nr) .order_by('submission__arxiv_identifier_wo_vn_nr').last()) @property def relation_to_published(self): """ 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. """ publication = Publication.objects.get( accepted_submission__arxiv_identifier_wo_vn_nr=self.submission.arxiv_identifier_wo_vn_nr) if publication: relation = { 'isReviewOfDOI': publication.doi_string, 'stage': 'pre-publication', 'type': 'referee-report', 'title': 'Report on ' + self.submission.arxiv_identifier_w_vn_nr, 'contributor_role': 'reviewer', } return relation return None @property def citation(self): 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.arxiv_identifier_w_vn_nr citation += 'delivered %s, ' % self.date_submitted.strftime('%Y-%m-%d') citation += 'doi: %s' % self.doi_string return citation ########################## # EditorialCommunication # ########################## class EditorialCommunication(SubmissionRelatedObjectMixin, models.Model): """ Each individual communication between Editor-in-charge to and from Referees and Authors becomes an instance of this class. """ submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE, related_name='editorial_communications') referee = models.ForeignKey('scipost.Contributor', related_name='referee_in_correspondence', blank=True, null=True, on_delete=models.CASCADE) comtype = models.CharField(max_length=4, choices=ED_COMM_CHOICES) timestamp = models.DateTimeField(default=timezone.now) text = models.TextField() class Meta: ordering = ['timestamp'] def __str__(self): output = self.comtype if self.referee is not None: output += ' ' + self.referee.user.first_name + ' ' + self.referee.user.last_name output += (' for submission ' + self.submission.title[:30] + ' by ' + self.submission.author_list[:30]) return output class EICRecommendation(SubmissionRelatedObjectMixin, models.Model): """ The EICRecommendation is the recommendation of a Submission written by the Editor-in-charge made 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) # 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() def __str__(self): return (self.submission.title[:20] + ' by ' + self.submission.author_list[:30] + ', ' + self.get_recommendation_display()) def get_absolute_url(self): # TODO: Fix this weird redirect, but it's neccesary for the notifications to have one. return self.submission.get_absolute_url() @property def notification_name(self): return self.submission.arxiv_identifier_w_vn_nr @property def nr_for(self): return self.voted_for.count() @property def nr_against(self): return self.voted_against.count() @property def nr_abstained(self): return self.voted_abstain.count() class iThenticateReport(TimeStampedModel): """ iThenticateReport is the SciPost register of an iThenticate report. It saves 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) class Meta: verbose_name = 'iThenticate Report' verbose_name_plural = 'iThenticate Reports' def get_absolute_url(self): if hasattr(self, 'to_submission'): return reverse('submissions:plagiarism', kwargs={ 'arxiv_identifier_w_vn_nr': self.to_submission.arxiv_identifier_w_vn_nr}) return None def get_report_url(self): """ Request new read-only url from iThenticate and return. 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) def __str__(self): _str = 'Report {doc_id}'.format(doc_id=self.doc_id) if hasattr(self, 'to_submission'): _str += ' on Submission {arxiv}'.format( arxiv=self.to_submission.arxiv_identifier_w_vn_nr) return _str def save(self, *args, **kwargs): obj = super().save(*args, **kwargs) if hasattr(self, 'to_submission') and kwargs.get('commit', True): self.to_submission.touch() return obj @property def score(self): return self.percent_match