From 14cc9d01bd27b72496496fa1300dc909de060e5b Mon Sep 17 00:00:00 2001 From: Jorran de Wit <jorrandewit@outlook.com> Date: Fri, 25 May 2018 17:11:14 +0200 Subject: [PATCH] Coi 2. --- conflicts/admin.py | 9 +- conflicts/apps.py | 4 + conflicts/constants.py | 13 ++- conflicts/management/__init__.py | 0 conflicts/management/commands/__init__.py | 0 .../commands/update_coi_via_arxiv.py | 34 +++++++ conflicts/migrations/0001_initial.py | 30 ++++++ .../migrations/0002_auto_20180525_1433.py | 34 +++++++ .../migrations/0003_auto_20180525_1438.py | 27 ++++++ ..._conflictofinterest_related_submissions.py | 21 +++++ conflicts/models.py | 38 ++++++-- conflicts/services.py | 93 +++++++++++++++++++ conflicts/templatetags/__init__.py | 0 conflicts/templatetags/conflict_tags.py | 13 +++ conflicts/tests.py | 4 + conflicts/views.py | 4 + scipost/services.py | 2 + submissions/managers.py | 4 + .../0026_submission_needs_conflicts_update.py | 20 ++++ submissions/models.py | 3 +- .../admin/submission_prescreening.html | 62 +++++++++++++ 21 files changed, 405 insertions(+), 10 deletions(-) create mode 100644 conflicts/management/__init__.py create mode 100644 conflicts/management/commands/__init__.py create mode 100644 conflicts/management/commands/update_coi_via_arxiv.py create mode 100644 conflicts/migrations/0001_initial.py create mode 100644 conflicts/migrations/0002_auto_20180525_1433.py create mode 100644 conflicts/migrations/0003_auto_20180525_1438.py create mode 100644 conflicts/migrations/0004_conflictofinterest_related_submissions.py create mode 100644 conflicts/services.py create mode 100644 conflicts/templatetags/__init__.py create mode 100644 conflicts/templatetags/conflict_tags.py create mode 100644 submissions/migrations/0026_submission_needs_conflicts_update.py diff --git a/conflicts/admin.py b/conflicts/admin.py index 8c38f3f3d..c36ef1606 100644 --- a/conflicts/admin.py +++ b/conflicts/admin.py @@ -1,3 +1,10 @@ +__copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + from django.contrib import admin -# Register your models here. +from .models import ConflictOfInterest + + +admin.site.register(ConflictOfInterest) diff --git a/conflicts/apps.py b/conflicts/apps.py index 25d0ba12f..749dd0604 100644 --- a/conflicts/apps.py +++ b/conflicts/apps.py @@ -1,3 +1,7 @@ +__copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + from django.apps import AppConfig diff --git a/conflicts/constants.py b/conflicts/constants.py index 923e33295..7e27f64ad 100644 --- a/conflicts/constants.py +++ b/conflicts/constants.py @@ -1,7 +1,18 @@ +__copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + STATUS_UNVERIFIED, STATUS_VERIFIED = 'unverified', 'verified' STATUS_DEPRECATED = 'deprecated' -CONFLIC_OF_INTEREST_STATUSES = ( +CONFLICT_OF_INTEREST_STATUSES = ( (STATUS_UNVERIFIED, 'Unverified'), (STATUS_VERIFIED, 'Verified by Admin'), (STATUS_DEPRECATED, 'Deprecated'), ) + + +TYPE_OTHER, TYPE_COAUTHOR = 'other', 'coauthor' +CONFLICT_OF_INTEREST_TYPES = ( + (TYPE_COAUTHOR, 'Co-authorship'), + (TYPE_OTHER, 'Other'), +) diff --git a/conflicts/management/__init__.py b/conflicts/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/conflicts/management/commands/__init__.py b/conflicts/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/conflicts/management/commands/update_coi_via_arxiv.py b/conflicts/management/commands/update_coi_via_arxiv.py new file mode 100644 index 000000000..259c7abd4 --- /dev/null +++ b/conflicts/management/commands/update_coi_via_arxiv.py @@ -0,0 +1,34 @@ +__copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.core.management.base import BaseCommand + +from scipost.models import Contributor +from submissions.models import Submission + +from ...services import ArxivCaller + + +class Command(BaseCommand): + """Update Conflict of Interests using arXiv API.""" + + def add_arguments(self, parser): + parser.add_argument( + '--arxiv', action='store', default=0, type=str, + dest='arxiv', help='ArXiv id of Submission to force update of conflicts.') + + def handle(self, *args, **options): + if options['arxiv']: + submissions = Submission.objects.filter(arxiv_identifier_w_vn_nr=options['arxiv']) + else: + submissions = Submission.objects.needs_conflicts_update() + + for sub in submissions: + fellow_ids = sub.fellows.values_list('id', flat=True) + fellows = Contributor.objects.filter(fellowships__id__in=fellow_ids) + if 'entries' in sub.metadata: + caller = ArxivCaller(sub.metadata['entries'][0]['authors']) + caller.compare_to(fellows) + caller.add_to_db(sub) + Submission.objects.filter(id=sub.id).update(needs_conflicts_update=False) diff --git a/conflicts/migrations/0001_initial.py b/conflicts/migrations/0001_initial.py new file mode 100644 index 000000000..46182d027 --- /dev/null +++ b/conflicts/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-05-25 07:52 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('scipost', '0014_auto_20180414_2218'), + ('journals', '0030_merge_20180519_2204'), + ] + + operations = [ + migrations.CreateModel( + name='ConflictOfInterest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('unverified', 'Unverified'), ('verified', 'Verified by Admin'), ('deprecated', 'Deprecated')], default='unverified', max_length=16)), + ('type', models.CharField(choices=[('coauthor', 'Co-authorship'), ('other', 'Other')], default='other', max_length=16)), + ('origin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conflicts', to='scipost.Contributor')), + ('to_contributor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='scipost.Contributor')), + ('to_unregistered', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='journals.UnregisteredAuthor')), + ], + ), + ] diff --git a/conflicts/migrations/0002_auto_20180525_1433.py b/conflicts/migrations/0002_auto_20180525_1433.py new file mode 100644 index 000000000..0337dd083 --- /dev/null +++ b/conflicts/migrations/0002_auto_20180525_1433.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-05-25 12:33 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('conflicts', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='conflictofinterest', + name='to_unregistered', + ), + migrations.AddField( + model_name='conflictofinterest', + name='conflict_title', + field=models.CharField(blank=True, max_length=256), + ), + migrations.AddField( + model_name='conflictofinterest', + name='conflict_url', + field=models.URLField(blank=True), + ), + migrations.AddField( + model_name='conflictofinterest', + name='to_name', + field=models.CharField(blank=True, max_length=128), + ), + ] diff --git a/conflicts/migrations/0003_auto_20180525_1438.py b/conflicts/migrations/0003_auto_20180525_1438.py new file mode 100644 index 000000000..70026c687 --- /dev/null +++ b/conflicts/migrations/0003_auto_20180525_1438.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-05-25 12:38 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('conflicts', '0002_auto_20180525_1433'), + ] + + operations = [ + migrations.AddField( + model_name='conflictofinterest', + name='created', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='conflictofinterest', + name='modified', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/conflicts/migrations/0004_conflictofinterest_related_submissions.py b/conflicts/migrations/0004_conflictofinterest_related_submissions.py new file mode 100644 index 000000000..cfaf39a93 --- /dev/null +++ b/conflicts/migrations/0004_conflictofinterest_related_submissions.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-05-25 13:50 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0026_submission_needs_conflicts_update'), + ('conflicts', '0003_auto_20180525_1438'), + ] + + operations = [ + migrations.AddField( + model_name='conflictofinterest', + name='related_submissions', + field=models.ManyToManyField(blank=True, related_name='conflicts', to='submissions.Submission'), + ), + ] diff --git a/conflicts/models.py b/conflicts/models.py index 2178ec6e8..380c54347 100644 --- a/conflicts/models.py +++ b/conflicts/models.py @@ -1,18 +1,42 @@ +__copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django.core.exceptions import ValidationError from django.db import models -from .constants import CONFLIC_OF_INTEREST_STATUSES, STATUS_UNVERIFIED +from .constants import ( + CONFLICT_OF_INTEREST_STATUSES, STATUS_UNVERIFIED, CONFLICT_OF_INTEREST_TYPES, TYPE_OTHER) class ConflictOfInterest(models.Model): """Conflict of Interest is a flagged relation between scientists.""" status = models.CharField( - max_length=16, choices=CONFLIC_OF_INTEREST_STATUSES, default=STATUS_UNVERIFIED) - origin = models.ForeignKey('scipost.Contributor') - to_contributor = models.ForeignKey('scipost.Contributor', blank=True, null=True) - to_unregistered = models.ForeignKey('journals.UnregisteredAuthor', blank=True, null=True) + max_length=16, choices=CONFLICT_OF_INTEREST_STATUSES, default=STATUS_UNVERIFIED) + origin = models.ForeignKey('scipost.Contributor', related_name='conflicts') + to_contributor = models.ForeignKey( + 'scipost.Contributor', blank=True, null=True, related_name='+') + to_name = models.CharField(max_length=128, blank=True) + type = models.CharField( + max_length=16, choices=CONFLICT_OF_INTEREST_TYPES, default=TYPE_OTHER) + + # Meta + conflict_url = models.URLField(blank=True) + conflict_title = models.CharField(max_length=256, blank=True) + related_submissions = models.ManyToManyField( + 'submissions.Submission', blank=True, related_name='conflicts') + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + def __str__(self): + _str = '{} {}'.format(self.get_type_display(), self.origin) + if self.conflict_title: + _str += ' on {}...'.format(self.conflict_title[:20]) + return _str def clean(self): + """Check if Conflict of Interest is complete.""" if not self.to_contributor and not self.to_unregistered: - raise NotImplementedError('Choose something...') - raise NotImplementedError('Fine.') + raise ValidationError( + 'Conflict of Interest must be related to Contributor or UnregisteredAuthor.') diff --git a/conflicts/services.py b/conflicts/services.py new file mode 100644 index 000000000..dcbee9f3a --- /dev/null +++ b/conflicts/services.py @@ -0,0 +1,93 @@ +__copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +# Module for making external api calls as needed in the submissions cycle +import feedparser +import logging + +from scipost.models import Contributor + +from .constants import TYPE_COAUTHOR +from .models import ConflictOfInterest + +logger = logging.getLogger('scipost.conflicts.arxiv') + + +class ArxivCaller: + """ArXiv Caller will help retrieve author data from arXiv API.""" + + query_base_url = 'https://export.arxiv.org/api/query?search_query={query}' + + def __init__(self, author_list): + """Init ArXivCaller with list `author_list` as per Submission meta data.""" + self.author_query = '' + self.conflicts = [] + + last_names = [] + for author in author_list: + # Gather author data to do conflict-of-interest queries with + last_names.append(author['name'].split()[-1]) + self.author_query = '+OR+'.join(last_names) + + logger.info('Update from ArXiv for author list [{0}]'.format(author_list[:30])) + + def compare_to(self, contributors_list): + """Add list of Contributors to compare the `author_list` with.""" + for contributor in contributors_list: + # For each fellow found, so a query with the authors to check for conflicts + search_query = 'au:({fellow}+AND+({authors}))'.format( + fellow=contributor.user.last_name, authors=self.author_query) + queryurl = self.query_base_url.format(query=search_query) + queryurl += '&sortBy=submittedDate&sortOrder=descending&max_results=5' + queryurl = queryurl.replace(' ', '+') # Fallback for some last names with spaces + + # Call the API + response_content = feedparser.parse(queryurl) + valid = False + logger.info('GET [{contributor}] [request] | {url}'.format( + contributor=contributor.user.last_name, url=queryurl)) + if self._search_result_present(response_content): + valid = True + self.conflicts.append({ + 'from': contributor, + 'results': response_content + }) + logger.info('{result} | {response}.'.format( + result='Found results' if valid else 'No results', + response=response_content)) + return + + def _search_result_present(self, data): + if len(data.get('entries', [])) > 0: + return 'title' in data['entries'][0] + return False + + def add_to_db(self, submission=None): + """Add found conflicts to database as unverfied co-author conflicts.""" + logger.info('Pushing {} query results to database.'.format(len(self.conflicts))) + + count = 0 + for conflict in self.conflicts: + # Loop all separate queries + for result in conflict['results']['entries']: + # Read all results in one query + for author in result['authors']: + # Try to find an registered Contributor first. + contributor = Contributor.objects.active().filter( + user__first_name__istartswith=author['name'][0], # Only use first letter due to database inconsistency + user__last_name__iendswith=author['name'].split(' ')[-1]).first() + + coi, new = ConflictOfInterest.objects.get_or_create( + origin=conflict['from'], + to_contributor=contributor, + to_name=author['name'], + conflict_url=result['link'].replace('http:', 'https:'), + defaults={ + 'conflict_title': result['title'], + 'type': TYPE_COAUTHOR}) + if submission: + coi.related_submissions.add(submission) + if new: + count += 1 + logger.info('{} new conflicts added.'.format(count)) diff --git a/conflicts/templatetags/__init__.py b/conflicts/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/conflicts/templatetags/conflict_tags.py b/conflicts/templatetags/conflict_tags.py new file mode 100644 index 000000000..01c2a5bc7 --- /dev/null +++ b/conflicts/templatetags/conflict_tags.py @@ -0,0 +1,13 @@ +__copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django import template + +register = template.Library() + + +@register.filter +def filter_for_contributor(qs, contributor): + """Filter ConflictOfInterest query for specific Contributor.""" + return qs.filter(origin=contributor) diff --git a/conflicts/tests.py b/conflicts/tests.py index 7ce503c2d..9135c42ab 100644 --- a/conflicts/tests.py +++ b/conflicts/tests.py @@ -1,3 +1,7 @@ +__copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + from django.test import TestCase # Create your tests here. diff --git a/conflicts/views.py b/conflicts/views.py index 91ea44a21..3dcbaf4f5 100644 --- a/conflicts/views.py +++ b/conflicts/views.py @@ -1,3 +1,7 @@ +__copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + from django.shortcuts import render # Create your views here. diff --git a/scipost/services.py b/scipost/services.py index 1d3f8b975..dfb01269e 100644 --- a/scipost/services.py +++ b/scipost/services.py @@ -98,6 +98,8 @@ class DOICaller: class ArxivCaller: + """ArXiv Caller will help retrieve Submission data from arXiv API.""" + query_base_url = 'https://export.arxiv.org/api/query?id_list=%s' def __init__(self, identifier): diff --git a/submissions/managers.py b/submissions/managers.py index c7632ce37..91b6d4e65 100644 --- a/submissions/managers.py +++ b/submissions/managers.py @@ -192,6 +192,10 @@ class SubmissionQuerySet(models.QuerySet): """Return Submission that allow for commenting.""" return self.filter(open_for_commenting=True) + def needs_conflicts_update(self): + """Return set of Submissions that need an ConflictOfInterest update.""" + return self.filter(needs_conflicts_update=True) + class SubmissionEventQuerySet(models.QuerySet): def for_author(self): diff --git a/submissions/migrations/0026_submission_needs_conflicts_update.py b/submissions/migrations/0026_submission_needs_conflicts_update.py new file mode 100644 index 000000000..7bf7d57d4 --- /dev/null +++ b/submissions/migrations/0026_submission_needs_conflicts_update.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-05-25 12:44 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0025_auto_20180520_1430'), + ] + + operations = [ + migrations.AddField( + model_name='submission', + name='needs_conflicts_update', + field=models.BooleanField(default=True), + ), + ] diff --git a/submissions/models.py b/submissions/models.py index 3211a217d..3eba5ff28 100644 --- a/submissions/models.py +++ b/submissions/models.py @@ -104,7 +104,8 @@ class Submission(models.Model): # Comments can be added to a Submission comments = GenericRelation('comments.Comment', related_query_name='submissions') - # iThenticate Reports + # 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, diff --git a/submissions/templates/submissions/admin/submission_prescreening.html b/submissions/templates/submissions/admin/submission_prescreening.html index 8ebe8e61d..27e3507cc 100644 --- a/submissions/templates/submissions/admin/submission_prescreening.html +++ b/submissions/templates/submissions/admin/submission_prescreening.html @@ -2,6 +2,7 @@ {% load bootstrap %} {% load scipost_extras %} +{% load conflict_tags %} {% block pagetitle %}: pre-screening ({{ submission.arxiv_identifier_w_vn_nr }}){% endblock pagetitle %} @@ -69,6 +70,67 @@ <p>No Plagiarism Report found. <a href="{% url 'submissions:plagiarism' submission.arxiv_identifier_w_vn_nr %}">Run plagiarism check</a>.</p> {% endif %} + <h3>Editorial Pre-assignments</h3> + <div> + + {% if submission.needs_conflicts_update %} + <p> + <i class="fa fa-clock-o text-danger" aria-hidden="true"></i> + <span class="text-muted">Conflict of interest awaiting update. <a href="mailto:techsupport@scipost.org">Contact techsupport</a> if this is not automatically resolved soon.</span> + </p> + {% else %} + <p> + <i class="fa fa-check-circle text-success" aria-hidden="true"></i> + <span class="text-muted">Conflict of interest updated.</span> + <br><br> + + Select Fellows that are eligable to become editor-in-charge and sort them in prefered order of invitation. Every [time], the next selected Fellow in line will be invited until an editor is assigned. + </p> + + <table class="table"> + <thead> + <tr> + <th></th> + <th>Fellow</th> + <th>Possible conflicts</th> + </tr> + </thead> + <tbody> + {% for fellow in submission.fellows.all %} + <tr> + <td> + <a href="javascript:;"><i class="fa fa-bars" aria-hidden="true"></i></a> + <input type="checkbox" class="ml-2"> + </td> + <td>{{ fellow.contributor }}</td> + <td> + {% for conflict in submission.conflicts.all|filter_for_contributor:fellow.contributor %} + <div class="my-1"> + <a href="{{ conflict.conflict_url }}" target="_blank">{{ conflict.conflict_title }}</a> + <br> + In conflict with: {{ conflict.to_name }} + <br> + {% if conflict.status == 'unverified' %} + <i class="fa fa-question-circle text-warning" aria-hidden="true"></i> + {% elif conflict.status == 'verified' %} + <i class="fa fa-check-circle text-success" aria-hidden="true"></i> + {% endif %} + {{ conflict.get_status_display }}: {{ conflict.get_type_display }}. + + + <br> + </div> + {% empty %} + <em><i class="fa fa-check text-success" aria-hidden="true"></i> No conflicts found</em> + {% endfor %} + </td> + </tr> + {% endfor %} + </tbody> + </table> + {% endif %} + </div> + <h3>Take decision on pre-screening</h3> <form method="post"> {% csrf_token %} -- GitLab