diff --git a/submissions/exceptions.py b/submissions/exceptions.py index a6e26c2d6c946fec39d3b402415bd0110e63378a..474d3b343b4eff03158cc7456bd2c57dbcab2d8d 100644 --- a/submissions/exceptions.py +++ b/submissions/exceptions.py @@ -12,3 +12,7 @@ class CycleUpdateDeadlineError(BaseCustomException): class InvalidReportVettingValue(BaseCustomException): pass + + +class ArxivPDFNotFound(Exception): + pass diff --git a/submissions/forms.py b/submissions/forms.py index e6b3cd433e0f6adc4f31f247a07fd4751ce104fa..32c8207409087a1235d003d1828fab7f7b00bd90 100644 --- a/submissions/forms.py +++ b/submissions/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.contrib.auth.models import Group from django.db import transaction from django.utils import timezone @@ -10,17 +11,18 @@ from .constants import ASSIGNMENT_BOOL, ASSIGNMENT_REFUSAL_REASONS, STATUS_RESUB 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 . import exceptions, helpers +from .models import Submission, RefereeInvitation, Report, EICRecommendation, EditorialAssignment,\ + iThenticateReport from scipost.constants import SCIPOST_SUBJECT_AREAS from scipost.services import ArxivCaller from scipost.models import Contributor +import strings from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Div, Field, HTML, Submit - -import strings +import iThenticate class SubmissionSearchForm(forms.Form): @@ -554,7 +556,7 @@ class VetReportForm(forms.Form): # The report is rejected report.status = self.cleaned_data['refusal_reason'] else: - raise InvalidReportVettingValue(self.cleaned_data['action_option']) + raise exceptions.InvalidReportVettingValue(self.cleaned_data['action_option']) report.save() return report @@ -643,3 +645,114 @@ class SubmissionCycleChoiceForm(forms.ModelForm): other_submission = self.instance.other_versions.first() if other_submission: self.fields['referees_reinvite'].queryset = other_submission.referee_invitations.all() + + +class iThenticateReportForm(forms.ModelForm): + class Meta: + model = iThenticateReport + fields = [] + + def __init__(self, submission, *args, **kwargs): + self.submission = submission + super().__init__(*args, **kwargs) + + if kwargs.get('files', {}).get('file'): + # Add file field if file data is coming in! + self.fields['file'] = forms.FileField() + + def clean(self): + cleaned_data = super().clean() + doc_id = self.instance.doc_id + if not doc_id and not self.fields.get('file'): + try: + cleaned_data['document'] = helpers.retrieve_pdf_from_arxiv( + self.submission.arxiv_identifier_w_vn_nr) + except exceptions.ArxivPDFNotFound: + self.add_error(None, ('The pdf could not be found at arXiv.' + ' Please upload the pdf manually.')) + self.fields['file'] = forms.FileField() + elif not doc_id and cleaned_data.get('file'): + cleaned_data['document'] = cleaned_data['file'] + elif doc_id: + self.document_id = doc_id + + # Login client to append login-check to form + self.client = self.get_client() + + # Document (id) is found + if cleaned_data.get('document'): + self.document = cleaned_data['document'] + self.response = self.call_ithenticate() + elif hasattr(self, 'document_id'): + self.response = self.call_ithenticate() + + if self.response: + return cleaned_data + # Don't return anything as someone submitted invalid data for the form at this point! + return None + + def save(self, *args, **kwargs): + if self.instance: + report = self.instance + else: + report = iThenticateReport.objects.get_or_create(doc_id=self.response['data']['id']) + report.submission = self.submission + report.uploaded_time = data['uploaded_time'] + report.processed_time = data['processed_time'] + report.percent_match = data['percent_match'] + report.save() + return report + + def call_ithenticate(self): + if hasattr(self, 'document_id'): + # Update iThenticate status + return self.update_status() + elif hasattr(self, 'document'): + # Upload iThenticate document first time + return self.upload_document() + + def get_client(self): + client = iThenticate.API.Client(settings.ITHENTICATE_USERNAME, + settings.ITHENTICATE_PASSWORD) + if client.login(): + return client + self.add_error(None, "Failed to login to iThenticate.") + return None + + def update_status(self): + client = self.client + response = client.documents.get(self.document_id) + if response['status'] == 200: + return response['data'] + self.add_error(None, "Updating failed. iThenticate didn't return valid data [1]") + self.add_error(None, client.messages[0]) + return None + + def upload_document(self): + client = self.client + + # Get first folder available + # TODO: Fix this ugly piece of crap + folders = client.folders.all() + if folders['status'] == 200: + folder_id = folders['data'][0]['id'] + else: + self.add_error(None, "Uploading failed. iThenticate didn't return valid data [2]") + self.add_error(None, client.messages[0]) + + # Finally, upload the file + author = self.submission.authors.first() + response = client.documents.add( + self.document.read(), + folder_id, + author.user.first_name, + author.user.last_name, + self.submission.title, + ) + + if response['status'] == 200: + return response['data'] + + self.add_error(None, "Updating failed. iThenticate didn't return valid data [3]") + self.add_error(None, client.messages[0]) + return None diff --git a/submissions/helpers.py b/submissions/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..1d7a8b5fdf2eaec109b9304c38f8b82287d243fe --- /dev/null +++ b/submissions/helpers.py @@ -0,0 +1,17 @@ +import requests + +from .exceptions import ArxivPDFNotFound + + +def retrieve_pdf_from_arxiv(arxiv_id): + """ + Try to download the pdf as bytes object from arXiv for a certain arXiv Identifier. + Raise ArxivPDFNotFound instead. + + :arxiv_id: Arxiv Identifier with or without (takes latest version instead) version number + """ + path_to_pdf = 'https://arxiv.org/pdf/{arxiv_id}.pdf'.format(arxiv_id=arxiv_id) + response = requests.get(path_to_pdf) + if response.status_code != 200: + raise ArxivPDFNotFound('No pdf found on arXiv.') + return response.content diff --git a/submissions/mixins.py b/submissions/mixins.py index 4bf8ee5953f1e2d70f5abc7911f9eafb59f8ee3b..0829ac4ebed60bb75c2e93cbf04dc779fc1fea0f 100644 --- a/submissions/mixins.py +++ b/submissions/mixins.py @@ -1,4 +1,6 @@ +from django.urls import NoReverseMatch from django.contrib.auth.mixins import PermissionRequiredMixin +from django.views.generic.list import ListView from .models import Submission @@ -20,7 +22,17 @@ class FriendlyPermissionMixin(PermissionRequiredMixin): return super().dispatch(request, *args, **kwargs) -class SubmissionAdminViewMixin(FriendlyPermissionMixin): +class SubmissionFormViewMixin: + def get_form_kwargs(self): + """ + Ideally all ModelForms on Submission-related objects have a required argument `submission`. + """ + kwargs = super().get_form_kwargs() + kwargs['submission'] = self._original_submission + return kwargs + + +class SubmissionAdminViewMixin(FriendlyPermissionMixin, SubmissionFormViewMixin): """ This mixin will provide all basic methods and checks required for Submission administrational actions regarding Submissions. @@ -38,7 +50,46 @@ class SubmissionAdminViewMixin(FriendlyPermissionMixin): return not self.editorial_page def get_queryset(self): + """ + Return either of two sets of Submissions, with an author filter for the current user. + + This method is used in all Class-Based-Views. However, if one overwrites either one of the + - get_object() + - get_queryset() + methods, please don't forget to call super().method_name() to not remove this filter! + """ qs = super().get_queryset() if self.pool: return qs.get_pool(self.request.user) return qs.filter_editorial_page(self.request.user) + + def get_object(self): + """ + Save the original Submission instance for performance reasons to the view, + which may be used in get_context_data(). + """ + obj = super().get_object() + self.submission = obj + return obj + + def get_context_data(self, *args, **kwargs): + """ + If the main object in a DetailView is not a Submission instance, it will be lost. + Here, explicitly save the Submission instance to the context data. + """ + ctx = super().get_context_data(*args, **kwargs) + + if not ctx.get('submission') and not isinstance(self, ListView): + # Call parent get_object() to explicitly save the submission which is related + # to the view's main object. + ctx['submission'] = self._original_submission + return ctx + + @property + def _original_submission(self): + if hasattr(self, 'submission'): + return self.submission + obj = super().get_object() + if isinstance(obj, Submission): + return obj + return None diff --git a/submissions/models.py b/submissions/models.py index 87af26ed1eb3995690e592f803e259eea45b4594..21cf120c2841a94791c228217ef561534b14a15b 100644 --- a/submissions/models.py +++ b/submissions/models.py @@ -506,6 +506,11 @@ class iThenticateReport(TimeStampedModel): doc_id = models.IntegerField(primary_key=True) percent_match = models.IntegerField(null=True, blank=True) + def get_absolute_url(self): + return reverse('submissions:plagiarism', kwargs={ + 'arxiv_identifier_w_vn_nr': + self.to_submission.arxiv_identifier_w_vn_nr}) + @property def score(self): return self.percent_match diff --git a/submissions/templates/submissions/admin/plagiarism_report.html b/submissions/templates/submissions/admin/plagiarism_report.html index b87381c2a746ebf6dc43df3ad6c54d12fca9a926..fb528064830d7e339e07a046505ee7f9e282702a 100644 --- a/submissions/templates/submissions/admin/plagiarism_report.html +++ b/submissions/templates/submissions/admin/plagiarism_report.html @@ -1,5 +1,7 @@ {% extends 'scipost/_personal_page_base.html' %} +{% load bootstrap %} + {% block pagetitle %}: plagiarism report ({{ submission.arxiv_identifier_w_vn_nr }}){% endblock pagetitle %} {% block breadcrumb_items %} @@ -18,8 +20,9 @@ No Plagiarism Report found. {% endif %} - <form method="post" class="mt-3"> + <form method="post" class="mt-3" enctype="multipart/form-data"> {% csrf_token %} + {{form|bootstrap}} <input type="submit" class="btn btn-primary" value="{% if submission.plagiarism_report %}Update report status{% else %}Submit submission for plagiarism check{% endif %}"> </form> diff --git a/submissions/views.py b/submissions/views.py index 768c220bf3e21250ff9913fa93e883b32c42a1ce..77fc1679ae0f30ee793a7178083e707fb050699e 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1,7 +1,6 @@ import datetime import feedparser -from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.models import Group @@ -13,12 +12,11 @@ from django.template import Template, Context from django.utils import timezone from django.utils.decorators import method_decorator from django.views.generic.detail import DetailView -from django.views.generic.edit import CreateView +from django.views.generic.edit import CreateView, UpdateView from django.views.generic.list import ListView from guardian.decorators import permission_required_or_403 from guardian.shortcuts import assign_perm, get_objects_for_user -import iThenticate from .constants import SUBMISSION_STATUS_VOTING_DEPRECATED, STATUS_VETTED, STATUS_EIC_ASSIGNED,\ SUBMISSION_STATUS_PUBLICLY_INVISIBLE, SUBMISSION_STATUS, ED_COMM_CHOICES,\ @@ -31,7 +29,8 @@ from .forms import SubmissionIdentifierForm, RequestSubmissionForm, SubmissionSe SetRefereeingDeadlineForm, RefereeSelectForm, RefereeRecruitmentForm,\ ConsiderRefereeInvitationForm, EditorialCommunicationForm,\ EICRecommendationForm, ReportForm, VetReportForm, VotingEligibilityForm,\ - SubmissionCycleChoiceForm, ReportPDFForm, SubmissionReportsForm + SubmissionCycleChoiceForm, ReportPDFForm, SubmissionReportsForm,\ + iThenticateReportForm from .utils import SubmissionUtils from scipost.forms import ModifyPersonalMessageForm, RemarkForm @@ -90,6 +89,7 @@ class RequestSubmission(CreateView): messages.warning(self.request, *error_messages) return super().form_invalid(form) + @login_required @permission_required('scipost.can_submit_manuscript', raise_exception=True) def prefill_using_arxiv_identifier(request): @@ -1472,23 +1472,20 @@ class EditorialSummaryView(SubmissionAdminViewMixin, ListView): .get(arxiv_identifier_w_vn_nr=arxiv_id)) except (AssertionError, Submission.DoesNotExist): context['submission'] = None - context['latest_events'] = SubmissionEvent.objects.for_eic()#.last_hours() + context['latest_events'] = SubmissionEvent.objects.for_eic().last_hours() return context -class PlagiarismView(SubmissionAdminViewMixin, DetailView): +class PlagiarismView(SubmissionAdminViewMixin, UpdateView): permission_required = 'scipost.can_do_plagiarism_checks' template_name = 'submissions/admin/plagiarism_report.html' editorial_page = True + success_url = reverse_lazy('submissions:plagiarism') + form_class = iThenticateReportForm - def post(self, request, *args, **kwargs): - client = iThenticate.API.Client(settings.ITHENTICATE_USERNAME, - settings.ITHENTICATE_PASSWORD) - submission = self.get_object() - if submission.plagiarism_report: - # Plagiarism Report needs an update - client.documents.get() - else: - # Plagiarism Report needs to be uploaded still - client.folders.all() - raise NotImplementedError + def get_object(self): + submission = super().get_object() + return submission.plagiarism_report + + # def post(self, request, *args, **kwargs): + # raise NotImplementedError