diff --git a/conflicts/admin.py b/conflicts/admin.py index 8c38f3f3dad51e4585f3984282c2a4bec5349c1e..c36ef1606ea15e36a306a5ecb354acde2e56811f 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 25d0ba12fc3ff195cefaa5948cd324da09f14f38..749dd060425e635e5775c6c0e12c156c0d6c3eb0 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 923e332950d5edfa7f60eb0f38666661fb58291e..7e27f64ad714848ce11f6b04e115f78107f7db3d 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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/conflicts/management/commands/__init__.py b/conflicts/management/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 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 0000000000000000000000000000000000000000..259c7abd4f76db5a6e7dafbe8a2d81a155dedf49 --- /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 0000000000000000000000000000000000000000..46182d0270263fe431963197d41bea3a8b90939e --- /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 0000000000000000000000000000000000000000..0337dd0830a663b6838985c90df1ed7369566053 --- /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 0000000000000000000000000000000000000000..70026c68721ca9c1c0a7fec406ee1d0ffcffef5f --- /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 0000000000000000000000000000000000000000..cfaf39a93f87f762fe71443f9e0101fc173218cc --- /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 2178ec6e8599a6f88bb6108062697d4ce6f47ef7..380c543476cadf914ecb0a6658515b1ab63ccb46 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 0000000000000000000000000000000000000000..dcbee9f3a3e21426beaad2f93015f93a9cc60e06 --- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/conflicts/templatetags/conflict_tags.py b/conflicts/templatetags/conflict_tags.py new file mode 100644 index 0000000000000000000000000000000000000000..01c2a5bc701f4670b9edc064f2ee1f17e8d883b5 --- /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 7ce503c2dd97ba78597f6ff6e4393132753573f6..9135c42ab26e15b71fd25dc25d9f92bcec7e676b 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 91ea44a218fbd2f408430959283f0419c921093e..3dcbaf4f5d3078e6fae387d3a53101333eae998e 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 1d3f8b975201925989bc1293324e42290d732aca..dfb01269e9aeb28ab5761836478cdf1cadde1b85 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 c7632ce376d420245dce17675d3c86ee89f98dce..91b6d4e654a08e3548efabda7c25a5663a8d27cf 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 0000000000000000000000000000000000000000..7bf7d57d46008cd4dc5f68b4ebf00894e3a0028c --- /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 3211a217d6c60d270c52944046ef8dc32af970e9..3eba5ff284f8f927455c096e688e5822dcbff0ee 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 8ebe8e61ded688eaa48a2ec20db361fafdfbca7e..27e3507cc63810cd4283011eebc2e424beacc7d4 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 %}