from django import forms from django.contrib.auth.models import Group from django.db import transaction from django.utils import timezone from guardian.shortcuts import assign_perm from .constants import ASSIGNMENT_BOOL, ASSIGNMENT_REFUSAL_REASONS, STATUS_RESUBMITTED,\ REPORT_ACTION_CHOICES, REPORT_REFUSAL_CHOICES, STATUS_REVISION_REQUESTED,\ STATUS_REJECTED, STATUS_REJECTED_VISIBLE, STATUS_RESUBMISSION_INCOMING,\ STATUS_DRAFT, STATUS_UNVETTED, REPORT_ACTION_ACCEPT, REPORT_ACTION_REFUSE,\ STATUS_VETTED from .exceptions import InvalidReportVettingValue from .models import Submission, RefereeInvitation, Report, EICRecommendation, EditorialAssignment from scipost.constants import SCIPOST_SUBJECT_AREAS from scipost.services import ArxivCaller from scipost.models import Contributor from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Div, Field, HTML, Submit import strings class SubmissionSearchForm(forms.Form): author = forms.CharField(max_length=100, required=False, label="Author(s)") title = forms.CharField(max_length=100, required=False) abstract = forms.CharField(max_length=1000, required=False) subject_area = forms.CharField(max_length=10, required=False, widget=forms.Select( choices=((None, 'Show all'),) + SCIPOST_SUBJECT_AREAS[0][1])) def search_results(self): """Return all Submission objects according to search""" return Submission.objects.public_newest().filter( title__icontains=self.cleaned_data.get('title', ''), author_list__icontains=self.cleaned_data.get('author', ''), abstract__icontains=self.cleaned_data.get('abstract', ''), subject_area__icontains=self.cleaned_data.get('subject_area', '') ) ############################### # Submission and resubmission # ############################### class SubmissionChecks: """ Use this class as a blueprint containing checks which should be run in multiple forms. """ is_resubmission = False last_submission = None def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Prefill `is_resubmission` property if data is coming from initial data if kwargs.get('initial', None): if kwargs['initial'].get('is_resubmission', None): self.is_resubmission = kwargs['initial']['is_resubmission'] in ('True', True) # `is_resubmission` property if data is coming from (POST) request if kwargs.get('data', None): if kwargs['data'].get('is_resubmission', None): self.is_resubmission = kwargs['data']['is_resubmission'] in ('True', True) def _submission_already_exists(self, identifier): if Submission.objects.filter(arxiv_identifier_w_vn_nr=identifier).exists(): error_message = 'This preprint version has already been submitted to SciPost.' raise forms.ValidationError(error_message, code='duplicate') def _call_arxiv(self, identifier): caller = ArxivCaller(identifier) if caller.is_valid: self.arxiv_data = ArxivCaller(identifier).data self.metadata = ArxivCaller(identifier).metadata else: error_message = 'A preprint associated to this identifier does not exist.' raise forms.ValidationError(error_message) def _submission_is_already_published(self, identifier): published_id = None if 'arxiv_doi' in self.arxiv_data: published_id = self.arxiv_data['arxiv_doi'] elif 'arxiv_journal_ref' in self.arxiv_data: published_id = self.arxiv_data['arxiv_journal_ref'] if published_id: error_message = ('This paper has been published under DOI %(published_id)s' '. Please comment on the published version.'), raise forms.ValidationError(error_message, code='published', params={'published_id': published_id}) def _submission_previous_version_is_valid_for_submission(self, identifier): '''Check if previous submitted versions have the appropriate status.''' identifiers = self.identifier_into_parts(identifier) submission = (Submission.objects .filter(arxiv_identifier_wo_vn_nr=identifiers['arxiv_identifier_wo_vn_nr']) .order_by('arxiv_vn_nr').last()) # If submissions are found; check their statuses if submission: self.last_submission = submission if submission.status == STATUS_REVISION_REQUESTED: self.is_resubmission = True elif submission.status in [STATUS_REJECTED, STATUS_REJECTED_VISIBLE]: error_message = ('This arXiv preprint has previously undergone refereeing ' 'and has been rejected. Resubmission is only possible ' 'if the manuscript has been substantially reworked into ' 'a new arXiv submission with distinct identifier.') raise forms.ValidationError(error_message) else: error_message = ('There exists a preprint with this arXiv identifier ' 'but an earlier version number, which is still undergoing ' 'peer refereeing. ' 'A resubmission can only be performed after request ' 'from the Editor-in-charge. Please wait until the ' 'closing of the previous refereeing round and ' 'formulation of the Editorial Recommendation ' 'before proceeding with a resubmission.') raise forms.ValidationError(error_message) def submission_is_resubmission(self): return self.is_resubmission def identifier_into_parts(self, identifier): data = { 'arxiv_identifier_w_vn_nr': identifier, 'arxiv_identifier_wo_vn_nr': identifier.rpartition('v')[0], 'arxiv_vn_nr': int(identifier.rpartition('v')[2]) } return data def do_pre_checks(self, identifier): self._submission_already_exists(identifier) self._call_arxiv(identifier) self._submission_is_already_published(identifier) self._submission_previous_version_is_valid_for_submission(identifier) class SubmissionIdentifierForm(SubmissionChecks, forms.Form): IDENTIFIER_PATTERN_NEW = r'^[0-9]{4,}.[0-9]{4,5}v[0-9]{1,2}$' IDENTIFIER_PLACEHOLDER = 'new style (with version nr) ####.####(#)v#(#)' identifier = forms.RegexField(regex=IDENTIFIER_PATTERN_NEW, strip=True, # help_text=strings.arxiv_query_help_text, error_messages={'invalid': strings.arxiv_query_invalid}, widget=forms.TextInput({'placeholder': IDENTIFIER_PLACEHOLDER})) def clean_identifier(self): identifier = self.cleaned_data['identifier'] self.do_pre_checks(identifier) return identifier def _gather_data_from_last_submission(self): '''Return dictionary with data coming from previous submission version.''' if self.submission_is_resubmission(): data = { 'is_resubmission': True, 'discipline': self.last_submission.discipline, 'domain': self.last_submission.domain, 'referees_flagged': self.last_submission.referees_flagged, 'referees_suggested': self.last_submission.referees_suggested, 'secondary_areas': self.last_submission.secondary_areas, 'subject_area': self.last_submission.subject_area, 'submitted_to_journal': self.last_submission.submitted_to_journal, 'submission_type': self.last_submission.submission_type, } return data or {} def request_arxiv_preprint_form_prefill_data(self): '''Return dictionary to prefill `RequestSubmissionForm`.''' form_data = self.arxiv_data form_data.update(self.identifier_into_parts(self.cleaned_data['identifier'])) if self.submission_is_resubmission(): form_data.update(self._gather_data_from_last_submission()) return form_data class RequestSubmissionForm(SubmissionChecks, forms.ModelForm): class Meta: model = Submission fields = [ 'is_resubmission', 'discipline', 'submitted_to_journal', 'submission_type', 'domain', 'subject_area', 'secondary_areas', 'title', 'author_list', 'abstract', 'arxiv_identifier_w_vn_nr', 'arxiv_link', 'author_comments', 'list_of_changes', 'remarks_for_editors', 'referees_suggested', 'referees_flagged' ] widgets = { 'is_resubmission': forms.HiddenInput(), 'arxiv_identifier_w_vn_nr': forms.HiddenInput(), 'secondary_areas': forms.SelectMultiple(choices=SCIPOST_SUBJECT_AREAS) } def __init__(self, *args, **kwargs): self.requested_by = kwargs.pop('requested_by', None) super().__init__(*args, **kwargs) if not self.submission_is_resubmission(): # These fields are only available for resubmissions del self.fields['author_comments'] del self.fields['list_of_changes'] else: self.fields['author_comments'].widget.attrs.update({ 'placeholder': 'Your resubmission letter (will be viewable online)', }) self.fields['list_of_changes'].widget.attrs.update({ 'placeholder': 'Give a point-by-point list of changes (will be viewable online)'}) # Update placeholder for the other fields self.fields['arxiv_link'].widget.attrs.update({ 'placeholder': 'ex.: arxiv.org/abs/1234.56789v1'}) self.fields['abstract'].widget.attrs.update({'cols': 100}) self.fields['remarks_for_editors'].widget.attrs.update({ 'placeholder': 'Any private remarks (for the editors only)', }) self.fields['referees_suggested'].widget.attrs.update({ 'placeholder': 'Optional: names of suggested referees', 'rows': 3}) self.fields['referees_flagged'].widget.attrs.update({ 'placeholder': ('Optional: names of referees whose reports should' ' be treated with caution (+ short reason)'), 'rows': 3}) def clean(self, *args, **kwargs): """ Do all prechecks which are also done in the prefiller. """ cleaned_data = super().clean(*args, **kwargs) self.do_pre_checks(cleaned_data['arxiv_identifier_w_vn_nr']) return cleaned_data def clean_author_list(self): """ Important check! The submitting user must be an author of the submission. Also possibly may be extended to check permissions and give ultimate submission power to certain user groups. """ author_list = self.cleaned_data['author_list'] if not self.requested_by.last_name.lower() in author_list.lower(): error_message = ('Your name does not match that of any of the authors. ' 'You are not authorized to submit this preprint.') raise forms.ValidationError(error_message, code='not_an_author') return author_list @transaction.atomic def copy_and_save_data_from_resubmission(self, submission): """ Fill given Submission with data coming from last_submission in the SubmissionChecks blueprint. """ if not self.last_submission: raise Submission.DoesNotExist # Open for comment and reporting submission.open_for_reporting = True submission.open_for_commenting = True # Close last submission self.last_submission.is_current = False self.last_submission.open_for_reporting = False self.last_submission.status = STATUS_RESUBMITTED self.last_submission.save() # Editor-in-charge submission.editor_in_charge = self.last_submission.editor_in_charge submission.status = STATUS_RESUBMISSION_INCOMING # Author claim fields submission.authors.add(*self.last_submission.authors.all()) submission.authors_claims.add(*self.last_submission.authors_claims.all()) submission.authors_false_claims.add(*self.last_submission.authors_false_claims.all()) submission.save() return submission @transaction.atomic def reassign_eic_and_admins(self, submission): # Assign permissions assign_perm('can_take_editorial_actions', submission.editor_in_charge.user, submission) ed_admins = Group.objects.get(name='Editorial Administrators') assign_perm('can_take_editorial_actions', ed_admins, submission) # Assign editor assignment = EditorialAssignment( submission=submission, to=submission.editor_in_charge, accepted=True ) assignment.save() submission.save() return submission @transaction.atomic def save(self): """ Prefill instance before save. Because of the ManyToManyField on `authors`, commit=False for this form is disabled. Saving the form without the database call may loose `authors` data without notice. """ submission = super().save(commit=False) submission.submitted_by = self.requested_by.contributor # Save metadata directly from ArXiv call without possible user interception submission.metadata = self.metadata # Update identifiers identifiers = self.identifier_into_parts(submission.arxiv_identifier_w_vn_nr) submission.arxiv_identifier_wo_vn_nr = identifiers['arxiv_identifier_wo_vn_nr'] submission.arxiv_vn_nr = identifiers['arxiv_vn_nr'] # Save submission.save() if self.submission_is_resubmission(): submission = self.copy_and_save_data_from_resubmission(submission) submission = self.reassign_eic_and_admins(submission) submission.authors.add(self.requested_by.contributor) return submission class SubmissionReportsForm(forms.ModelForm): class Meta: model = Submission fields = ['pdf_refereeing_pack'] ###################### # Editorial workflow # ###################### class AssignSubmissionForm(forms.Form): def __init__(self, *args, **kwargs): discipline = kwargs.pop('discipline') super(AssignSubmissionForm, self).__init__(*args, **kwargs) self.fields['editor_in_charge'] = forms.ModelChoiceField( queryset=Contributor.objects.filter(user__groups__name='Editorial College', user__contributor__discipline=discipline, ).order_by('user__last_name'), required=True, label='Select an Editor-in-charge') class ConsiderAssignmentForm(forms.Form): accept = forms.ChoiceField(widget=forms.RadioSelect, choices=ASSIGNMENT_BOOL, label="Are you willing to take charge of this Submission?") refusal_reason = forms.ChoiceField(choices=ASSIGNMENT_REFUSAL_REASONS, required=False) class RefereeSelectForm(forms.Form): last_name = forms.CharField() def __init__(self, *args, **kwargs): super(RefereeSelectForm, self).__init__(*args, **kwargs) self.fields['last_name'].widget.attrs.update( {'size': 20, 'placeholder': 'Search in contributors database'}) class RefereeRecruitmentForm(forms.ModelForm): class Meta: model = RefereeInvitation fields = ['title', 'first_name', 'last_name', 'email_address'] def __init__(self, *args, **kwargs): super(RefereeRecruitmentForm, self).__init__(*args, **kwargs) self.fields['first_name'].widget.attrs.update({'size': 20}) self.fields['last_name'].widget.attrs.update({'size': 20}) self.helper = FormHelper() self.helper.layout = Layout( Div(Field('title'), Field('first_name'), Field('last_name'), Field('email_address'), Submit('submit', 'Send invitation', css_class="submitButton"), css_class="flex-whitebox320") ) class ConsiderRefereeInvitationForm(forms.Form): accept = forms.ChoiceField(widget=forms.RadioSelect, choices=ASSIGNMENT_BOOL, label="Are you willing to referee this Submission?") refusal_reason = forms.ChoiceField(choices=ASSIGNMENT_REFUSAL_REASONS, required=False) class SetRefereeingDeadlineForm(forms.Form): deadline = forms.DateField(required=False, label='', widget=forms.SelectDateWidget) class VotingEligibilityForm(forms.Form): def __init__(self, *args, **kwargs): discipline = kwargs.pop('discipline') subject_area = kwargs.pop('subject_area') super(VotingEligibilityForm, self).__init__(*args, **kwargs) self.fields['eligible_Fellows'] = forms.ModelMultipleChoiceField( queryset=Contributor.objects.filter( user__groups__name__in=['Editorial College'], user__contributor__discipline=discipline, user__contributor__expertises__contains=[subject_area] ).order_by('user__last_name'), widget=forms.CheckboxSelectMultiple({'checked': 'checked'}), required=True, label='Eligible for voting', ) ############ # Reports: ############ class ReportPDFForm(forms.ModelForm): class Meta: model = Report fields = ['pdf_report'] class ReportForm(forms.ModelForm): class Meta: model = Report fields = ['qualification', 'strengths', 'weaknesses', 'report', 'requested_changes', 'validity', 'significance', 'originality', 'clarity', 'formatting', 'grammar', 'recommendation', 'remarks_for_editors', 'anonymous'] def __init__(self, *args, **kwargs): if kwargs.get('instance'): if kwargs['instance'].is_followup_report: # Prefill data from latest report in the series latest_report = kwargs['instance'].latest_report_from_series() kwargs.update({ 'initial': { 'qualification': latest_report.qualification, 'anonymous': latest_report.anonymous } }) super(ReportForm, self).__init__(*args, **kwargs) self.fields['strengths'].widget.attrs.update({ 'placeholder': ('Give a point-by-point ' '(numbered 1-, 2-, ...) list of the paper\'s strengths'), 'rows': 10, 'cols': 100 }) self.fields['weaknesses'].widget.attrs.update({ 'placeholder': ('Give a point-by-point ' '(numbered 1-, 2-, ...) list of the paper\'s weaknesses'), 'rows': 10, 'cols': 100 }) self.fields['report'].widget.attrs.update({'placeholder': 'Your general remarks', 'rows': 10, 'cols': 100}) self.fields['requested_changes'].widget.attrs.update({ 'placeholder': 'Give a numbered (1-, 2-, ...) list of specifically requested changes', 'cols': 100 }) # If the Report is not a followup: Explicitly assign more fields as being required! if not self.instance.is_followup_report: required_fields = [ 'strengths', 'weaknesses', 'requested_changes', 'validity', 'significance', 'originality', 'clarity', 'formatting', 'grammar' ] for field in required_fields: self.fields[field].required = True # Let user know the field is required! for field in self.fields: if self.fields[field].required: self.fields[field].label += ' *' def save(self, submission): """ Update meta data if ModelForm is submitted (non-draft). Possibly overwrite the default status if user asks for saving as draft. """ report = super().save(commit=False) report.submission = submission report.date_submitted = timezone.now() # Save with right status asked by user if 'save_draft' in self.data: report.status = STATUS_DRAFT elif 'save_submit' in self.data: report.status = STATUS_UNVETTED # Update invitation and report meta data if exist invitation = submission.referee_invitations.filter(referee=report.author).first() if invitation: invitation.fulfilled = True invitation.save() report.invited = True # Check if report author if the report is being flagged on the submission if submission.referees_flagged: if report.author.user.last_name in submission.referees_flagged: report.flagged = True report.save() return report class VetReportForm(forms.Form): action_option = forms.ChoiceField(widget=forms.RadioSelect, choices=REPORT_ACTION_CHOICES, required=True, label='Action') refusal_reason = forms.ChoiceField(choices=REPORT_REFUSAL_CHOICES, required=False) email_response_field = forms.CharField(widget=forms.Textarea(), label='Justification (optional)', required=False) report = forms.ModelChoiceField(queryset=Report.objects.awaiting_vetting(), required=True, widget=forms.HiddenInput()) def __init__(self, *args, **kwargs): super(VetReportForm, self).__init__(*args, **kwargs) self.fields['email_response_field'].widget.attrs.update({ 'placeholder': ('Optional: give a textual justification ' '(will be included in the email to the Report\'s author)'), 'rows': 5 }) def clean_refusal_reason(self): '''Require a refusal reason if report is rejected.''' reason = self.cleaned_data['refusal_reason'] if self.cleaned_data['action_option'] == REPORT_ACTION_REFUSE: if not reason: self.add_error('refusal_reason', 'A reason must be given to refuse a report.') return reason def process_vetting(self, current_contributor): '''Set the right report status and update submission fields if needed.''' report = self.cleaned_data['report'] report.vetted_by = current_contributor if self.cleaned_data['action_option'] == REPORT_ACTION_ACCEPT: # Accept the report as is report.status = STATUS_VETTED report.submission.latest_activity = timezone.now() report.submission.save() elif self.cleaned_data['action_option'] == REPORT_ACTION_REFUSE: # The report is rejected report.status = self.cleaned_data['refusal_reason'] else: raise InvalidReportVettingValue(self.cleaned_data['action_option']) report.save() return report ################### # Communications # ################### class EditorialCommunicationForm(forms.Form): text = forms.CharField(widget=forms.Textarea(), label='') def __init__(self, *args, **kwargs): super(EditorialCommunicationForm, self).__init__(*args, **kwargs) self.fields['text'].widget.attrs.update( {'rows': 5, 'cols': 50, 'placeholder': 'Write your message in this box.'}) ###################### # EIC Recommendation # ###################### class EICRecommendationForm(forms.ModelForm): class Meta: model = EICRecommendation fields = ['recommendation', 'remarks_for_authors', 'requested_changes', 'remarks_for_editorial_college'] def __init__(self, *args, **kwargs): super(EICRecommendationForm, self).__init__(*args, **kwargs) self.fields['remarks_for_authors'].widget.attrs.update( {'placeholder': 'Your general remarks for the authors', 'rows': 10, 'cols': 100}) self.fields['requested_changes'].widget.attrs.update( {'placeholder': 'If you request revisions, give a numbered (1-, 2-, ...) list of specifically requested changes', 'cols': 100}) self.fields['remarks_for_editorial_college'].widget.attrs.update( {'placeholder': 'If you recommend to accept or refuse, the Editorial College will vote; write any relevant remarks for the EC here.'}) ############### # Vote form # ############### class RecommendationVoteForm(forms.Form): vote = forms.ChoiceField(widget=forms.RadioSelect, choices=[('agree', 'Agree'), ('disagree', 'Disagree'), ('abstain', 'Abstain')], label='', ) remark = forms.CharField(widget=forms.Textarea(), label='', required=False) def __init__(self, *args, **kwargs): super(RecommendationVoteForm, self).__init__(*args, **kwargs) self.fields['remark'].widget.attrs.update( {'rows': 3, 'cols': 30, 'placeholder': 'Your remarks (optional)'}) self.helper = FormHelper() self.helper.layout = Layout( Div( Div( HTML('<h3>Your position on this recommendation:</h3>'), Field('vote'), css_class='flex-Fellowactionbox'), Div(Field('remark'), css_class='flex-Fellowactionbox'), Div(Submit('submit', 'Cast your vote', css_class='submitButton'), css_class='flex-Fellowactionbox'), css_class='flex-container') ) class SubmissionCycleChoiceForm(forms.ModelForm): referees_reinvite = forms.ModelMultipleChoiceField(queryset=RefereeInvitation.objects.none(), widget=forms.CheckboxSelectMultiple({ 'checked': 'checked'}), required=False, label='Reinvite referees') class Meta: model = Submission fields = ('refereeing_cycle',) widgets = {'refereeing_cycle': forms.RadioSelect} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['refereeing_cycle'].default = None other_submission = self.instance.other_versions.first() if other_submission: self.fields['referees_reinvite'].queryset = other_submission.referee_invitations.all()