From dc620fa5da1d141be342bf87bc0fbb8a96ab096b Mon Sep 17 00:00:00 2001 From: "J.-S. Caux" <J.S.Caux@uva.nl> Date: Mon, 4 Nov 2019 21:34:54 +0100 Subject: [PATCH] Split submissions.models into models module with separate files --- submissions/models.py | 1183 ---------------------- submissions/models/__init__.py | 19 + submissions/models/assignment.py | 93 ++ submissions/models/communication.py | 49 + submissions/models/decision.py | 81 ++ submissions/models/plagiarism.py | 65 ++ submissions/models/recommendation.py | 125 +++ submissions/models/referee_invitation.py | 139 +++ submissions/models/report.py | 248 +++++ submissions/models/submission.py | 439 ++++++++ 10 files changed, 1258 insertions(+), 1183 deletions(-) delete mode 100644 submissions/models.py create mode 100644 submissions/models/__init__.py create mode 100644 submissions/models/assignment.py create mode 100644 submissions/models/communication.py create mode 100644 submissions/models/decision.py create mode 100644 submissions/models/plagiarism.py create mode 100644 submissions/models/recommendation.py create mode 100644 submissions/models/referee_invitation.py create mode 100644 submissions/models/report.py create mode 100644 submissions/models/submission.py diff --git a/submissions/models.py b/submissions/models.py deleted file mode 100644 index 08a0f0340..000000000 --- a/submissions/models.py +++ /dev/null @@ -1,1183 +0,0 @@ -__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, - EIC_REC_PUBLISH, EIC_REC_CHOICES, - ALT_REC_CHOICES, SUBMISSION_TIERS, EDITORIAL_DECISION_CHOICES, - 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, SCIPOST_APPROACHES -from scipost.fields import ChoiceArrayField -from scipost.models import Contributor -from scipost.storage import SecureFileStorage -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', on_delete=models.CASCADE, - 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') - subject_area = models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS, - verbose_name='Primary subject area', default='Phys:QP') - secondary_areas = ChoiceArrayField( - models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS), - blank=True, null=True) - approaches = ChoiceArrayField( - models.CharField(max_length=24, choices=SCIPOST_APPROACHES), - blank=True, null=True, verbose_name='approach(es) [optional]') - 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) - - # 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, - on_delete=models.SET_NULL, 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') - - 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, - on_delete=models.SET_NULL, related_name='submissions', - help_text=( - 'Don\'t find the Proceedings you are looking for? ' - 'Ask the conference organizers to contact our admin ' - 'at admin@scipost.org to set things up.')) - 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=dict, 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) - update_search_index = models.BooleanField(default=True) - - # Topics for semantic linking - topics = models.ManyToManyField('ontology.Topic', blank=True) - - objects = SubmissionQuerySet.as_manager() - - # Temporary - 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( - '-submission_date', '-preprint__vn_nr') - - @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 - - @property - def editorial_decision(self): - """Returns the EditorialDecision (if exists and not deprecated).""" - if self.editorialdecision_set.exists(): - return self.editorialdecision_set.last() - return None - - -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', assignment=self) - mail_sender.send_mail() - - 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') - for_journal = models.ForeignKey('journals.Journal', blank=True, null=True, - on_delete=models.SET_NULL) - recommendation = models.SmallIntegerField(choices=EIC_REC_CHOICES) - 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): - """Summarize 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 SubmissionTiering(models.Model): - """A Fellow's quality tiering of a Submission for a given Journal, given during voting.""" - submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE, - related_name='tierings') - fellow = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE) - for_journal = models.ForeignKey('journals.Journal', on_delete=models.CASCADE) - tier = models.SmallIntegerField(choices=SUBMISSION_TIERS) - - -class AlternativeRecommendation(models.Model): - """Alternative recommendation from voting Fellow who disagrees with EICRec.""" - eicrec = models.ForeignKey('submissions.EICRecommendation', on_delete=models.CASCADE) - fellow = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE) - for_journal = models.ForeignKey('journals.Journal', on_delete=models.CASCADE) - recommendation = models.SmallIntegerField(choices=ALT_REC_CHOICES) - - -class EditorialDecision(models.Model): - """Editorial decision, created by EdAdmin based on voting results. - - If the decision is to publish in the journal the authors submitted to, - or in a higher one (e.g. Selections instead of flagship), authors are - presumed to accept the outcome. - - If the decision is to publish in a Journal which is subsidiary to the one - the authors submitted to, the authors are sent a publication offer which - they have to accept before production is initiated. - """ - - DRAFTED = 0 - FIXED_AND_ACCEPTED = 1 - AWAITING_PUBOFFER_ACCEPTANCE = 2 - PUBOFFER_REFUSED_BY_AUTHORS = 3 - APPEALED_BY_AUTHORS = 4 - UNDER_REVIEW_BY_OMBUDSPERSON = 5 - DEPRECATED = -1 - EDITORIAL_DECISION_STATUSES = ( - (DRAFTED, 'Editorial decision drafted (yet to be communicated to authors)'), - (FIXED_AND_ACCEPTED, 'Editorial decision fixed and (if required) accepted by authors'), - (AWAITING_PUBOFFER_ACCEPTANCE, - 'Awaiting author acceptance of publication offer'), - (PUBOFFER_REFUSED_BY_AUTHORS, - 'Publication offer refused by authors; manuscript will not be produced'), - (APPEALED_BY_AUTHORS, 'Editorial decision appealed by authors'), - (UNDER_REVIEW_BY_OMBUDSPERSON, 'Editorial decision under review by ombudsperson'), - (DEPRECATED, 'Deprecated'), - ) - - submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE) - for_journal = models.ForeignKey('journals.Journal', on_delete=models.CASCADE) - decision = models.SmallIntegerField(choices=EDITORIAL_DECISION_CHOICES) - taken_on = models.DateTimeField(default=timezone.now) - remarks_for_authors = models.TextField( - blank=True, verbose_name='optional remarks for the authors') - remarks_for_editorial_college = models.TextField( - blank=True, verbose_name='optional remarks for the Editorial College') - status = models.SmallIntegerField(choices=EDITORIAL_DECISION_STATUSES) - version = models.SmallIntegerField(default=1) - - class Meta: - ordering = ['version'] - unique_together = ['submission', 'version'] - verbose_name = 'Editorial Decision' - verbose_name_plural = 'Editorial Decisions' - - def __str__(self): - return '%s: %s for journal %s' % (self.submission.preprint.identifier_w_vn_nr, - self.get_decision_display(), - self.for_journal) - - def summary(self): - return 'For Journal %s: %s (status: %s)' % (self.for_journal, - self.get_decision_display(), - self.get_status_display()) - - def get_absolute_url(self): - return reverse('submissions:editorial_decision_detail', - kwargs={'identifier_w_vn_nr': self.submission.preprint.identifier_w_vn_nr}) - - @property - def publish(self): - """Whether the decision is to publish (True) or reject (False).""" - return self.decision == EIC_REC_PUBLISH - - @property - def production_can_proceed(self): - return self.status == self.FIXED_AND_ACCEPTED - - -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 diff --git a/submissions/models/__init__.py b/submissions/models/__init__.py new file mode 100644 index 000000000..39ffd0034 --- /dev/null +++ b/submissions/models/__init__.py @@ -0,0 +1,19 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from .submission import Submission, SubmissionEvent, SubmissionTiering + +from .plagiarism import iThenticateReport + +from .assignment import EditorialAssignment + +from .communication import EditorialCommunication + +from .referee_invitation import RefereeInvitation + +from .report import Report + +from .recommendation import EICRecommendation, AlternativeRecommendation + +from .decision import EditorialDecision diff --git a/submissions/models/assignment.py b/submissions/models/assignment.py new file mode 100644 index 000000000..6af06c8f3 --- /dev/null +++ b/submissions/models/assignment.py @@ -0,0 +1,93 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models +from django.urls import reverse +from django.utils import timezone + +from mails.utils import DirectMailUtil + +from ..behaviors import SubmissionRelatedObjectMixin +from ..constants import ( + ASSIGNMENT_STATUSES, STATUS_PREASSIGNED, STATUS_INVITED, STATUS_REPLACED, + STATUS_ACCEPTED, STATUS_DEPRECATED, STATUS_COMPLETED, + ASSIGNMENT_REFUSAL_REASONS) +from ..managers import EditorialAssignmentQuerySet + + +class EditorialAssignment(SubmissionRelatedObjectMixin, models.Model): + """Fellow assignment to a Submission to become Editor-in-Charge.""" + + 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 the 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', assignment=self) + mail_sender.send_mail() + + EditorialAssignment.objects.filter( + id=self.id).update(date_invited=timezone.now(), status=STATUS_INVITED) + + return True diff --git a/submissions/models/communication.py b/submissions/models/communication.py new file mode 100644 index 000000000..3a02b8b8b --- /dev/null +++ b/submissions/models/communication.py @@ -0,0 +1,49 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models +from django.urls import reverse +from django.utils import timezone + +from ..behaviors import SubmissionRelatedObjectMixin +from ..constants import ED_COMM_CHOICES +from ..managers import EditorialCommunicationQueryset + + +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): + """Summarize 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 the 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() diff --git a/submissions/models/decision.py b/submissions/models/decision.py new file mode 100644 index 000000000..5cda1c68d --- /dev/null +++ b/submissions/models/decision.py @@ -0,0 +1,81 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models +from django.urls import reverse +from django.utils import timezone + +from ..constants import EDITORIAL_DECISION_CHOICES, EIC_REC_PUBLISH + + +class EditorialDecision(models.Model): + """Editorial decision, created by EdAdmin based on voting results. + + If the decision is to publish in the journal the authors submitted to, + or in a higher one (e.g. Selections instead of flagship), authors are + presumed to accept the outcome. + + If the decision is to publish in a Journal which is subsidiary to the one + the authors submitted to, the authors are sent a publication offer which + they have to accept before production is initiated. + """ + + DRAFTED = 0 + FIXED_AND_ACCEPTED = 1 + AWAITING_PUBOFFER_ACCEPTANCE = 2 + PUBOFFER_REFUSED_BY_AUTHORS = 3 + APPEALED_BY_AUTHORS = 4 + UNDER_REVIEW_BY_OMBUDSPERSON = 5 + DEPRECATED = -1 + EDITORIAL_DECISION_STATUSES = ( + (DRAFTED, 'Editorial decision drafted (yet to be communicated to authors)'), + (FIXED_AND_ACCEPTED, 'Editorial decision fixed and (if required) accepted by authors'), + (AWAITING_PUBOFFER_ACCEPTANCE, + 'Awaiting author acceptance of publication offer'), + (PUBOFFER_REFUSED_BY_AUTHORS, + 'Publication offer refused by authors; manuscript will not be produced'), + (APPEALED_BY_AUTHORS, 'Editorial decision appealed by authors'), + (UNDER_REVIEW_BY_OMBUDSPERSON, 'Editorial decision under review by ombudsperson'), + (DEPRECATED, 'Deprecated'), + ) + + submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE) + for_journal = models.ForeignKey('journals.Journal', on_delete=models.CASCADE) + decision = models.SmallIntegerField(choices=EDITORIAL_DECISION_CHOICES) + taken_on = models.DateTimeField(default=timezone.now) + remarks_for_authors = models.TextField( + blank=True, verbose_name='optional remarks for the authors') + remarks_for_editorial_college = models.TextField( + blank=True, verbose_name='optional remarks for the Editorial College') + status = models.SmallIntegerField(choices=EDITORIAL_DECISION_STATUSES) + version = models.SmallIntegerField(default=1) + + class Meta: + ordering = ['version'] + unique_together = ['submission', 'version'] + verbose_name = 'Editorial Decision' + verbose_name_plural = 'Editorial Decisions' + + def __str__(self): + return '%s: %s for journal %s' % (self.submission.preprint.identifier_w_vn_nr, + self.get_decision_display(), + self.for_journal) + + def summary(self): + return 'For Journal %s: %s (status: %s)' % (self.for_journal, + self.get_decision_display(), + self.get_status_display()) + + def get_absolute_url(self): + return reverse('submissions:editorial_decision_detail', + kwargs={'identifier_w_vn_nr': self.submission.preprint.identifier_w_vn_nr}) + + @property + def publish(self): + """Whether the decision is to publish (True) or reject (False).""" + return self.decision == EIC_REC_PUBLISH + + @property + def production_can_proceed(self): + return self.status == self.FIXED_AND_ACCEPTED diff --git a/submissions/models/plagiarism.py b/submissions/models/plagiarism.py new file mode 100644 index 000000000..d30e65819 --- /dev/null +++ b/submissions/models/plagiarism.py @@ -0,0 +1,65 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models +from django.urls import reverse + +from ..constants import PLAGIARISM_STATUSES, STATUS_WAITING + +from scipost.behaviors import TimeStampedModel + + +class iThenticateReport(TimeStampedModel): + """iThenticate plagiarism report for a Submission.""" + + 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): + """Summary of 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): + """On save, touch the Submission's latest update timestamp.""" + 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 the url of the Submission's plagiarism report 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 diff --git a/submissions/models/recommendation.py b/submissions/models/recommendation.py new file mode 100644 index 000000000..5efa98e7f --- /dev/null +++ b/submissions/models/recommendation.py @@ -0,0 +1,125 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.db import models +from django.utils import timezone + +from ..behaviors import SubmissionRelatedObjectMixin +from ..constants import ( + EIC_REC_CHOICES, EIC_REC_STATUSES, DECISION_FIXED, DEPRECATED, + VOTING_IN_PREP, ALT_REC_CHOICES) +from ..managers import EICRecommendationQuerySet + + +class EICRecommendation(SubmissionRelatedObjectMixin, models.Model): + """ + A recommendation formulated by the EIC for a specific Submission. + + The EICRecommendation on a Submission is formulated by the Editor-in-charge + at the end of the refereeing cycle. If it recommends a minor/major revision, + it is communicated directly to the authors. If it recommends to publish or + reject, it is voted on by chosen Fellows of the appropriate Editorial College. + """ + + 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') + for_journal = models.ForeignKey('journals.Journal', blank=True, null=True, + on_delete=models.SET_NULL) + recommendation = models.SmallIntegerField(choices=EIC_REC_CHOICES) + 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): + """Summarize 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 the 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 AlternativeRecommendation(models.Model): + """Alternative recommendation from voting Fellow who disagrees with EICRec.""" + eicrec = models.ForeignKey('submissions.EICRecommendation', on_delete=models.CASCADE) + fellow = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE) + for_journal = models.ForeignKey('journals.Journal', on_delete=models.CASCADE) + recommendation = models.SmallIntegerField(choices=ALT_REC_CHOICES) diff --git a/submissions/models/referee_invitation.py b/submissions/models/referee_invitation.py new file mode 100644 index 000000000..af3e5ed89 --- /dev/null +++ b/submissions/models/referee_invitation.py @@ -0,0 +1,139 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +import datetime + +from django.db import models +from django.urls import reverse +from django.utils import timezone + +from scipost.constants import TITLE_CHOICES + +from ..behaviors import SubmissionRelatedObjectMixin +from ..constants import ASSIGNMENT_NULLBOOL, ASSIGNMENT_REFUSAL_REASONS +from ..managers import RefereeInvitationQuerySet + + +class RefereeInvitation(SubmissionRelatedObjectMixin, models.Model): + """Invitation to an active professional scientist to referee a Submission. + + A RefereeInvitation represents an invitation to a Contributor + or a non-registered scientist to write a Report for a specific Submission. + The instance will register the response to the invitation and + the current status of the 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 diff --git a/submissions/models/report.py b/submissions/models/report.py new file mode 100644 index 000000000..cf33b6ad5 --- /dev/null +++ b/submissions/models/report.py @@ -0,0 +1,248 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.contrib.contenttypes.fields import GenericRelation +from django.db import models +from django.urls import reverse +from django.utils.functional import cached_property + +from scipost.storage import SecureFileStorage +from comments.behaviors import validate_file_extension, validate_max_file_size +from journals.models import Publication + +from ..behaviors import SubmissionRelatedObjectMixin +from ..constants import ( + REPORT_TYPES, REPORT_NORMAL, + REPORT_STATUSES, STATUS_DRAFT, STATUS_UNVETTED, STATUS_VETTED, + STATUS_INCORRECT, STATUS_UNCLEAR, STATUS_NOT_USEFUL, STATUS_NOT_ACADEMIC, + REFEREE_QUALIFICATION, RANKING_CHOICES, QUALITY_SPEC, + REPORT_REC) +from ..managers import ReportQuerySet + + +class Report(SubmissionRelatedObjectMixin, models.Model): + """Report on a Submission, written by a Contributor.""" + + 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 becomes 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 does, 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() diff --git a/submissions/models/submission.py b/submissions/models/submission.py new file mode 100644 index 000000000..0841678c0 --- /dev/null +++ b/submissions/models/submission.py @@ -0,0 +1,439 @@ +__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.db import models +from django.db.models import Q +from django.urls import reverse +from django.utils import timezone +from django.utils.functional import cached_property + +from scipost.behaviors import TimeStampedModel +from scipost.constants import SCIPOST_DISCIPLINES, SCIPOST_SUBJECT_AREAS, SCIPOST_APPROACHES +from scipost.fields import ChoiceArrayField +from scipost.models import Contributor + +from comments.models import Comment + +from ..behaviors import SubmissionRelatedObjectMixin +from ..constants import ( + SUBMISSION_TYPE, + SUBMISSION_STATUS, STATUS_INCOMING, STATUS_UNASSIGNED, STATUS_PREASSIGNED, + STATUS_EIC_ASSIGNED, SUBMISSION_UNDER_CONSIDERATION, + STATUS_FAILED_PRESCREENING, STATUS_RESUBMITTED, STATUS_ACCEPTED, + STATUS_REJECTED, STATUS_WITHDRAWN, STATUS_PUBLISHED, + SUBMISSION_CYCLES, CYCLE_DEFAULT, CYCLE_SHORT, CYCLE_DIRECT_REC, + EVENT_TYPES, EVENT_GENERAL, EVENT_FOR_AUTHOR, EVENT_FOR_EIC, + SUBMISSION_TIERS) +from ..managers import SubmissionQuerySet, SubmissionEventQuerySet +from ..refereeing_cycles import ShortCycle, DirectCycle, RegularCycle + + +class Submission(models.Model): + """A Submission is a preprint sent to SciPost for consideration. + + The class collects all the information necessary to run the editorial + process. + """ + + preprint = models.OneToOneField('preprints.Preprint', on_delete=models.CASCADE, + 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') + subject_area = models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS, + verbose_name='Primary subject area', default='Phys:QP') + secondary_areas = ChoiceArrayField( + models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS), + blank=True, null=True) + approaches = ChoiceArrayField( + models.CharField(max_length=24, choices=SCIPOST_APPROACHES), + blank=True, null=True, verbose_name='approach(es) [optional]') + 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) + + # 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, + on_delete=models.SET_NULL, 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') + + 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, + on_delete=models.SET_NULL, related_name='submissions', + help_text=( + 'Don\'t find the Proceedings you are looking for? ' + 'Ask the conference organizers to contact our admin ' + 'at admin@scipost.org to set things up.')) + 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=dict, 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) + update_search_index = models.BooleanField(default=True) + + # Topics for semantic linking + topics = models.ManyToManyField('ontology.Topic', blank=True) + + objects = SubmissionQuerySet.as_manager() + + # Temporary + 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): + """Summarize 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 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 requires 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 its 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 thread.""" + return Submission.objects.public().filter(thread_hash=self.thread_hash).order_by( + '-submission_date', '-preprint__vn_nr') + + @cached_property + def other_versions(self): + """Return other Submissions in the database in this thread.""" + return self.get_other_versions().order_by('-preprint__vn_nr') + + def get_other_versions(self): + """Return queryset of other Submissions with this thread.""" + 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 + + @property + def editorial_decision(self): + """Returns the EditorialDecision (if exists and not deprecated).""" + if self.editorialdecision_set.exists(): + return self.editorialdecision_set.last() + return None + + +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): + """Summarize the SubmissionEvent's meta information.""" + return '%s: %s' % (str(self.submission), self.get_event_display()) + + +class SubmissionTiering(models.Model): + """A Fellow's quality tiering of a Submission for a given Journal, given during voting.""" + submission = models.ForeignKey('submissions.Submission', on_delete=models.CASCADE, + related_name='tierings') + fellow = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE) + for_journal = models.ForeignKey('journals.Journal', on_delete=models.CASCADE) + tier = models.SmallIntegerField(choices=SUBMISSION_TIERS) -- GitLab