diff --git a/SciPost_v1/settings/base.py b/SciPost_v1/settings/base.py index 37daa74432221b33bd378ba2bff1548de2f4940a..717768e786de473b2a0e6ec5cbb5c232e2af0999 100644 --- a/SciPost_v1/settings/base.py +++ b/SciPost_v1/settings/base.py @@ -81,7 +81,6 @@ INSTALLED_APPS = ( 'django.contrib.sites', 'django_countries', 'django_extensions', - # 'django_mathjax', 'affiliations', 'ajax_select', 'captcha', @@ -94,7 +93,6 @@ INSTALLED_APPS = ( 'finances', 'guides', 'guardian', - # 'haystack', 'invitations', 'journals', 'mailing_lists', @@ -154,9 +152,6 @@ SPHINXDOC_PROTECTED_PROJECTS = { 'developers': ['scipost.can_view_docs_scipost'], } -CAPTCHA_CHALLENGE_FUNCT = 'captcha.helpers.math_challenge' -CAPTCHA_LETTER_ROTATION = (-15, 15) -CAPTCHA_NOISE_FUNCTIONS = ('captcha.helpers.noise_dots',) SHELL_PLUS_POST_IMPORTS = ( ('theses.factories', ('ThesisLinkFactory')), @@ -320,7 +315,6 @@ DOAJ_API_KEY = '' # https://developers.google.com/recaptcha/docs/faq#id-like-to-run-automated-tests-with-recaptcha-v2-what-should-i-do RECAPTCHA_PUBLIC_KEY = '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI' RECAPTCHA_PRIVATE_KEY = '6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe' -NOCAPTCHA = True # PASSWORDS diff --git a/colleges/templates/colleges/_potentialfellowship_card.html b/colleges/templates/colleges/_potentialfellowship_card.html index bef5812085a189c9488bb2c8b9fb2532a7122b7c..35737a4620c79577cd08ea756b3520d51523915c 100644 --- a/colleges/templates/colleges/_potentialfellowship_card.html +++ b/colleges/templates/colleges/_potentialfellowship_card.html @@ -2,37 +2,47 @@ <div class="card-body"> <div class="row"> - <div class="col-6"> - {% include 'profiles/_profile_card.html' with profile=potfel.profile %} + <div class="col-12"> + <h3 class="highlight">Potential Fellowship details for {{ potfel }}</h3> + <div id="profileAccordion"> + <div class="card"> + <div class="card-header" id="potfelProfile"> + <h4 class="mb-0"> + <button class="btn btn-link" data-toggle="collapse" data-target="#collapseProfile" aria-expanded="true" aria-controls="collapseProfile"> + View Profile + </button> + </h4> + </div> + <div id="collapseProfile" class="collapse" aria-labelledby="potfelProfile" data-parent="#profileAccordion"> + <div class="card-body"> + {% include 'profiles/_profile_card.html' with profile=potfel.profile %} + </div> + </div> + </div> + </div> </div> - <div class="col-6"> + </div> + <div class="row"> + <div class="col-md-6"> <ul> <li><a href="{% url 'colleges:potential_fellowship_update' pk=potfel.id %}">Update</a> the data</li> <li><a href="{% url 'colleges:potential_fellowship_delete' pk=potfel.id %}">Delete</a> this Potential Fellowship</li> <li><a href="{% url 'colleges:potential_fellowship_email_initial' pk=potfel.id %}">Prepare and send initial email</a></li> </ul> - </div> - </div> - <div class="row"> - <div class="col-md-6 ml-auto"> - <h3>Events</h3> - <ul> - {% for event in potfel.potentialfellowshipevent_set.all %} - {% include 'colleges/_potentialfellowship_event_li.html' with event=event %} - {% empty %} - <li>No events found.</li> - {% endfor %} - </ul> </div> - <div class="col-md-5"> + <div class="col-md-6"> <h3>Update the status of this Potential Fellowship</h3> <form class="d-block mt-2 mb-3" action="{% url 'colleges:potential_fellowship_update_status' pk=potfel.id %}" method="post"> {% csrf_token %} {{ pfstatus_form|bootstrap }} <input type="submit" name="submit" value="Update status" class="btn btn-outline-secondary"> </form> - <hr/> + </div> + </div> + + <div class="row"> + <div class="col-md-6"> <h3>Add an event for this Potential Fellowship</h3> <form class="d-block mt-2 mb-3" action="{% url 'colleges:potential_fellowship_event_create' pk=potfel.id %}" method="post"> {% csrf_token %} @@ -40,5 +50,15 @@ <input type="submit" name="submit" value="Submit" class="btn btn-outline-secondary"> </form> </div> + <div class="col-md-6"> + <h3>Events</h3> + <ul> + {% for event in potfel.potentialfellowshipevent_set.all %} + {% include 'colleges/_potentialfellowship_event_li.html' with event=event %} + {% empty %} + <li>No events found.</li> + {% endfor %} + </ul> + </div> </div> </div> diff --git a/colleges/templates/colleges/base.html b/colleges/templates/colleges/base.html new file mode 100644 index 0000000000000000000000000000000000000000..b02dd091ee5a212cbd810f90f4554beaba8ee882 --- /dev/null +++ b/colleges/templates/colleges/base.html @@ -0,0 +1,13 @@ +{% extends 'scipost/base.html' %} + +{% block breadcrumb %} + <div class="container-outside header"> + <div class="container"> + <nav class="breadcrumb hidden-sm-down"> + {% block breadcrumb_items %} + <a href="{% url 'colleges:potential_fellowships' %}" class="breadcrumb-item">Potential Fellowships</a> + {% endblock %} + </nav> + </div> + </div> +{% endblock %} diff --git a/colleges/templates/colleges/potentialfellowship_confirm_delete.html b/colleges/templates/colleges/potentialfellowship_confirm_delete.html index 36f86e25ab8d008a53c431b18e7af9613c755846..6f010dc92acb888cecece40988193b9938ae0adb 100644 --- a/colleges/templates/colleges/potentialfellowship_confirm_delete.html +++ b/colleges/templates/colleges/potentialfellowship_confirm_delete.html @@ -1,4 +1,4 @@ -{% extends 'scipost/base.html' %} +{% extends 'colleges/base.html' %} {% load bootstrap %} diff --git a/colleges/templates/colleges/potentialfellowship_detail.html b/colleges/templates/colleges/potentialfellowship_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..9357ec706277609c0fb3f563a2e8e5fc68d5e93a --- /dev/null +++ b/colleges/templates/colleges/potentialfellowship_detail.html @@ -0,0 +1,21 @@ +{% extends 'colleges/base.html' %} + +{% load bootstrap %} + +{% block breadcrumb_items %} + {{ block.super }} +<span class="breadcrumb-item">Details</span> +{% endblock %} + +{% load scipost_extras %} + +{% block pagetitle %}: Potential Fellowship details{% endblock pagetitle %} + +{% block content %} +<div class="row"> + <div class="col-12"> + {% include 'colleges/_potentialfellowship_card.html' with potfel=object pfevent_form=pfevent_form %} + </div> +</div> + +{% endblock content %} diff --git a/colleges/templates/colleges/potentialfellowship_form.html b/colleges/templates/colleges/potentialfellowship_form.html index fa4e43c4fa4388ca9edec525e47d47fb7bada765..a80217f8798440bf22e9826c05c1ab07b9242826 100644 --- a/colleges/templates/colleges/potentialfellowship_form.html +++ b/colleges/templates/colleges/potentialfellowship_form.html @@ -1,7 +1,12 @@ -{% extends 'scipost/base.html' %} +{% extends 'colleges/base.html' %} {% load bootstrap %} +{% block breadcrumb_items %} + {{ block.super }} + <span class="breadcrumb-item">{% if form.instance.id %}Update{% else %}Add new{% endif %} Potential Fellowship</span> +{% endblock %} + {% block pagetitle %}: Potential Fellowships{% endblock pagetitle %} {% block content %} diff --git a/colleges/templates/colleges/potentialfellowship_list.html b/colleges/templates/colleges/potentialfellowship_list.html index 9b3cd2b9f5cd06b8e46158f938f1b209ad8ee291..0fa8ce291258052c86a14a6dc9d20599693b536f 100644 --- a/colleges/templates/colleges/potentialfellowship_list.html +++ b/colleges/templates/colleges/potentialfellowship_list.html @@ -1,14 +1,27 @@ -{% extends 'scipost/base.html' %} +{% extends 'colleges/base.html' %} {% load scipost_extras %} +{% load colleges_extras %} {% load bootstrap %} +{% block headsup %} +<script type="text/javascript"> +$(document).ready(function($) { + $(".table-row").click(function() { + window.document.location = $(this).data("href"); + }); +}); +</script> +{% endblock headsup %} + {% block pagetitle %}: Potential Fellowships{% endblock pagetitle %} {% block content %} <div class="row"> <div class="col-12"> + <a href="{% url 'colleges:potential_fellowship_create' %}">Add a Potential Fellowship</a> + <br/><br/> <p> <ul class="list-inline"> <li class="list-inline-item"> @@ -34,8 +47,6 @@ <div class="row"> <div class="col-12"> - <a href="{% url 'colleges:potential_fellowship_create' %}">Add a Potential Fellowship</a> - <br/><br/> {% if view.kwargs.discipline %} <h3>Potential Fellowships in {{ view.kwargs.discipline }}{% if view.kwargs.expertise %}, {{ view.kwargs.expertise }}{% endif %}:</h3> <br/> @@ -49,9 +60,9 @@ <th>Status</th> </tr> </thead> - <tbody id="accordion" role="tablist" aria-multiselectable="true"> + <tbody> {% for potfel in object_list %} - <tr data-toggle="collapse" data-parent="#accordion" href="#collapse{{ potfel.id }}" aria-expanded="false" aria-controls="collapse{{ potfel.id }}" style="cursor: pointer;"> + <tr class="table-row" data-href="{% url 'colleges:potential_fellowship_detail' pk=potfel.id %}" target="_blank" style="cursor: pointer;"> <td>{{ potfel.profile.last_name }}, {{ potfel.profile.get_title_display }} {{ potfel.profile.first_name }}</td> <td>{{ potfel.profile.get_discipline_display }}</td> <td> @@ -59,12 +70,7 @@ <div class="single d-inline" data-specialization="{{expertise|lower}}" data-toggle="tooltip" data-placement="bottom" title="{{expertise|get_specialization_display}}">{{expertise|get_specialization_code}}</div> {% endfor %} </td> - <td>{{ potfel.get_status_display }}</td> - </tr> - <tr id="collapse{{ potfel.id }}" class="collapse" role="tabpanel" aria-labelledby="heading{{ potfel.id }}" style="background-color: #fff;"> - <td colspan="4"> - {% include 'colleges/_potentialfellowship_card.html' with potfel=potfel pfevent_form=pfevent_form %} - </td> + <td style="color: #ffffff; background-color:{{ potfel.status|potfelstatuscolor }};">{{ potfel.get_status_display }}</td> </tr> {% empty %} <tr> @@ -73,6 +79,13 @@ {% endfor %} </tbody> </table> + + {% if is_paginated %} + <div class="col-12"> + {% include 'partials/pagination.html' with page_obj=page_obj %} + </div> + {% endif %} + </div> </div> {% endblock content %} diff --git a/colleges/templatetags/colleges_extras.py b/colleges/templatetags/colleges_extras.py new file mode 100644 index 0000000000000000000000000000000000000000..d0a10eceb9560daad8525c3d9e6be8ebda4c75ef --- /dev/null +++ b/colleges/templatetags/colleges_extras.py @@ -0,0 +1,48 @@ +__copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +from django import template + +from ..constants import ( + POTENTIAL_FELLOWSHIP_IDENTIFIED, POTENTIAL_FELLOWSHIP_INVITED, POTENTIAL_FELLOWSHIP_REINVITED, + POTENTIAL_FELLOWSHIP_MULTIPLY_REINVITED, POTENTIAL_FELLOWSHIP_DECLINED, + POTENTIAL_FELLOWSHIP_UNRESPONSIVE, POTENTIAL_FELLOWSHIP_RETIRED, POTENTIAL_FELLOWSHIP_DECEASED, + POTENTIAL_FELLOWSHIP_INTERESTED, POTENTIAL_FELLOWSHIP_REGISTERED, + POTENTIAL_FELLOWSHIP_ACTIVE_IN_COLLEGE, POTENTIAL_FELLOWSHIP_SCIPOST_EMERITUS + ) + +from common.utils import hslColorWheel + + +register = template.Library() + + +@register.filter(name='potfelstatuscolor') +def potfelstatuscolor(status): + color = '#333333' + if status == POTENTIAL_FELLOWSHIP_IDENTIFIED: + color = hslColorWheel(12, 8) + elif status == POTENTIAL_FELLOWSHIP_INVITED: + color = hslColorWheel(12, 9) + elif status == POTENTIAL_FELLOWSHIP_REINVITED: + color = hslColorWheel(12, 10) + elif status == POTENTIAL_FELLOWSHIP_MULTIPLY_REINVITED: + color = hslColorWheel(12, 11) + elif status == POTENTIAL_FELLOWSHIP_DECLINED: + color = hslColorWheel(12, 0) + elif status == POTENTIAL_FELLOWSHIP_UNRESPONSIVE: + color = hslColorWheel(12, 1) + elif status == POTENTIAL_FELLOWSHIP_RETIRED: + color = hslColorWheel(12, 1, 75) + elif status == POTENTIAL_FELLOWSHIP_DECEASED: + color = hslColorWheel(12, 1, 10) + elif status == POTENTIAL_FELLOWSHIP_INTERESTED: + color = hslColorWheel(12, 2) + elif status == POTENTIAL_FELLOWSHIP_REGISTERED: + color = hslColorWheel(12, 3) + elif status == POTENTIAL_FELLOWSHIP_ACTIVE_IN_COLLEGE: + color = hslColorWheel(12, 4) + elif status == POTENTIAL_FELLOWSHIP_SCIPOST_EMERITUS: + color = hslColorWheel(12, 4, 40, 40) + return color diff --git a/colleges/urls.py b/colleges/urls.py index fd290b952cc27883ac2b9bdd2794fd194a69b4c8..dc6fb8c3501531edd64a093e1e439306300ff2a8 100644 --- a/colleges/urls.py +++ b/colleges/urls.py @@ -65,15 +65,20 @@ urlpatterns = [ name='potential_fellowship_delete' ), url( - r'^potentialfellowships/(?P<pk>[0-9]+)/events/add$', + r'^potentialfellowships/(?P<pk>[0-9]+)/events/add/$', views.PotentialFellowshipEventCreateView.as_view(), name='potential_fellowship_event_create' ), url( - r'^potentialfellowships/(?P<pk>[0-9]+)/email/$', + r'^potentialfellowships/(?P<pk>[0-9]+)/email_initial/$', views.PotentialFellowshipInitialEmailView.as_view(), name='potential_fellowship_email_initial' ), + url( + r'^potentialfellowships/(?P<pk>[0-9]+)/$', + views.PotentialFellowshipDetailView.as_view(), + name='potential_fellowship_detail' + ), url( r'^potentialfellowships/(?P<discipline>[a-zA-Z]+)/(?P<expertise>[a-zA-Z:]+)/$', views.PotentialFellowshipListView.as_view(), diff --git a/colleges/views.py b/colleges/views.py index 293e04dd9596f619c44a114d5924238f14114aad..d62a106bc805a2ff34c0c6ce49b1fb562254e153 100644 --- a/colleges/views.py +++ b/colleges/views.py @@ -8,6 +8,7 @@ from django.shortcuts import get_object_or_404, render, redirect from django.core.urlresolvers import reverse, reverse_lazy 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, UpdateView, DeleteView from django.views.generic.list import ListView @@ -24,7 +25,7 @@ from .forms import FellowshipForm, FellowshipTerminateForm, FellowshipRemoveSubm from .models import Fellowship, PotentialFellowship, PotentialFellowshipEvent from scipost.constants import SCIPOST_SUBJECT_AREAS -from scipost.mixins import PermissionsMixin +from scipost.mixins import PermissionsMixin, PaginationMixin from mails.forms import EmailTemplateForm from mails.views import MailView @@ -352,7 +353,7 @@ class PotentialFellowshipDeleteView(PermissionsMixin, DeleteView): success_url = reverse_lazy('colleges:potential_fellowships') -class PotentialFellowshipListView(PermissionsMixin, ListView): +class PotentialFellowshipListView(PermissionsMixin, PaginationMixin, ListView): """ List the PotentialFellowship object instances. """ @@ -374,6 +375,15 @@ class PotentialFellowshipListView(PermissionsMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['subject_areas'] = SCIPOST_SUBJECT_AREAS + return context + + +class PotentialFellowshipDetailView(PermissionsMixin, DetailView): + permission_required = 'scipost.can_manage_college_composition' + model = PotentialFellowship + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) context['pfstatus_form'] = PotentialFellowshipStatusForm() context['pfevent_form'] = PotentialFellowshipEventForm() return context diff --git a/common/forms.py b/common/forms.py index 3f1b8651d2b2c3d30e5389ec3d478f3f5a156df0..5fa2d913773a61c40c068238d3a7be01ab3cce36 100644 --- a/common/forms.py +++ b/common/forms.py @@ -6,6 +6,7 @@ import calendar import datetime import re +from django import forms from django.forms.widgets import Widget, Select from django.utils.dates import MONTHS from django.utils.safestring import mark_safe @@ -115,3 +116,8 @@ class MonthYearWidget(Widget): return '%s-%s-%s' % (y, m, d) return data.get(name, None) + + +class ModelChoiceFieldwithid(forms.ModelChoiceField): + def label_from_instance(self, obj): + return '%s (id = %i)' % (super().label_from_instance(obj), obj.id) diff --git a/common/utils.py b/common/utils.py index cdbb65c174398fa1f5b1df176cd9c9a534d23240..0edde7a732a107ba3beb620cbd66ed9ff8b64c43 100644 --- a/common/utils.py +++ b/common/utils.py @@ -7,6 +7,22 @@ from django.core.mail import EmailMultiAlternatives from django.template import loader +def hslColorWheel(N=10, index=0, saturation=50, lightness=50): + """ + Distributes colors into N values around a color wheel, + according to hue-saturation-lightness (HSL). + + index takes values from 0 to N-1. + """ + hue = int(index * 360/N % 360) + saturation = max(saturation, 0) + saturation = min(saturation, 100) + lightness = max(lightness, 0) + lightness = min(lightness, 100) + + return 'hsl(%s, %s%%, %s%%)' % (str(hue), str(saturation), str(lightness)) + + def workdays_between(datetime_from, datetime_until): """Return number of complete workdays. diff --git a/cronjob_production_eachhour.sh b/cronjob_production_eachhour.sh new file mode 100644 index 0000000000000000000000000000000000000000..47ae6e28c0d5c797807e4d65d363a9ecb1791c02 --- /dev/null +++ b/cronjob_production_eachhour.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# Per minute cronjobs for production area + +cd /home/scipost/webapps/scipost/scipost_v1 +source venv/bin/activate + +# Do tasks +python3 manage.py check_celery diff --git a/finances/forms.py b/finances/forms.py index 70ac25a2403ded67c2bf578f9a6ddfac084272a6..7786bd89458bedb323c6ea652bec3695e2807994 100644 --- a/finances/forms.py +++ b/finances/forms.py @@ -21,7 +21,8 @@ class SubsidyForm(forms.ModelForm): class Meta: model = Subsidy fields = ['organization', 'subsidy_type', 'description', - 'amount', 'status', 'date', 'date_until'] + 'amount', 'amount_publicly_shown', 'status', + 'date', 'date_until'] class WorkLogForm(forms.ModelForm): diff --git a/finances/migrations/0008_subsidy_amount_publicly_shown.py b/finances/migrations/0008_subsidy_amount_publicly_shown.py new file mode 100644 index 0000000000000000000000000000000000000000..a13a33679406ab10f42df221e704daceb873a97d --- /dev/null +++ b/finances/migrations/0008_subsidy_amount_publicly_shown.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-12-05 18:07 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finances', '0007_auto_20181011_2146'), + ] + + operations = [ + migrations.AddField( + model_name='subsidy', + name='amount_publicly_shown', + field=models.BooleanField(default=True), + ), + ] diff --git a/finances/models.py b/finances/models.py index f7755ead7f9b6847b1d0850c04e09b4350f2c177..c6cd606061aaea986ef4d3f6a1289b954e0f92d8 100644 --- a/finances/models.py +++ b/finances/models.py @@ -36,6 +36,7 @@ class Subsidy(models.Model): subsidy_type = models.CharField(max_length=256, choices=SUBSIDY_TYPES) description = models.TextField() amount = models.PositiveIntegerField(help_text="in € (rounded)") + amount_publicly_shown = models.BooleanField(default=True) status = models.CharField(max_length=32, choices=SUBSIDY_STATUS) date = models.DateField() date_until = models.DateField(blank=True, null=True) diff --git a/finances/templates/finances/_subsidy_card.html b/finances/templates/finances/_subsidy_card.html index d3e0a2291bcd5d50dfe2f7a3086c737b5f55869c..e7168adcd386d086873b36df8789e6ba30579b51 100644 --- a/finances/templates/finances/_subsidy_card.html +++ b/finances/templates/finances/_subsidy_card.html @@ -16,7 +16,7 @@ <table class="table"> <tr> - <td>From:</td><td><a href="{{ subsidy.organization.get_absolute_url }}">{{ subsidy.organization }}</a></td> + <td>From:</td><td>{% if subsidy.organization.details_publicly_viewable or perms.scipost.can_manage_organizations %}<a href="{{ subsidy.organization.get_absolute_url }}">{{ subsidy.organization }}</a>{% else %}{{ subsidy.organization }}{% endif %}</td> </tr> <tr> <td>Type:</td><td>{{ subsidy.get_subsidy_type_display }}</td> @@ -25,7 +25,7 @@ <td>Description:</td><td>{{ subsidy.description }}</td> </tr> <tr> - <td>Amount:</td><td>€{{ subsidy.amount }}</td> + <td>Amount:</td><td>{% if subsidy.amount_publicly_shown or perms.scipost.can_manage_subsidies %}€{{ subsidy.amount }}{% else %}-{% endif %}</td> </tr> <tr> <td>Date:</td><td>{{ subsidy.date }}</td> diff --git a/finances/templates/finances/subsidy_list.html b/finances/templates/finances/subsidy_list.html index 6fcbb6e749c6b56226cbf08ebe262029c2ffc6db..e8a11c93262fd3a946ca40c8c83ce4130167df90 100644 --- a/finances/templates/finances/subsidy_list.html +++ b/finances/templates/finances/subsidy_list.html @@ -72,7 +72,7 @@ $(document).ready(function($) { <tr class="table-row" data-href="{% url 'finances:subsidy_details' pk=subsidy.id %}" style="cursor: pointer;"> <td>{{ subsidy.organization }}</td> <td>{{ subsidy.get_subsidy_type_display }}</td> - <td>€{{ subsidy.amount }}</td> + <td>{% if subsidy.amount_publicly_shown or perms.scipost.can_manage_subsidies %}€{{ subsidy.amount }}{% else %}-{% endif %}</td> <td>{{ subsidy.date }}</td> </tr> {% empty %} diff --git a/finances/views.py b/finances/views.py index 8c574883ae90c3543466ead6a26075cce8a79bab..6b43fd16b8e712e3d4cf69dc46138ebcd84c447f 100644 --- a/finances/views.py +++ b/finances/views.py @@ -66,7 +66,7 @@ class SubsidyListView(ListView): order_by = self.request.GET.get('order_by') ordering = self.request.GET.get('ordering') if order_by == 'amount': - qs = qs.order_by('amount') + qs = qs.filter(amount_publicly_shown=True).order_by('amount') elif order_by == 'date': qs = qs.order_by('date') if ordering == 'desc': diff --git a/journals/migrations/0053_auto_20181118_1758.py b/journals/migrations/0053_auto_20181118_1758.py new file mode 100644 index 0000000000000000000000000000000000000000..f2a41d010006dd96dcdc203e13e60f4abc5de665 --- /dev/null +++ b/journals/migrations/0053_auto_20181118_1758.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-11-18 16:58 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('journals', '0052_journal_refereeing_period'), + ] + + operations = [ + migrations.AlterField( + model_name='unregisteredauthor', + name='profile', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='profiles.Profile'), + ), + ] diff --git a/journals/models.py b/journals/models.py index 38fcf8fe1e3a59a869fc754a0849bfa52b312bfa..ee1c7a4d2caec59d040aa6fe1cc724acdaa119f1 100644 --- a/journals/models.py +++ b/journals/models.py @@ -38,23 +38,12 @@ class UnregisteredAuthor(models.Model): first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) - profile = models.OneToOneField( + profile = models.ForeignKey( 'profiles.Profile', on_delete=models.SET_NULL, null=True, blank=True) def __str__(self): return self.last_name + ', ' + self.first_name - def merge(self, unregistered_author): - """ - Merge another UnregisteredAuthor into this object. - """ - if unregistered_author == self: # Do nothing. - return - - self.profile = unregistered_author.profile - self.save() - unregistered_author.delete() - class PublicationAuthorsTable(models.Model): """PublicationAuthorsTable is an ordered link between people and Publications.""" diff --git a/journals/templates/journals/SciPostPhysComm_about.html b/journals/templates/journals/SciPostPhysComm_about.html index 64ee74023bb60543bcd7773f8ad41169efe42a13..69dea8a6e0093e8d366c366ffca3e57d2e0dbc66 100644 --- a/journals/templates/journals/SciPostPhysComm_about.html +++ b/journals/templates/journals/SciPostPhysComm_about.html @@ -47,12 +47,13 @@ <div class="row"> <div class="col-md-6"> <h2>Content</h2> - <p>The journal accepts three types of content: <strong>Letters</strong>, <strong>Articles</strong> and <strong>Reviews</strong>.</p> + <p>The journal accepts three indicative types of content: <strong>Letters</strong>, <strong>Articles</strong> and <strong>Reviews</strong>.</p> <ul> <li><strong>Letters</strong> report broad-interest and high-quality advances in Physics, of interest and importance to researchers in multiple subject areas.</li> <li><strong>Articles</strong> provide in-depth, detailed reports of research achievements within one or more subject areas.</li> <li><strong>Reviews</strong> are short pieces taking a snapshot of a research area, written by recognized leaders in the field, providing a critical assessment of current frontline research and providing pointers towards future opportunities.</li> </ul> + <p>These three types of content are published side-by-side on our online portal.</p> </div> <div class="col-md-6"> <h2>Submission and Editorial Process</h2> diff --git a/journals/templates/journals/journals.html b/journals/templates/journals/journals.html index 6595cc88dbece6fb4932f23025b24648f84a663f..3731fde185c5e43215e9897ff6dd047435815988 100644 --- a/journals/templates/journals/journals.html +++ b/journals/templates/journals/journals.html @@ -84,7 +84,7 @@ </div> <div class="card"> <div class="card-header banner"> - <h1 class="m-0"><a href="{% url 'scipost:landing_page' 'SciPostPhysComm' %}">SciPost Physics Commons</a> <em><small style="color: red;">New!</small></em></h1> + <h1 class="m-0"><a href="{% url 'journal:about' 'SciPostPhysComm' %}">SciPost Physics Commons</a> <em><small style="color: red;">New!</small></em></h1> </div> <div class="card-body"> <p>SciPost Physics Commons is a premium-quality refereed Journal for the general field of Physics.</p> @@ -98,7 +98,7 @@ <div class="card-deck"> <div class="card"> <div class="card-header banner"> - <h1 class="m-0"><a href="{% url 'scipost:landing_page' 'SciPostPhysLectNotes' %}">SciPost Physics Lecture Notes</a></h1> + <h1 class="m-0"><a href="{% url 'journal:about' 'SciPostPhysLectNotes' %}">SciPost Physics Lecture Notes</a></h1> </div> <div class="card-body"> <p>SciPost Physics Lecture Notes publishes didactic material in all domains and subject areas of Physics.</p> @@ -108,7 +108,7 @@ </div> <div class="card"> <div class="card-header banner"> - <h1 class="m-0"><a href="{% url 'scipost:landing_page' 'SciPostPhysProc' %}">SciPost Physics Proceedings</a></h1> + <h1 class="m-0"><a href="{% url 'journal:about' 'SciPostPhysProc' %}">SciPost Physics Proceedings</a></h1> </div> <div class="card-body"> <p>SciPost Physics Proceedings is a premium-quality, two-way open access, community-run peer-witnessed refereed publishing venue for conference/workshop/school proceedings in Experimental, Theoretical and Computational physics, in all specializations.</p> @@ -117,7 +117,7 @@ </div> <div class="card"> <div class="card-header banner"> - <h1 class="m-0"><a href="{% url 'scipost:landing_page' 'SciPostPhysCodeb' %}">SciPost Physics Codebases</a> <em><small style="color: red;">New!</small></em></h1> + <h1 class="m-0"><a href="{% url 'journal:about' 'SciPostPhysCodeb' %}">SciPost Physics Codebases</a> <em><small style="color: red;">New!</small></em></h1> </div> <div class="card-body"> <p>SciPost Physics Codebases is an innovative peer-reviewed publication venue for modern forms of scientific contributions which too often remain unrecognized: codes and algorithms at the heart of contemporary research.</p> diff --git a/mails/admin.py b/mails/admin.py index d7d26b99e714c27c50f251942892f2fe9976d30c..6967486fda3158c00f55d1233d4b8b1d568be0a8 100644 --- a/mails/admin.py +++ b/mails/admin.py @@ -8,7 +8,7 @@ from .models import MailLog class MailLogAdmin(admin.ModelAdmin): - list_display = ['__str__', 'processed'] + list_display = ['__str__', 'to_recipients', 'created', 'processed'] readonly_fields = ('created', 'latest_activity') diff --git a/notifications/migrations/0002_notification_url_code.py b/notifications/migrations/0002_notification_url_code.py new file mode 100644 index 0000000000000000000000000000000000000000..b8922d06cd68ebd87772913a30232203c46fe343 --- /dev/null +++ b/notifications/migrations/0002_notification_url_code.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-12-04 19:11 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='url_code', + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/notifications/models.py b/notifications/models.py index 470cf57d961dab1bb8112761ad61d9b175dd7ec3..fa02efaef64b47e0052615cabf37a62940e89475 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -74,6 +74,8 @@ class Notification(models.Model): # of notifications. internal_type = models.CharField(max_length=255, blank=True, choices=NOTIFICATION_TYPES) + url_code = models.CharField(max_length=255, blank=True) + objects = NotificationQuerySet.as_manager() class Meta: diff --git a/ontology/forms.py b/ontology/forms.py index 7f85b113c511c8453a278f11acb4c538f5d56c35..ab23a61ebc930726011364278c631fa462a56064 100644 --- a/ontology/forms.py +++ b/ontology/forms.py @@ -16,6 +16,11 @@ class SelectTagForm(forms.Form): class SelectTopicForm(forms.Form): topic = AutoCompleteSelectField('topic_lookup', label='', help_text='') + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['topic'].widget.attrs.update({ + 'placeholder':'type here to find topic'}) + class SelectLinkedTopicForm(forms.Form): topic = AutoCompleteSelectField('linked_topic_lookup', diff --git a/ontology/templates/ontology/_topic_card.html b/ontology/templates/ontology/_topic_card.html index 08c644fb3c40ac6a1b57a529b0e14938d6e7c5de..977868b955bddc8127f56f52b9c2c0e95e34bf48 100644 --- a/ontology/templates/ontology/_topic_card.html +++ b/ontology/templates/ontology/_topic_card.html @@ -119,7 +119,7 @@ </div> <div class="card"> <div class="card-header"> - Profiles + Top experts </div> <div class="card-body"> <ul> diff --git a/ontology/templates/ontology/topic_form.html b/ontology/templates/ontology/topic_form.html index c16140e9e93b292d4d31323b2368be5c73de6b36..d63f2706956c132d9c146e312ca4cdfb1fc243ca 100644 --- a/ontology/templates/ontology/topic_form.html +++ b/ontology/templates/ontology/topic_form.html @@ -26,6 +26,13 @@ $("#id_name").keyup(function() { {% block content %} <div class="row"> <div class="col-12"> + <h4>Please use the following conventions:</h4> + <ul> + <li>Start with a capital letter</li> + <li>Use plural words (<em>e.g.</em> <strong>superconductors</strong> instead of <strong>superconductor</strong>)</li> + <li>If an acronym exists, put it in parentheses at the end (<em>e.g.</em> <strong>Renormalization group (RG)</strong>). <strong class="text-danger">Remove any parentheses from the slug!</strong></li> + <li>Mix equivalent words by using a slash, <em>e.g.</em> <strong>Superconductivity⁄superconductors</strong>. <strong class="text-danger">You will similarly need to remove the slash from the slug!</strong></li> + </ul> <form action="" method="post"> {% csrf_token %} {{ form|bootstrap }} diff --git a/organizations/models.py b/organizations/models.py index f47a81b1210f0a44306228ae95a01b089c9928f4..291f8203e494316af8e9f1f2846ca652eb2d33e4 100644 --- a/organizations/models.py +++ b/organizations/models.py @@ -12,7 +12,8 @@ from django.urls import reverse from django_countries.fields import CountryField -from .constants import ORGANIZATION_TYPES, ORGANIZATION_STATUSES, ORGSTATUS_ACTIVE +from .constants import ORGANIZATION_TYPES, ORGTYPE_PRIVATE_BENEFACTOR,\ + ORGANIZATION_STATUSES, ORGSTATUS_ACTIVE from .managers import OrganizationQuerySet from scipost.models import Contributor @@ -90,6 +91,10 @@ class Organization(models.Model): def get_absolute_url(self): return reverse('organizations:organization_details', kwargs = {'pk': self.id}) + @property + def details_publicly_viewable(self): + return self.orgtype != ORGTYPE_PRIVATE_BENEFACTOR + def get_publications(self): org_and_children_ids = [k['id'] for k in list(self.children.all().values('id'))] org_and_children_ids += [self.id] diff --git a/organizations/views.py b/organizations/views.py index d10c4131759a38ba82eda5d0d16ac40c2a2a1b26..30dfbff9c0da0a16686f571080a7e2599de326d2 100644 --- a/organizations/views.py +++ b/organizations/views.py @@ -8,6 +8,7 @@ from django.views.generic.detail import DetailView from django.views.generic.edit import CreateView, UpdateView, DeleteView from django.views.generic.list import ListView +from .constants import ORGTYPE_PRIVATE_BENEFACTOR from .models import Organization from funders.models import Funder @@ -59,7 +60,7 @@ class OrganizationListView(ListView): return context def get_queryset(self): - qs = super().get_queryset() + qs = super().get_queryset().exclude(orgtype=ORGTYPE_PRIVATE_BENEFACTOR) order_by = self.request.GET.get('order_by') ordering = self.request.GET.get('ordering') if order_by == 'country': @@ -80,3 +81,12 @@ class OrganizationDetailView(DetailView): context = super().get_context_data(*args, **kwargs) context['pubyears'] = range(int(timezone.now().strftime('%Y')), 2015, -1) return context + + def get_queryset(self): + """ + Restrict view to permitted people if Organization details not publicly viewable. + """ + queryset = super().get_queryset() + if not self.request.user.has_perm('scipost.can_manage_organizations'): + queryset = queryset.exclude(orgtype=ORGTYPE_PRIVATE_BENEFACTOR) + return queryset diff --git a/petitions/templates/petitions/petition_email.html b/petitions/templates/petitions/petition_email.html index eb4b4543014eccc45b54335eab833b576e94fbfb..355560c70969968c9a2843acad2635685c890875 100644 --- a/petitions/templates/petitions/petition_email.html +++ b/petitions/templates/petitions/petition_email.html @@ -1,4 +1,4 @@ -[PLEASE FILL IN THE TO FIELD ABOVE (keeping partners@scipost.org in cc)]%0D%0A +[PLEASE FILL IN THE TO FIELD ABOVE (keeping sponsors@scipost.org in cc)]%0D%0A %0D%0A Dear ...%0D%0A %0D%0A @@ -15,4 +15,4 @@ SciPost (https://scipost.org) is a top-quality next-generation Open Access publi %0D%0A SciPost follows a different funding model than most traditional publishers. It operates on an entirely not-for-profit basis, and charges neither subscription fees nor article processing charges; instead, its activities are financed through a cost-slashing consortial model.%0D%0A %0D%0A -By making a small financial commitment, the institutions and organizations that benefit from SciPost’s activities can become Supporting Partners. This enables SciPost to perform all of its publication-related activities, maintain its online portal and implement its long-term development plan. Details of the consortial funding scheme and how to join can be found at https://scipost.org/partners or by emailing partners@scipost.org.%0D%0A +By making a small financial commitment, the institutions and organizations that benefit from SciPost’s activities can become Sponsors. This enables SciPost to perform all of its publication-related activities, maintain its online portal and implement its long-term development plan. Details of the consortial funding scheme and how to join can be found at https://scipost.org/sponsors or by emailing sponsors@scipost.org.%0D%0A diff --git a/preprints/helpers.py b/preprints/helpers.py index d5437b287c6539ca03ce34265425af7b6438b19c..1af7fba21670c593835cbbb0a1f1a3a6360490d8 100644 --- a/preprints/helpers.py +++ b/preprints/helpers.py @@ -5,24 +5,40 @@ __license__ = "AGPL v3" from django.db.models import Max from django.utils import timezone +from submissions.models import Submission + from .models import Preprint -def generate_new_scipost_identifier(): - """Return an identifier for a new SciPost preprint series without version number.""" +def generate_new_scipost_identifier(old_preprint=None): + """ + Return an identifier for a new SciPost preprint series without version number. + + TODO: This method will explode as soon as it will be used similtaneously by two or more people. + """ now = timezone.now() - existing_identifier = Preprint.objects.filter( - created__year=now.year, created__month=now.month).aggregate( - identifier=Max('scipost_preprint_identifier'))['identifier'] - if not existing_identifier: - existing_identifier = '1' - else: - existing_identifier = str(existing_identifier + 1) - return '{year}{month}_{identifier}'.format( - year=now.year, month=str(now.month).rjust(2, '0'), - identifier=existing_identifier.rjust(5, '0')), int(existing_identifier) + if isinstance(old_preprint, Submission): + old_preprint = old_preprint.preprint + + if old_preprint: + # Generate new version number of existing series. + preprint_series = Preprint.objects.filter( + scipost_preprint_identifier=old_preprint.scipost_preprint_identifier).values_list( + 'vn_nr', flat=True) + identifier = '{}v{}'.format(old_preprint.identifier_wo_vn_nr, max(preprint_series) + 1) + return identifier, old_preprint.scipost_preprint_identifier + else: + # New series of Preprints. + existing_identifier = Preprint.objects.filter( + created__year=now.year, created__month=now.month).aggregate( + identifier=Max('scipost_preprint_identifier'))['identifier'] + if not existing_identifier: + existing_identifier = '1' + else: + existing_identifier = str(existing_identifier + 1) -def format_scipost_identifier(identifier, version=1): - return 'scipost_{identifier}v{version}'.format( - identifier=identifier, version=version) + identifier = 'scipost_{year}{month}_{identifier}v1'.format( + year=now.year, month=str(now.month).rjust(2, '0'), + identifier=existing_identifier.rjust(5, '0')) + return identifier, int(existing_identifier) diff --git a/preprints/migrations/0009_auto_20181123_1000.py b/preprints/migrations/0009_auto_20181123_1000.py new file mode 100644 index 0000000000000000000000000000000000000000..8ef8494faec9d72742f6d402143c113a3998ffd5 --- /dev/null +++ b/preprints/migrations/0009_auto_20181123_1000.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-11-23 09:00 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('preprints', '0008_auto_20180913_2112'), + ] + + operations = [ + migrations.AlterModelOptions( + name='preprint', + options={'ordering': ['-identifier_w_vn_nr']}, + ), + ] diff --git a/preprints/models.py b/preprints/models.py index 41e89808518abb67c5cc6adce6963b5677c18015..443f8ddc584cbeab11181939437e09eb69258a7c 100644 --- a/preprints/models.py +++ b/preprints/models.py @@ -32,6 +32,10 @@ class Preprint(models.Model): modified = models.DateTimeField(auto_now=True) created = models.DateTimeField(auto_now_add=True) + class Meta: + ordering = ['-identifier_w_vn_nr'] + + def __str__(self): return 'Preprint {}'.format(self.identifier_w_vn_nr) diff --git a/preprints/views.py b/preprints/views.py index 9c291adb42e9a6cfc4cf3e729b96ff84d8e2f293..340afc48895810952fb842b9099badb7aa184c6b 100644 --- a/preprints/views.py +++ b/preprints/views.py @@ -1,6 +1,7 @@ __copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" +import os from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect @@ -28,7 +29,13 @@ def preprint_pdf(request, identifier_w_vn_nr): contributor__user=request.user).exists(): raise Http404 - response = HttpResponse(preprint._file.read(), content_type='application/pdf') - filename = '{}.pdf'.format(preprint.identifier_w_vn_nr) - response['Content-Disposition'] = ('filename=' + filename) + __, extension = os.path.splitext(preprint._file.name) + if extension == '.pdf': + response = HttpResponse(preprint._file.read(), content_type='application/pdf') + filename = '{}.pdf'.format(preprint.identifier_w_vn_nr) + response['Content-Disposition'] = ('filename=' + filename) + else: + response = HttpResponse(preprint._file.read(), content_type='application/force-download') + filename = '{}{}'.format(preprint.identifier_w_vn_nr, extension) + response['Content-Disposition'] = ('filename=' + filename) return response diff --git a/profiles/admin.py b/profiles/admin.py index d00201908914d436c4e62a62e8e45a6275378b04..771c7c3b119dc26e079bbe7e7db65b47737a9853 100644 --- a/profiles/admin.py +++ b/profiles/admin.py @@ -13,6 +13,7 @@ class ProfileEmailInline(admin.TabularInline): class ProfileAdmin(admin.ModelAdmin): + list_display = ['__str__', 'email', 'discipline', 'expertises', 'has_active_contributor'] search_fields = ['first_name', 'last_name', 'emails__email', 'orcid_id'] inlines = [ProfileEmailInline] diff --git a/profiles/forms.py b/profiles/forms.py index 92862facaf33f881125b393d8c35e6e0dd2a1da1..0ec066920a5839c5e207eb1800df5f6cb4c4dfc5 100644 --- a/profiles/forms.py +++ b/profiles/forms.py @@ -5,6 +5,7 @@ __license__ = "AGPL v3" from django import forms from django.shortcuts import get_object_or_404 +from common.forms import ModelChoiceFieldwithid from invitations.models import RegistrationInvitation from journals.models import UnregisteredAuthor from ontology.models import Topic @@ -15,7 +16,7 @@ from .models import Profile, ProfileEmail class ProfileForm(forms.ModelForm): - email = forms.EmailField() + email = forms.EmailField(required=False) # If the Profile is created from an existing object (so we can update the object): instance_from_type = forms.CharField(max_length=32, required=False) instance_pk = forms.IntegerField(required=False) @@ -36,7 +37,7 @@ class ProfileForm(forms.ModelForm): def clean_email(self): """Check that the email isn't yet associated to an existing Profile.""" cleaned_email = self.cleaned_data['email'] - if ProfileEmail.objects.filter( + if cleaned_email and ProfileEmail.objects.filter( email=cleaned_email).exclude(profile__id=self.instance.id).exists(): raise forms.ValidationError('A Profile with this email already exists.') return cleaned_email @@ -53,10 +54,11 @@ class ProfileForm(forms.ModelForm): def save(self): profile = super().save() - profile.emails.update(primary=False) - email, __ = ProfileEmail.objects.get_or_create( - profile=profile, email=self.cleaned_data['email']) - profile.emails.filter(id=email.id).update(primary=True, still_valid=True) + if self.cleaned_data['email']: + profile.emails.update(primary=False) + email, __ = ProfileEmail.objects.get_or_create( + profile=profile, email=self.cleaned_data['email']) + profile.emails.filter(id=email.id).update(primary=True, still_valid=True) instance_pk = self.cleaned_data['instance_pk'] if instance_pk: if self.cleaned_data['instance_from_type'] == 'contributor': @@ -84,11 +86,6 @@ class SimpleProfileForm(ProfileForm): self.fields['accepts_refereeing_requests'].widget = forms.HiddenInput() -class ModelChoiceFieldwithid(forms.ModelChoiceField): - def label_from_instance(self, obj): - return '%s (id = %i)' % (super().label_from_instance(obj), obj.id) - - class ProfileMergeForm(forms.Form): to_merge = ModelChoiceFieldwithid(queryset=Profile.objects.all(), empty_label=None) to_merge_into = ModelChoiceFieldwithid(queryset=Profile.objects.all(), empty_label=None) @@ -102,10 +99,11 @@ class ProfileMergeForm(forms.Form): data = super().clean() if self.cleaned_data['to_merge'] == self.cleaned_data['to_merge_into']: self.add_error(None, 'A Profile cannot be merged into itself.') - if self.cleaned_data['to_merge'].has_contributor and \ - self.cleaned_data['to_merge_into'].has_contributor: - self.add_error(None, 'Each of these two Profiles has a Contributor. ' - 'Cannot merge. If these are distinct people or if two separate ' + if self.cleaned_data['to_merge'].has_active_contributor and \ + self.cleaned_data['to_merge_into'].has_active_contributor: + self.add_error(None, 'Each of these two Profiles has an active Contributor. ' + 'Merge the Contributors first.\n' + 'If these are distinct people or if two separate ' 'accounts are needed, a ProfileNonDuplicate instance should be created; ' 'contact techsupport.') return data @@ -118,27 +116,25 @@ class ProfileMergeForm(forms.Form): profile = self.cleaned_data['to_merge_into'] profile_old = self.cleaned_data['to_merge'] - # Merge scientific information from old Profile to the new Profile. + # Merge information from old to new Profile. profile.expertises = list( set(profile_old.expertises or []) | set(profile.expertises or [])) if profile.orcid_id is None: profile.orcid_id = profile_old.orcid_id if profile.webpage is None: profile.webpage = profile_old.webpage + if profile_old.has_active_contributor and not profile.has_active_contributor: + profile.contributor = profile_old.contributor profile.save() # Save all the field updates. profile.topics.add(*profile_old.topics.all()) - if hasattr(profile_old, 'unregisteredauthor') and profile_old.unregisteredauthor: - profile.unregisteredauthor.merge(profile_old.unregisteredauthor) + UnregisteredAuthor.objects.filter(profile=profile_old).update(profile=profile) - # Merge email and Contributor information + # Merge email profile_old.emails.exclude( email__in=profile.emails.values_list('email', flat=True)).update( primary=False, profile=profile) - if hasattr(profile_old, 'contributor') and profile_old.contributor: - profile.contributor = profile_old.contributor - profile.contributor.save() # Move all invitations to the "new" profile. profile_old.refereeinvitation_set.all().update(profile=profile) @@ -152,7 +148,7 @@ class ProfileEmailForm(forms.ModelForm): class Meta: model = ProfileEmail - fields = ['email', 'still_valid'] + fields = ['email', 'still_valid', 'primary'] def __init__(self, *args, **kwargs): self.profile = kwargs.pop('profile', None) diff --git a/profiles/migrations/0015_auto_20181118_0849.py b/profiles/migrations/0015_auto_20181118_0849.py new file mode 100644 index 0000000000000000000000000000000000000000..83aff5b2ddbf1c71623d30e3463424c11335943c --- /dev/null +++ b/profiles/migrations/0015_auto_20181118_0849.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-11-18 07:49 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0014_auto_20181110_0637'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='title', + field=models.CharField(blank=True, choices=[('PR', 'Prof.'), ('DR', 'Dr'), ('MR', 'Mr'), ('MRS', 'Mrs'), ('MS', 'Ms')], max_length=4, null=True), + ), + ] diff --git a/profiles/models.py b/profiles/models.py index f840a6ca0bad5c876a5815167b71f378317e8fad..93da3c30ebafd65a0c8367848581e800581c4c27 100644 --- a/profiles/models.py +++ b/profiles/models.py @@ -45,7 +45,7 @@ class Profile(models.Model): #. mark somebody as a non-referee (if that person does not want to referee for SciPost) """ - title = models.CharField(max_length=4, choices=TITLE_CHOICES) + title = models.CharField(max_length=4, choices=TITLE_CHOICES, blank=True, null=True) first_name = models.CharField(max_length=64) last_name = models.CharField(max_length=64) discipline = models.CharField(max_length=20, choices=SCIPOST_DISCIPLINES, @@ -70,20 +70,23 @@ class Profile(models.Model): ordering = ['last_name'] def __str__(self): - return '%s, %s %s' % (self.last_name, self.get_title_display(), self.first_name) + return '%s, %s %s' % (self.last_name, + self.get_title_display() if self.title != None else '', + self.first_name) @property def email(self): return getattr(self.emails.filter(primary=True).first(), 'email', '') @property - def has_contributor(self): - has_contributor = False + def has_active_contributor(self): + has_active_contributor = False try: - has_contributor = (self.contributor is not None) + has_active_contributor = (self.contributor is not None and + self.contributor.is_active) except Contributor.DoesNotExist: pass - return has_contributor + return has_active_contributor def get_absolute_url(self): return reverse('profiles:profile_detail', kwargs={'pk': self.id}) @@ -136,9 +139,9 @@ def get_profiles(slug): if tbl.contributor is not None] unreg_id_list = [tbl.unregistered_author.id for tbl in publications.all() \ if tbl.unregistered_author is not None] - print (unreg_id_list) return Profile.objects.filter(models.Q(contributor__id__in=cont_id_list) | - models.Q(unregisteredauthor__id__in=unreg_id_list)) + models.Q(unregisteredauthor__id__in=unreg_id_list + )).distinct() class ProfileNonDuplicates(models.Model): diff --git a/profiles/templates/profiles/_profile_card.html b/profiles/templates/profiles/_profile_card.html index 49698c21080a857be7bbad6498aa1984a4d05a34..503bd04d65b0f3df805f52d734073582574cbae0 100644 --- a/profiles/templates/profiles/_profile_card.html +++ b/profiles/templates/profiles/_profile_card.html @@ -1,28 +1,31 @@ {% load bootstrap %} {% load scipost_extras %} +{% load user_groups %} +{% is_edcol_admin request.user as is_edcol_admin %} +{% is_scipost_admin request.user as is_scipost_admin %} <div class="card"> <div class="card-header"> - Details + Details for profile {{ profile.id }} </div> <div class="card-body"> <table class="table"> <tr> <td>Name:</td> - <td>{{ profile.last_name }}, {{ profile.get_title_display }} {{ profile.first_name }}</td> + <td>{{ profile }}</td> </tr> <tr> <td>Email(s)</td> <td> <table class="table table-sm"> <thead> - <tr> - <th colspan="2">Email</th> - <th>Still valid</th> - <th></th> - </tr> + <tr> + <th colspan="2">Email</th> + <th>Still valid</th> + <th></th> + </tr> </thead> {% for profile_mail in profile.emails.all %} <tr> @@ -56,7 +59,7 @@ <tr><td>Webpage</td><td><a href="{{ profile.webpage }}" target="_blank">{{ profile.webpage }}</a></td></tr> <tr><td>Accepts SciPost emails</td><td>{{ profile.accepts_SciPost_emails }}</td></tr> <tr><td>Accepts refereeing requests</td><td>{{ profile.accepts_refereeing_requests }}</td></tr> - <tr><td>Contributor</td><td>{% if profile.contributor %}Yes (<a href="{% url 'scipost:contributor_info' contributor_id=profile.contributor.id %}" target="_blank">info link</a>){% else %}No{% endif %}</td></tr> + <tr><td>Contributor</td><td>{% if profile.contributor %}Yes, id: {{ profile.contributor.pk }}, status: {{ profile.contributor.get_status_display }}, user active: {{ profile.contributor.user.is_active }} (<a href="{% url 'scipost:contributor_info' contributor_id=profile.contributor.id %}" target="_blank">info link</a>){% else %}No{% endif %}</td></tr> </table> </div> </div> @@ -65,43 +68,51 @@ <div class="card"> <div class="card-header"> - Actions + Publications </div> <div class="card-body"> <ul> - <li><a href="{% url 'profiles:profile_update' pk=profile.id %}">Update</a> this Profile</li> - <li><a href="{% url 'profiles:profile_delete' pk=profile.id %}" class="text-danger">Delete</a> this Profile</li> - {% if email_form %} - <li> - <div> - Add an email to this Profile: - <form action="{% url 'profiles:add_profile_email' profile_id=profile.id %}" method="post"> - {% csrf_token %} - {{ email_form|bootstrap }} - <input class="btn btn-outline-secondary" type="submit" value="Add"> - </form> - </div> - </li> - {% endif %} + {% for pub in profile.publications.all %} + <li><a href="{{ pub.get_absolute_url }}">{{ pub.citation }}</a></li> + {% empty %} + <li>No Publication found</li> + {% endfor %} </ul> </div> </div> <div class="card"> <div class="card-header"> - Refereeing invitations + Comments </div> <div class="card-body"> - <ul> - {% for inv in profile.refereeinvitation_set.all %} - <li>{{ inv.submission.title }}<br/>(invited {{ inv.date_invited }}; fulfilled: {% if inv.fulfilled %}<i class="fa fa-check-square text-success"></i>{% else %}<i class="fa fa-times-circle"></i>{% endif %})</li> - {% empty %} - <li>No refereeing invitation found</li> - {% endfor %} - </ul> + {% for comment in profile.comments.all %} + <li><a href="{{ comment.get_absolute_url }}">{{ comment }}</a></li> + {% empty %} + <li>No Comment found</li> + {% endfor %} </div> </div> + <div class="card"> + <div class="card-header"> + Theses + </div> + <div class="card-body"> + {% for thesis in profile.theses.all %} + <li><a href="{{ thesis.get_absolute_url }}">{{ thesis }}</a></li> + {% empty %} + <li>No Thesis found</li> + {% endfor %} + </div> + </div> + +</div> + +{% if is_scipost_admin or is_edcol_admin %} +<h4 class="highlight p-2 text-danger">Admin-level info</h4> +<div class="card-columns"> + <div class="card"> <div class="card-header"> Registration invitations @@ -109,7 +120,7 @@ <div class="card-body"> <ul> {% for reginv in profile.registrationinvitation_set.all %} - <li>{{ reginv }}</li> + <li>{{ reginv }}<br/>status: {{ reginv.get_status_display }}</li> {% empty %} <li>No invitation found</li> {% endfor %} @@ -119,43 +130,70 @@ <div class="card"> <div class="card-header"> - Publications + Fellowships and Potential Fellowships </div> <div class="card-body"> + <h5>Fellowships</h5> <ul> - {% for pub in profile.publications.all %} - <li><a href="{{ pub.get_absolute_url }}">{{ pub.citation }}</a></li> + {% for fellowship in profile.contributor.fellowships.all %} + <li><a href="{{ fellowship.get_absolute_url }}">{{ fellowship }}</a></li> {% empty %} - <li>No Publication found</li> + <li>No fellowships found</li> + {% endfor %} + </ul> + <h5>Potential Fellowships</h5> + <ul> + {% for potfellowship in profile.potentialfellowship_set.all %} + <li>{{ potfellowship }}</li> + {% empty %} + <li>No Potential Fellowships found</li> {% endfor %} </ul> </div> </div> + + <div class="card"> <div class="card-header"> - Comments + Refereeing invitations </div> <div class="card-body"> - {% for comment in profile.comments.all %} - <li><a href="{{ comment.get_absolute_url }}">{{ comment }}</a></li> - {% empty %} - <li>No Comment found</li> - {% endfor %} + <ul> + {% for inv in profile.refereeinvitation_set.all %} + <li>{{ inv.submission.title }}<br/>(invited {{ inv.date_invited }}; fulfilled: {% if inv.fulfilled %}<i class="fa fa-check-square text-success"></i>{% else %}<i class="fa fa-times-circle"></i>{% endif %})</li> + {% empty %} + <li>No refereeing invitation found</li> + {% endfor %} + </ul> </div> </div> +</div> +<div class="card-columns"> <div class="card"> <div class="card-header"> - Theses + Actions </div> <div class="card-body"> - {% for thesis in profile.theses.all %} - <li><a href="{{ thesis.get_absolute_url }}">{{ thesis }}</a></li> - {% empty %} - <li>No Thesis found</li> - {% endfor %} + <ul> + <li><a href="{% url 'profiles:profile_update' pk=profile.id %}">Update</a> this Profile</li> + <li><a href="{% url 'profiles:profile_delete' pk=profile.id %}" class="text-danger">Delete</a> this Profile</li> + {% if email_form %} + <li> + <div> + Add an email to this Profile: + <form class="form-inline" action="{% url 'profiles:add_profile_email' profile_id=profile.id %}" method="post"> + {% csrf_token %} + {{ email_form|bootstrap }} + <input class="btn btn-outline-secondary" type="submit" value="Add"> + </form> + </div> + </li> + {% endif %} + </ul> </div> </div> </div> +{% endif %} diff --git a/profiles/templates/profiles/profile_form.html b/profiles/templates/profiles/profile_form.html index 1f8b76b6cbc9edf97e51333ff71ee8bd377103b5..b5e021cfcd8da36d25df6b56fd5325c926068176 100644 --- a/profiles/templates/profiles/profile_form.html +++ b/profiles/templates/profiles/profile_form.html @@ -4,7 +4,7 @@ {% block breadcrumb_items %} {{ block.super }} - <span class="breadcrumb-item">{% if form.instance.id %}Update {{ form.instance }}{% else %}Add new Profile{% endif %}</span> + <span class="breadcrumb-item">{% if form.instance.id %}Update {{ form.instance }}{% else %}Add new Profile {% if from_type %}(from {{ from_type }}){% endif %}{% endif %}</span> {% endblock %} {% block pagetitle %}: Profiles{% endblock pagetitle %} @@ -16,7 +16,7 @@ <h4>Matching profiles found for this {{ from_type }}</h4> <ul> {% for matching_profile in matching_profiles %} - <li>{{ matching_profile }} <a href="{% url 'profiles:profile_match' profile_id=matching_profile.id from_type=from_type pk=pk %}">match this {{ from_type }} to this Profile</a> + <li>{{ matching_profile }} (id {{ matching_profile.id }}, {{ matching_profile.email }}) <a href="{% url 'profiles:profile_match' profile_id=matching_profile.id from_type=from_type pk=pk %}"><i class="fa fa-arrow-right"></i> Match this {{ from_type }} to this Profile</a> </li> {% endfor %} </ul> diff --git a/profiles/templates/profiles/profile_list.html b/profiles/templates/profiles/profile_list.html index b0539904e8a23e4d9db4146097ef799f654a0c74..a28bbeed51d5a5a44e2a17663263925a58838c94 100644 --- a/profiles/templates/profiles/profile_list.html +++ b/profiles/templates/profiles/profile_list.html @@ -3,6 +3,8 @@ {% load bootstrap %} {% load add_get_parameters %} {% load scipost_extras %} +{% load user_groups %} + {% block breadcrumb_items %} {{ block.super }} @@ -22,82 +24,102 @@ $(document).ready(function($) { {% block pagetitle %}: Profiles{% endblock pagetitle %} {% block content %} + +{% is_edcol_admin request.user as is_edcol_admin %} +{% is_scipost_admin request.user as is_scipost_admin %} + <div class="row"> <div class="col-12"> <h4>Profiles-related Actions:</h4> <ul> - <li><a href="{% url 'profiles:duplicates' %}">Check for duplicates</a></li> - {% if contributors_w_duplicate_email %} - <li class="text-danger"> - {{ contributors_w_duplicate_email|length }} Contributor duplicates (via email) identified - <ul> - {% for dup in contributors_w_duplicate_email %} - <li>{{ dup }}, {{ dup.user.email }}, id {{ dup.id }}</li> - {% empty %} - <li>No duplicates found</li> - {% endfor %} - </ul> - Please take action by merging these Contributor instances. - </li> - {% endif %} - <li><a href="{% url 'profiles:profile_create' %}">Add a Profile</a></li> - {% if next_reginv_wo_profile %} - <li>Create a Profile for <a href="{% url 'profiles:profile_create' from_type='registrationinvitation' pk=next_reginv_wo_profile.id %}">the next</a> Registration Invitation without one ({{ nr_reginv_wo_profile }} to handle)</li> - {% endif %} - {% if next_contributor_wo_profile %} - <li>Create a Profile for <a href="{% url 'profiles:profile_create' from_type='contributor' pk=next_contributor_wo_profile.id %}">the next</a> Contributor without one ({{ nr_contributors_wo_profile }} to handle)</li> - {% endif %} - {% if next_unreg_auth_wo_profile %} - <li>Create a Profile for <a href="{% url 'profiles:profile_create' from_type='unregisteredauthor' pk=next_unreg_auth_wo_profile.id %}">the next</a> UnregisteredAuthor without one ({{ nr_unreg_auth_wo_profile }} to handle)</li> - {% endif %} - {% if next_refinv_wo_profile %} - <li>Create a Profile for <a href="{% url 'profiles:profile_create' from_type='refereeinvitation' pk=next_refinv_wo_profile.id %}">the next</a> Referee Invitation without one ({{ nr_refinv_wo_profile }} to handle)</li> - {% endif %} - </ul> - <h4>Specialize the list:</h4> - <ul> - <li> - <ul class="list-inline"> - <li class="list-inline-item"> - <a href="{% url 'profiles:profiles' %}">View all</a> or view by discipline/subject area: - </li> - {% for discipline in subject_areas %} - <li class="list-inline-item"> - <div class="dropdown"> - <button class="btn btn-primary dropdown-toggle" type="button" id="dropdownMenuButton{{ discipline.0|cut:" " }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">{{ discipline.0 }}</button> - <div class="dropdown-menu" aria-labelledby="dropdownMenuButton{{ discipline.0|cut:" " }}"> - <a class="dropdown-item" href="{% add_get_parameters discipline=discipline.0|cut:' ' %}">View all in {{ discipline.0 }}</a> - {% for area in discipline.1 %} - <a class="dropdown-item" href="{% add_get_parameters discipline=discipline.0|cut:' ' expertise=area.0 %}">{{ area.0 }}</a> - {% endfor %} - </div> + {% if is_scipost_admin or is_edcol_admin %} + {% if nr_contributors_w_duplicate_names > 0 %} + <li><i class="fa fa-exclamation-circle text-warning"></i> <a href="{% url 'scipost:contributor_duplicates' %}?kind=names">Handle Contributors with duplicate names ({{ nr_contributors_w_duplicate_names }} to handle)</a></li> + {% else %} + <li><i class="fa fa-check-circle text-success"></i> No name-duplicate Contributors found</li> + {% endif %} + {% if nr_contributors_w_duplicate_emails > 0 %} + <li><a href="{% url 'scipost:contributor_duplicates' %}?kind=names">Handle Contributors with duplicate emails ({{ nr_contributors_w_duplicate_emails }} to handle)</a></li> + {% else %} + <li><i class="fa fa-check-circle text-success"></i> No email-duplicate Contributors found</li> + {% endif %} + {% if next_contributor_wo_profile %} + <li><i class="fa fa-exclamation-circle text-warning"></i> Create a Profile for <a href="{% url 'profiles:profile_create' from_type='contributor' pk=next_contributor_wo_profile.id %}">the next</a> Contributor without one ({{ nr_contributors_wo_profile }} to handle)</li> + {% else %} + <li><i class="fa fa-check-circle text-success"></i> All registered Contributors have a Profile</li> + {% endif %} + {% if nr_potential_duplicate_profiles > 0 %} + <li><i class="fa fa-exclamation-circle text-warning"></i> <a href="{% url 'profiles:duplicates' %}">Check for duplicate Profiles ({{ nr_potential_duplicate_profiles }} to handle)</a></li> + {% else %} + <li><i class="fa fa-check-circle text-success"></i> No potential duplicate Profiles detected</li> + {% endif %} + {% if next_reginv_wo_profile %} + <li><i class="fa fa-exclamation-circle text-warning"></i> Create a Profile for <a href="{% url 'profiles:profile_create' from_type='registrationinvitation' pk=next_reginv_wo_profile.id %}">the next</a> Registration Invitation without one ({{ nr_reginv_wo_profile }} to handle)</li> + {% else %} + <li><i class="fa fa-check-circle text-success"></i> All Registration Invitations have a Profile</li> + {% endif %} + {% if next_unreg_auth_wo_profile %} + <li><i class="fa fa-exclamation-circle text-warning"></i> Create a Profile for <a href="{% url 'profiles:profile_create' from_type='unregisteredauthor' pk=next_unreg_auth_wo_profile.id %}">the next</a> UnregisteredAuthor without one ({{ nr_unreg_auth_wo_profile }} to handle)</li> + {% else %} + <li><i class="fa fa-check-circle text-success"></i> All UnregisteredAuthors have a Profile</li> + {% endif %} + {% if next_refinv_wo_profile %} + <li><i class="fa fa-exclamation-circle text-warning"></i> Create a Profile for <a href="{% url 'profiles:profile_create' from_type='refereeinvitation' pk=next_refinv_wo_profile.id %}">the next</a> Referee Invitation without one ({{ nr_refinv_wo_profile }} to handle)</li> + {% else %} + <li><i class="fa fa-check-circle text-success"></i> All Referee Invitations have a Profile</li> + {% endif %} + {% endif %} + <li><a href="{% url 'profiles:profile_create' %}">Add a Profile</a></li> + </ul> + </div> +</div> + +<div class="row"> + <div class="col-12"> + <h4>Specialize the list:</h4> + <ul> + <li> + <ul class="list-inline"> + <li class="list-inline-item"> + <a href="{% url 'profiles:profiles' %}">View all</a> or view by discipline/subject area: + </li> + {% for discipline in subject_areas %} + <li class="list-inline-item"> + <div class="dropdown"> + <button class="btn btn-primary dropdown-toggle" type="button" id="dropdownMenuButton{{ discipline.0|cut:" " }}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">{{ discipline.0 }}</button> + <div class="dropdown-menu" aria-labelledby="dropdownMenuButton{{ discipline.0|cut:" " }}"> + <a class="dropdown-item" href="{% add_get_parameters discipline=discipline.0|cut:' ' %}">View all in {{ discipline.0 }}</a> + {% for area in discipline.1 %} + <a class="dropdown-item" href="{% add_get_parameters discipline=discipline.0|cut:' ' expertise=area.0 %}">{{ area.0 }}</a> + {% endfor %} </div> - </li> - {% endfor %} - </ul> - </li> - <li>View only Profiles <a href="{% add_get_parameters contributor=True %}">with</a> or <a href="{% add_get_parameters contributor=False %}">without</a> an associated Contributor</li> - <li> - <ul class="list-inline"> - <li class="list-inline-item">Last name startswith:</li> - <li class="list-inline-item"> - <form action="" method="get">{{ searchform }} - {% if request.GET.discipline %} - <input type="hidden" name="discipline" value="{{ request.GET.discipline }}"> - {% if request.GET.expertise %} - <input type="hidden" name="expertise" value="{{ request.GET.expertise }}"> - {% endif %} - {% endif %} - {% if request.GET.contributor %} - <input type="hidden" name="contributor" value="{{ request.GET.contributor }}"> - {% endif %} - </li> - <li class="list-inline-item"><input class="btn btn-outline-secondary" type="submit" value="Search"></form> - </li> - </ul> - </li> + </div> + </li> + {% endfor %} + </ul> + </li> + <li>View only Profiles <a href="{% add_get_parameters contributor=True %}">with</a> or <a href="{% add_get_parameters contributor=False %}">without</a> an associated Contributor</li> + <li> + <ul class="list-inline"> + <li class="list-inline-item">Last name startswith:</li> + <li class="list-inline-item"> + <form action="" method="get">{{ searchform }} + {% if request.GET.discipline %} + <input type="hidden" name="discipline" value="{{ request.GET.discipline }}"> + {% if request.GET.expertise %} + <input type="hidden" name="expertise" value="{{ request.GET.expertise }}"> + {% endif %} + {% endif %} + {% if request.GET.contributor %} + <input type="hidden" name="contributor" value="{{ request.GET.contributor }}"> + {% endif %} + </li> + <li class="list-inline-item"><input class="btn btn-outline-secondary" type="submit" value="Search"></form> + </li> </ul> - </div> +</li> +</ul> +</div> </div> <div class="row"> @@ -117,14 +139,14 @@ $(document).ready(function($) { <tbody> {% for profile in object_list %} <tr class="table-row" data-href="{% url 'profiles:profile_detail' pk=profile.id %}" target="_blank" style="cursor: pointer;"> - <td>{{ profile.last_name }}, {{ profile.get_title_display }} {{ profile.first_name }}</td> + <td>{{ profile }}</td> <td>{{ profile.get_discipline_display }}</td> <td> {% for expertise in profile.expertises %} <div class="single d-inline" data-specialization="{{expertise|lower}}" data-toggle="tooltip" data-placement="bottom" title="{{expertise|get_specialization_display}}">{{expertise|get_specialization_code}}</div> {% endfor %} </td> - <td>{% if profile.contributor %}<i class="fa fa-check-circle text-success"></i>{% else %}<i class="fa fa-times-circle text-danger"></i>{% endif %}</td> + <td>{% if profile.has_active_contributor %}<i class="fa fa-check-circle text-success"></i>{% else %}<i class="fa fa-times-circle text-danger"></i>{% endif %}</td> </tr> {% empty %} <tr> diff --git a/profiles/templates/profiles/profile_merge.html b/profiles/templates/profiles/profile_merge.html index 12cc944d6516421265fcfa9e221f70e01dd338aa..3952b44fda30e416f12293ef68d3b751daab1501 100644 --- a/profiles/templates/profiles/profile_merge.html +++ b/profiles/templates/profiles/profile_merge.html @@ -16,6 +16,10 @@ <div class="row"> <div class="col-12"> <h1 class="highlight">Merge Profiles {{ profile_to_merge.id }} and {{ profile_to_merge_into.id }}</h1> + {% if profile_to_merge.has_active_contributor and not profile_to_merge_into.has_active_contributor %} + <h3 class="text-danger">Warning: the Profile to merge is associated to an active Contributor, while the one to merge into is not</h3> + <p>Consider <a href="{% url 'profiles:merge' %}?to_merge={{ profile_to_merge_into.id }}&to_merge_into={{ profile_to_merge.id }}" method="get">merging the other way around</a></p> + {% endif %} </div> </div> <div class="row"> @@ -38,6 +42,7 @@ {% csrf_token %} {{ merge_form|bootstrap }} <input class="btn btn-primary" type="submit" value="Confirm merge"> + <a class="text-warning" href="{% url 'profiles:merge' %}?to_merge={{ profile_to_merge_into.id }}&to_merge_into={{ profile_to_merge.id }}" method="get">Merge the other way around</a></p> </form> </div> </div> diff --git a/profiles/views.py b/profiles/views.py index 991e203198fdbdb9967aeb1dc76912e85e3d2f4b..4ece47f98d620478e2d9520997534d56f85ba973 100644 --- a/profiles/views.py +++ b/profiles/views.py @@ -6,7 +6,7 @@ from django.contrib import messages from django.core.urlresolvers import reverse, reverse_lazy from django.db import transaction from django.db.models import Q -from django.http import Http404 +from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404, render, redirect from django.views.decorators.http import require_POST from django.views.generic.detail import DetailView @@ -70,7 +70,8 @@ class ProfileCreateView(PermissionsMixin, CreateView): matching_profiles = matching_profiles.filter( Q(last_name=reginv.last_name) | Q(emails__email__in=reginv.email)) - context['matching_profiles'] = matching_profiles[:10] + context['matching_profiles'] = matching_profiles.distinct().order_by( + 'last_name', 'first_name') return context def get_initial(self): @@ -131,29 +132,52 @@ class ProfileCreateView(PermissionsMixin, CreateView): def profile_match(request, profile_id, from_type, pk): """ Links an existing Profile to one of existing - Contributor, UnregisteredAuthor, RefereeInvitation, RegistrationInvitation. + Contributor, UnregisteredAuthor, RefereeInvitation or RegistrationInvitation. + + Profile relates to Contributor as OneToOne. + Matching is thus only allowed if there are no duplicate objects for these elements. + + For matching the Profile to a Contributor, the following preconditions are defined: + - the Profile has no association to another Contributor + - the Contributor has no association to another Profile + If these are not met, no action is taken. """ profile = get_object_or_404(Profile, pk=profile_id) + nr_rows = 0 if from_type == 'contributor': + if hasattr(profile, 'contributor') and profile.contributor.id != pk: + messages.error(request, + 'Error: cannot math this Profile to this Contributor, ' + 'since this Profile already has a different Contributor.\n' + 'Please merge the duplicate Contributors first.') + return redirect(reverse('profiles:profiles')) contributor = get_object_or_404(Contributor, pk=pk) - contributor.profile = profile - contributor.save() - messages.success(request, 'Profile matched with Contributor') + if contributor.profile and contributor.profile.id != profile.id: + messages.error(request, + 'Error: cannot match this Profile to this Contributor, ' + 'since this Contributor already has a different Profile.\n' + 'Please merge the duplicate Profiles first.') + return redirect(reverse('profiles:profiles')) + # Preconditions are met, match: + nr_rows = Contributor.objects.filter(pk=pk).update(profile=profile) + # Give priority to the email coming from Contributor + profile.emails.update(primary=False) + email, __ = ProfileEmail.objects.get_or_create( + profile=profile, email=contributor.user.email) + profile.emails.filter(id=email.id).update(primary=True, still_valid=True) elif from_type == 'unregisteredauthor': - unreg_auth = get_object_or_404(UnregisteredAuthor, pk=pk) - unreg_auth.profile = profile - unreg_auth.save() - messages.success(request, 'Profile matched with UnregisteredAuthor') + nr_rows = UnregisteredAuthor.objects.filter(pk=pk).update(profile=profile) elif from_type == 'refereeinvitation': - ref_inv = get_object_or_404(RefereeInvitation, pk=pk) - ref_inv.profile = profile - ref_inv.save() - messages.success(request, 'Profile matched with RefereeInvitation') + nr_rows = RefereeInvitation.objects.filter(pk=pk).update(profile=profile) elif from_type == 'registrationinvitation': - reg_inv = get_object_or_404(RegistrationInvitation, pk=pk) - reg_inv.profile = profile - reg_inv.save() - messages.success(request, 'Profile matched with RegistrationInvitation') + nr_rows = RegistrationInvitation.objects.filter(pk=pk).update(profile=profile) + if nr_rows == 1: + messages.success(request, 'Profile matched with %s' % from_type) + else: + messages.error( + request, + 'Error: Profile matching with %s: updated %s rows instead of 1!' + 'Please contact techsupport' % (from_type, nr_rows)) return redirect(reverse('profiles:profiles')) @@ -214,7 +238,10 @@ class ProfileListView(PermissionsMixin, PaginationMixin, ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - contributors_wo_profile = Contributor.objects.filter(profile__isnull=True) + contributors_w_duplicate_email = Contributor.objects.with_duplicate_email() + contributors_w_duplicate_names = Contributor.objects.with_duplicate_names() + contributors_wo_profile = Contributor.objects.active().filter(profile__isnull=True) + nr_potential_duplicate_profiles = Profile.objects.potential_duplicates().count() unreg_auth_wo_profile = UnregisteredAuthor.objects.filter(profile__isnull=True) refinv_wo_profile = RefereeInvitation.objects.filter(profile__isnull=True) reginv_wo_profile = RegistrationInvitation.objects.filter(profile__isnull=True) @@ -222,8 +249,10 @@ class ProfileListView(PermissionsMixin, PaginationMixin, ListView): context.update({ 'subject_areas': SCIPOST_SUBJECT_AREAS, 'searchform': SearchTextForm(initial={'text': self.request.GET.get('text')}), - 'contributors_w_duplicate_email': Contributor.objects.have_duplicate_email(), + 'nr_contributors_w_duplicate_emails': contributors_w_duplicate_email.count(), + 'nr_contributors_w_duplicate_names': contributors_w_duplicate_names.count(), 'nr_contributors_wo_profile': contributors_wo_profile.count(), + 'nr_potential_duplicate_profiles': nr_potential_duplicate_profiles, 'next_contributor_wo_profile': contributors_wo_profile.first(), 'nr_unreg_auth_wo_profile': unreg_auth_wo_profile.count(), 'next_unreg_auth_wo_profile': unreg_auth_wo_profile.first(), @@ -269,15 +298,29 @@ def profile_merge(request): merge_form = ProfileMergeForm(request.POST or None, initial=request.GET) context = {'merge_form': merge_form} - if merge_form.is_valid(): - profile = merge_form.save() - messages.success(request, 'Profiles merged') - return redirect(profile.get_absolute_url()) + if request.method == 'POST': + if merge_form.is_valid(): + profile = merge_form.save() + messages.success(request, 'Profiles merged') + return redirect(profile.get_absolute_url()) + else: + try: + context.update({ + 'profile_to_merge': get_object_or_404( + Profile, pk=merge_form.cleaned_data['to_merge'].id), + 'profile_to_merge_into': get_object_or_404( + Profile, pk=merge_form.cleaned_data['to_merge_into'].id) + }) + except ValueError: + raise Http404 + elif request.method == 'GET': try: context.update({ - 'profile_to_merge': get_object_or_404(Profile, pk=int(request.GET['to_merge'])), - 'profile_to_merge_into': get_object_or_404(Profile, pk=int(request.GET['to_merge_into'])) + 'profile_to_merge': get_object_or_404(Profile, + pk=int(request.GET['to_merge'])), + 'profile_to_merge_into': get_object_or_404(Profile, + pk=int(request.GET['to_merge_into'])) }) except ValueError: raise Http404 @@ -298,7 +341,9 @@ def add_profile_email(request, profile_id): else: for field, err in form.errors.items(): messages.warning(request, err[0]) - return redirect(reverse('profiles:profiles')) + if request.POST.get('next', None): + return HttpResponseRedirect(request.POST.get('next')) + return redirect(profile.get_absolute_url()) @require_POST @@ -321,7 +366,7 @@ def toggle_email_status(request, email_id): profile_email = get_object_or_404(ProfileEmail, pk=email_id) ProfileEmail.objects.filter(id=email_id).update(still_valid=not profile_email.still_valid) messages.success(request, 'Email updated') - return redirect('profiles:profiles') + return redirect(profile_email.profile.get_absolute_url()) @require_POST @@ -331,4 +376,4 @@ def delete_profile_email(request, email_id): profile_email = get_object_or_404(ProfileEmail, pk=email_id) profile_email.delete() messages.success(request, 'Email deleted') - return redirect('profiles:profiles') + return redirect(profile_email.profile.get_absolute_url()) diff --git a/requirements.txt b/requirements.txt index 2f75da286cca0ebbac9c2976edd5c2f757da950e..d96ced8b7e1612849193b4f8f85fabf60d5c11e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,7 +20,6 @@ django-mathjax==0.0.8 django-mptt==0.8.6 # Dead django-sphinxdoc==1.5.1 django-silk==2.0.0 -django-recaptcha==1.3.1 django-webpack-loader==0.5 django-maintenancemode-2==1.1.11 diff --git a/scipost/admin.py b/scipost/admin.py index 097b6c7f38f195d2998fb863965c5f2e6d085719..5eeb7a855d3aee19a0c8f6b606f1eb37610d15ae 100644 --- a/scipost/admin.py +++ b/scipost/admin.py @@ -41,8 +41,14 @@ class UserAdmin(UserAdmin): ContactToUserInline, ProductionUserInline ] + list_display = ['username', 'email', 'first_name', 'last_name', + 'is_active', 'is_staff', 'is_duplicate'] search_fields = ['last_name', 'email'] + def is_duplicate(self, obj): + return obj.contributor.is_duplicate + is_duplicate.short_description = 'Is duplicate?' + is_duplicate.boolean = True admin.site.unregister(User) admin.site.register(Contributor, ContributorAdmin) diff --git a/scipost/fields.py b/scipost/fields.py index dc3a5137a2e12b72e2b2de2a77ab8a3bc529b0e4..eea67bdc958c6b0f287ab16403f6cfa16eb46f3c 100644 --- a/scipost/fields.py +++ b/scipost/fields.py @@ -1,9 +1,16 @@ __copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" +import json +import requests from django import forms +from django.core.exceptions import ValidationError +from django.conf import settings from django.contrib.postgres.fields import ArrayField +from django.utils.encoding import force_text + +from .widgets import ReCaptcha class ChoiceArrayField(ArrayField): @@ -21,3 +28,62 @@ class ChoiceArrayField(ArrayField): } defaults.update(kwargs) return super(ArrayField, self).formfield(**defaults) + + +class ReCaptchaField(forms.CharField): + default_error_messages = { + 'captcha_invalid': 'Incorrect, please try again.', + 'captcha_error': 'Error verifying input, please try again.', + } + + def __init__(self, use_ssl=None, attrs=None, *args, **kwargs): + """ + ReCaptchaField can accepts attributes which is a dictionary of + attributes to be passed to the ReCaptcha widget class. The widget will + loop over any options added and create the RecaptchaOptions + JavaScript variables as specified in + https://developers.google.com/recaptcha/docs/display#render_param + """ + if attrs is None: + attrs = {} + + public_key = settings.RECAPTCHA_PUBLIC_KEY + self.use_ssl = getattr(settings, 'RECAPTCHA_USE_SSL', True) + self.widget = ReCaptcha(public_key=public_key, attrs=attrs) + self.required = True + self.verify_url = 'https://www.recaptcha.net/recaptcha/api/siteverify' + super().__init__(*args, **kwargs) + + def clean(self, values): + super().clean(values[0]) + recaptcha_response = force_text(values[0]) + + if not self.required: + return + + data = { + 'secret': settings.RECAPTCHA_PRIVATE_KEY, + 'response': recaptcha_response + } + + r = requests.post( + self.verify_url, + data=data, + headers={ + 'Content-type': 'application/x-www-form-urlencoded', + 'User-agent': 'reCAPTCHA Python' + }) + try: + r.raise_for_status() + response = r.json() + catpcha_success = response.get('success', False) + except (requests.exceptions.HTTPError, requests.exceptions.Timeout): + raise ValidationError( + self.error_messages['captcha_error'] + ) + + if not catpcha_success: + raise ValidationError( + self.error_messages['captcha_invalid'] + ) + return values[0] diff --git a/scipost/forms.py b/scipost/forms.py index 3a42e63fe017cbb3680442ac345c0f1a94ab4d9d..f2c7ed99a716539fdf156a0b47dd8a953f4b26a7 100644 --- a/scipost/forms.py +++ b/scipost/forms.py @@ -8,6 +8,7 @@ from django import forms from django.contrib.auth import authenticate from django.contrib.auth.models import User, Group from django.contrib.auth.password_validation import validate_password +from django.contrib.auth.validators import UnicodeUsernameValidator from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse_lazy from django.db.models import Q @@ -18,7 +19,6 @@ from django.utils.http import is_safe_url from django_countries import countries from django_countries.widgets import CountrySelectWidget from django_countries.fields import LazyTypedChoiceField -from captcha.fields import ReCaptchaField from ajax_select.fields import AutoCompleteSelectField from haystack.forms import ModelSearchForm as HayStackSearchForm @@ -28,15 +28,25 @@ from .constants import ( SCIPOST_DISCIPLINES, TITLE_CHOICES, SCIPOST_FROM_ADDRESSES, NO_SCIENTIST, DOUBLE_ACCOUNT, BARRED) from .decorators import has_contributor -from .models import Contributor, DraftInvitation, UnavailabilityPeriod, PrecookedEmail +from .fields import ReCaptchaField +from .models import Contributor, DraftInvitation, UnavailabilityPeriod, \ + Remark, AuthorshipClaim, PrecookedEmail from affiliations.models import Affiliation, Institution -from common.forms import MonthYearWidget +from common.forms import MonthYearWidget, ModelChoiceFieldwithid from partners.decorators import has_contact +from colleges.models import Fellowship, PotentialFellowshipEvent +from commentaries.models import Commentary from comments.models import Comment -from journals.models import Publication -from submissions.models import Report +from funders.models import Grant +from invitations.models import CitationNotification +from journals.models import PublicationAuthorsTable, Publication +from mails.utils import DirectMailUtil +from submissions.models import Submission, EditorialAssignment, RefereeInvitation, Report, \ + EditorialCommunication, EICRecommendation +from theses.models import ThesisLink +from virtualmeetings.models import Feedback, Nomination, Motion REGISTRATION_REFUSAL_CHOICES = ( @@ -98,11 +108,12 @@ class RegistrationForm(forms.Form): personalwebpage = forms.URLField( label='Personal web page', required=False, widget=forms.TextInput({'placeholder': 'full URL, e.g. http://www.[yourpage].com'})) - username = forms.CharField(label='* Username', max_length=100) + username = forms.CharField(label='* Username', max_length=100, + validators=[UnicodeUsernameValidator,]) password = forms.CharField(label='* Password', widget=forms.PasswordInput()) password_verif = forms.CharField(label='* Verify password', widget=forms.PasswordInput(), help_text='Your password must contain at least 8 characters') - captcha = ReCaptchaField(attrs={'theme': 'clean'}, label='*Please verify to continue:') + captcha = ReCaptchaField(label='*Please verify to continue:') subscribe = forms.BooleanField( required=False, initial=False, label='Stay informed, subscribe to the SciPost newsletter.') @@ -413,6 +424,203 @@ class UnavailabilityPeriodForm(forms.ModelForm): return end +class ContributorMergeForm(forms.Form): + to_merge = ModelChoiceFieldwithid(queryset=Contributor.objects.all(), empty_label=None) + to_merge_into = ModelChoiceFieldwithid(queryset=Contributor.objects.all(), empty_label=None) + + def clean(self): + data = super().clean() + if self.cleaned_data['to_merge'] == self.cleaned_data['to_merge_into']: + self.add_error(None, 'A Contributor cannot be merged into itself.') + return data + + def save(self): + """ + Merge one Contributor into another. Set the previous Contributor to inactive. + """ + contrib_from = self.cleaned_data['to_merge'] + contrib_into = self.cleaned_data['to_merge_into'] + + both_contribs_active = contrib_from.is_active and contrib_into.is_active + + contrib_from_qs = Contributor.objects.filter(pk=contrib_from.id) + contrib_into_qs = Contributor.objects.filter(pk=contrib_into.id) + + # Step 1: update all fields within Contributor + if contrib_from.profile and not contrib_into.profile: + profile = contrib_from.profile + contrib_from_qs.update(profile=None) + contrib_into_qs.update(profile=profile) + User.objects.filter(pk=contrib_from.user.id).update(is_active=False) + User.objects.filter(pk=contrib_into.user.id).update(is_active=True) + if contrib_from.invitation_key and not contrib_into.invitation_key: + contrib_into_qs.update(invitation_key=contrib_into.invitation_key) + if contrib_from.activation_key and not contrib_into.activation_key: + contrib_into_qs.update(activation_key=contrib_into.activation_key) + contrib_from_qs.update(status=DOUBLE_ACCOUNT) + if contrib_from.orcid_id and not contrib_into.orcid_id: + contrib_into_qs.update(orcid_id=contrib_from.orcid_id) + if contrib_from.personalwebpage and not contrib_into.personalwebpage: + contrib_into_qs.update(personalwebpage=contrib_from.personalwebpage) + + # Specify duplicate_of for deactivated Contributor + contrib_from_qs.update(duplicate_of=contrib_into) + + # Step 2: update all ForeignKey relations + Affiliation.objects.filter(contributor=contrib_from).update(contributor=contrib_into) + Fellowship.objects.filter(contributor=contrib_from).update(contributor=contrib_into) + PotentialFellowshipEvent.objects.filter( + noted_by=contrib_from).update(noted_by=contrib_into) + Commentary.objects.filter(requested_by=contrib_from).update(requested_by=contrib_into) + Commentary.objects.filter(vetted_by=contrib_from).update(vetted_by=contrib_into) + Comment.objects.filter(vetted_by=contrib_from).update(vetted_by=contrib_into) + Comment.objects.filter(author=contrib_from).update(author=contrib_into) + Grant.objects.filter(recipient=contrib_from).update(recipient=contrib_into) + CitationNotification.objects.filter( + contributor=contrib_from).update(contributor=contrib_into) + PublicationAuthorsTable.objects.filter( + contributor=contrib_from).update(contributor=contrib_into) + UnavailabilityPeriod.objects.filter( + contributor=contrib_from).update(contributor=contrib_into) + Remark.objects.filter( + contributor=contrib_from).update(contributor=contrib_into) + DraftInvitation.objects.filter( + drafted_by=contrib_from).update(drafted_by=contrib_into) + AuthorshipClaim.objects.filter( + claimant=contrib_from).update(claimant=contrib_into) + AuthorshipClaim.objects.filter( + vetted_by=contrib_from).update(vetted_by=contrib_into) + Submission.objects.filter( + editor_in_charge=contrib_from).update(editor_in_charge=contrib_into) + Submission.objects.filter( + submitted_by=contrib_from).update(submitted_by=contrib_into) + EditorialAssignment.objects.filter(to=contrib_from).update(to=contrib_into) + RefereeInvitation.objects.filter(referee=contrib_from).update(referee=contrib_into) + RefereeInvitation.objects.filter(invited_by=contrib_from).update(invited_by=contrib_into) + Report.objects.filter(vetted_by=contrib_from).update(vetted_by=contrib_into) + Report.objects.filter(author=contrib_from).update(author=contrib_into) + EditorialCommunication.objects.filter( + referee=contrib_from).update(referee=contrib_into) + ThesisLink.objects.filter(requested_by=contrib_from).update(requested_by=contrib_into) + ThesisLink.objects.filter(vetted_by=contrib_from).update(vetted_by=contrib_into) + Feedback.objects.filter(by=contrib_from).update(by=contrib_into) + Nomination.objects.filter(by=contrib_from).update(by=contrib_into) + Motion.objects.filter(put_forward_by=contrib_from).update(put_forward_by=contrib_into) + + # Step 3: update all ManyToMany + commentaries = Commentary.objects.filter(authors__in=[contrib_from,]).all() + for commentary in commentaries: + commentary.authors.remove(contrib_from) + commentary.authors.add(contrib_into) + commentaries = Commentary.objects.filter(authors_claims__in=[contrib_from,]).all() + for commentary in commentaries: + commentary.authors_claims.remove(contrib_from) + commentary.authors_claims.add(contrib_into) + commentaries = Commentary.objects.filter(authors_false_claims__in=[contrib_from,]).all() + for commentary in commentaries: + commentary.authors_false_claims.remove(contrib_from) + commentary.authors_false_claims.add(contrib_into) + comments = Comment.objects.filter(in_agreement__in=[contrib_from,]).all() + for comment in comments: + comment.in_agreement.remove(contrib_from) + comment.in_agreement.add(contrib_into) + comments = Comment.objects.filter(in_notsure__in=[contrib_from,]).all() + for comment in comments: + comment.in_notsure.remove(contrib_from) + comment.in_notsure.add(contrib_into) + comments = Comment.objects.filter(in_disagreement__in=[contrib_from,]).all() + for comment in comments: + comment.in_disagreement.remove(contrib_from) + comment.in_disagreement.add(contrib_into) + publications = Publication.objects.filter(authors_registered__in=[contrib_from,]).all() + for publication in publications: + publication.authors_registered.remove(contrib_from) + publication.authors_registered.add(contrib_into) + publications = Publication.objects.filter(authors_claims__in=[contrib_from,]).all() + for publication in publications: + publication.authors_claims.remove(contrib_from) + publication.authors_claims.add(contrib_into) + publications = Publication.objects.filter(authors_false_claims__in=[contrib_from,]).all() + for publication in publications: + publication.authors_false_claims.remove(contrib_from) + publication.authors_false_claims.add(contrib_into) + submissions = Submission.objects.filter(authors__in=[contrib_from,]).all() + for submission in submissions: + submission.authors.remove(contrib_from) + submission.authors.add(contrib_into) + submissions = Submission.objects.filter(authors_claims__in=[contrib_from,]).all() + for submission in submissions: + submission.authors_claims.remove(contrib_from) + submission.authors_claims.add(contrib_into) + submissions = Submission.objects.filter(authors_false_claims__in=[contrib_from,]).all() + for submission in submissions: + submission.authors_false_claims.remove(contrib_from) + submission.authors_false_claims.add(contrib_into) + eicrecs = EICRecommendation.objects.filter(eligible_to_vote__in=[contrib_from,]).all() + for eicrec in eicrecs: + eicrec.eligible_to_vote.remove(contrib_from) + eicrec.eligible_to_vote.add(contrib_into) + eicrecs = EICRecommendation.objects.filter(voted_for__in=[contrib_from,]).all() + for eicrec in eicrecs: + eicrec.voted_for.remove(contrib_from) + eicrec.voted_for.add(contrib_into) + eicrecs = EICRecommendation.objects.filter(voted_against__in=[contrib_from,]).all() + for eicrec in eicrecs: + eicrec.voted_against.remove(contrib_from) + eicrec.voted_against.add(contrib_into) + eicrecs = EICRecommendation.objects.filter(voted_abstain__in=[contrib_from,]).all() + for eicrec in eicrecs: + eicrec.voted_abstain.remove(contrib_from) + eicrec.voted_abstain.add(contrib_into) + thesislinks = ThesisLink.objects.filter(author_as_cont__in=[contrib_from,]).all() + for tl in thesislinks: + tl.author_as_cont.remove(contrib_from) + tl.author_as_cont.add(contrib_into) + thesislinks = ThesisLink.objects.filter(author_claims__in=[contrib_from,]).all() + for tl in thesislinks: + tl.author_claims.remove(contrib_from) + tl.author_claims.add(contrib_into) + thesislinks = ThesisLink.objects.filter(author_false_claims__in=[contrib_from,]).all() + for tl in thesislinks: + tl.author_false_claims.remove(contrib_from) + tl.author_false_claims.add(contrib_into) + thesislinks = ThesisLink.objects.filter(supervisor_as_cont__in=[contrib_from,]).all() + for tl in thesislinks: + tl.supervisor_as_cont.remove(contrib_from) + tl.supervisor_as_cont.add(contrib_into) + nominations = Nomination.objects.filter(in_agreement__in=[contrib_from,]).all() + for nom in nominations: + nom.in_agreement.remove(contrib_from) + nom.in_agreement.add(contrib_into) + nominations = Nomination.objects.filter(in_notsure__in=[contrib_from,]).all() + for nom in nominations: + nom.in_notsure.remove(contrib_from) + nom.in_notsure.add(contrib_into) + nominations = Nomination.objects.filter(in_disagreement__in=[contrib_from,]).all() + for nom in nominations: + nom.in_disagreement.remove(contrib_from) + nom.in_disagreement.add(contrib_into) + motions = Motion.objects.filter(in_agreement__in=[contrib_from,]).all() + for nom in motions: + nom.in_agreement.remove(contrib_from) + nom.in_agreement.add(contrib_into) + motions = Motion.objects.filter(in_notsure__in=[contrib_from,]).all() + for nom in motions: + nom.in_notsure.remove(contrib_from) + nom.in_notsure.add(contrib_into) + motions = Motion.objects.filter(in_disagreement__in=[contrib_from,]).all() + for nom in motions: + nom.in_disagreement.remove(contrib_from) + nom.in_disagreement.add(contrib_into) + # If both accounts were active, inform the Contributor of the merge + if both_contribs_active: + mail_sender = DirectMailUtil( + mail_code='contributors/inform_contributor_duplicate_accounts_merged', + contrib_from=Contributor.objects.get(id=contrib_from.id)) + mail_sender.send() + return Contributor.objects.get(id=contrib_into.id) + + class RemarkForm(forms.Form): remark = forms.CharField(widget=forms.Textarea(), label='') diff --git a/scipost/management/commands/add_groups_and_permissions.py b/scipost/management/commands/add_groups_and_permissions.py index 1d13fcd2a8d05b9209234c34155c754de9421a79..c8f3fdf54204861c1df382f308db7252c1432fb7 100644 --- a/scipost/management/commands/add_groups_and_permissions.py +++ b/scipost/management/commands/add_groups_and_permissions.py @@ -403,6 +403,7 @@ class Command(BaseCommand): can_view_pool, can_take_charge_of_submissions, can_create_profiles, + can_view_profiles, can_attend_VGMs, can_view_statistics, can_manage_ontology, diff --git a/scipost/management/commands/check_celery.py b/scipost/management/commands/check_celery.py new file mode 100644 index 0000000000000000000000000000000000000000..4d5aa0dc702f437948fc5d4006fab2d9c005271f --- /dev/null +++ b/scipost/management/commands/check_celery.py @@ -0,0 +1,40 @@ +__copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + + +import datetime + +from django.core import mail +from django.core.management.base import BaseCommand +from django.utils import timezone + +from django_celery_results.models import TaskResult + + +class Command(BaseCommand): + help = 'Check if Celery is still running, or at least not failing.' + + def handle(self, *args, **kwargs): + # check failed. + compare_dt = timezone.now() - datetime.timedelta(hours=1) + results_failed = TaskResult.objects.filter( + status='FAILURE', date_done__gt=compare_dt).order_by('date_done').last() + if results_failed: + # Mail failed + body = 'Celery has failed task results. Last failed ID: {}'.format( + results_failed.id) + mail.mail_admins('Celery failed', body) + self.stdout.write( + self.style.SUCCESS('Celery failed, last ID: {}.'.format(results_failed.id))) + else: + last_result = TaskResult.objects.filter( + date_done__gt=compare_dt).order_by('date_done').last() + if last_result and last_result.date_done < compare_dt: + # Mail inactive + body = 'No results for Celery found. Celery seems to be inactive.' + body += ' Last result ID: {}'.format(last_result.id) + mail.mail_admins('Celery inactive', body) + self.stdout.write( + self.style.SUCCESS('Celery inactive, last ID: {}.'.format(last_result.id))) + if not results_failed and not last_result: + self.stdout.write(self.style.SUCCESS('Celery alive!')) diff --git a/scipost/managers.py b/scipost/managers.py index 516c89c85aeff02cbecc3102e0781319e53eb6fa..abf9abfe38b39e1f658563fd9d21abca2c243613 100644 --- a/scipost/managers.py +++ b/scipost/managers.py @@ -4,10 +4,10 @@ __license__ = "AGPL v3" from django.db import models from django.db.models import Count, Q -from django.db.models.functions import Lower +from django.db.models.functions import Concat, Lower from django.utils import timezone -from .constants import NORMAL_CONTRIBUTOR, NEWLY_REGISTERED, AUTHORSHIP_CLAIM_PENDING +from .constants import NORMAL_CONTRIBUTOR, NEWLY_REGISTERED, DOUBLE_ACCOUNT, AUTHORSHIP_CLAIM_PENDING class FellowManager(models.Manager): @@ -31,13 +31,6 @@ class ContributorQuerySet(models.QuerySet): """Return all validated and vetted Contributors.""" return self.filter(user__is_active=True, status=NORMAL_CONTRIBUTOR) - def have_duplicate_email(self): - """ Return Contributors having duplicate emails. """ - duplicates = self.values(lower_email=Lower('user__email')).annotate( - Count('id')).order_by('user__last_name').filter(id__count__gt=1) - return self.annotate(lower_email=Lower('user__email') - ).filter(lower_email__in=[dup['lower_email'] for dup in duplicates]) - def available(self): """Filter out the Contributors that have active unavailability periods.""" today = timezone.now().date() @@ -51,12 +44,38 @@ class ContributorQuerySet(models.QuerySet): def awaiting_vetting(self): """Filter Contributors that have not been vetted through.""" - return self.filter(user__is_active=True, status=NEWLY_REGISTERED) + return self.filter(user__is_active=True, status=NEWLY_REGISTERED + ).exclude(status=DOUBLE_ACCOUNT) def fellows(self): """TODO: NEEDS UPDATE TO NEW FELLOWSHIP RELATIONS.""" return self.filter(fellowships__isnull=False).distinct() + def with_duplicate_names(self): + """ + Returns only potential duplicate Contributors (as identified by first and + last names). + Admins and superusers are explicitly excluded. + """ + contribs = self.exclude(status=DOUBLE_ACCOUNT + ).exclude(user__is_superuser=True).exclude(user__is_staff=True + ).annotate(full_name=Concat('user__last_name', 'user__first_name')) + duplicates = contribs.values('full_name').annotate( + nr_count=Count('full_name')).filter(nr_count__gt=1).values_list('full_name', flat=True) + return contribs.filter( + full_name__in=duplicates).order_by('user__last_name', 'user__first_name', '-id') + + def with_duplicate_email(self): + """ + Return Contributors having duplicate emails. + """ + qs = self.exclude(status=DOUBLE_ACCOUNT + ).exclude(user__is_superuser=True).exclude( + user__is_staff=True).annotate(lower_email=Lower('user__email')) + duplicates = qs.values('lower_email').annotate( + Count('id')).filter(id__count__gt=1).values_list('lower_email', flat=True) + return qs.filter(user__email__in=duplicates) + class UnavailabilityPeriodManager(models.Manager): def today(self): diff --git a/scipost/migrations/0017_auto_20181115_2150.py b/scipost/migrations/0017_auto_20181115_2150.py new file mode 100644 index 0000000000000000000000000000000000000000..0cb61cbe8813cb8ea31cf7c58be61e428b3174f5 --- /dev/null +++ b/scipost/migrations/0017_auto_20181115_2150.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-11-15 20:50 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('scipost', '0016_auto_20180930_1801'), + ] + + operations = [ + migrations.AlterModelOptions( + name='contributor', + options={'ordering': ['user__last_name', 'user__first_name']}, + ), + ] diff --git a/scipost/migrations/0018_contributor_duplicate_of.py b/scipost/migrations/0018_contributor_duplicate_of.py new file mode 100644 index 0000000000000000000000000000000000000000..cbaa585ff9b61428aa0615c77c2549e726e4f1cf --- /dev/null +++ b/scipost/migrations/0018_contributor_duplicate_of.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-11-17 17:01 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('scipost', '0017_auto_20181115_2150'), + ] + + operations = [ + migrations.AddField( + model_name='contributor', + name='duplicate_of', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='duplicates', to='scipost.Contributor'), + ), + ] diff --git a/scipost/models.py b/scipost/models.py index 7b365d9c5c3f0790fe17f7c69a3edee2f5ec4e74..a64cb490d47f4b98acf1b8f69a13bb6177259765 100644 --- a/scipost/models.py +++ b/scipost/models.py @@ -16,7 +16,7 @@ from django.utils import timezone from .behaviors import TimeStampedModel, orcid_validator from .constants import ( - SCIPOST_DISCIPLINES, SCIPOST_SUBJECT_AREAS, subject_areas_dict, DISABLED, + SCIPOST_DISCIPLINES, SCIPOST_SUBJECT_AREAS, subject_areas_dict, NORMAL_CONTRIBUTOR, DISABLED, TITLE_CHOICES, INVITATION_STYLE, INVITATION_TYPE, INVITATION_CONTRIBUTOR, INVITATION_FORMAL, AUTHORSHIP_CLAIM_PENDING, AUTHORSHIP_CLAIM_STATUS, CONTRIBUTOR_STATUSES, NEWLY_REGISTERED) from .fields import ChoiceArrayField @@ -63,9 +63,15 @@ class Contributor(models.Model): related_name="contrib_vetted_by", blank=True, null=True) accepts_SciPost_emails = models.BooleanField( default=True, verbose_name="I accept to receive SciPost emails") + # If this Contributor is merged into another, then this field is set to point to the new one: + duplicate_of = models.ForeignKey('scipost.Contributor', on_delete=models.SET_NULL, + null=True, blank=True, related_name='duplicates') objects = ContributorQuerySet.as_manager() + class Meta: + ordering = ['user__last_name', 'user__first_name'] + def __str__(self): return '%s, %s' % (self.user.last_name, self.user.first_name) @@ -83,6 +89,17 @@ class Contributor(models.Model): def formal_str(self): return '%s %s' % (self.get_title_display(), self.user.last_name) + def is_active(self): + """ + Checks if the Contributor is registered, vetted, + and has not been deactivated for any reason. + """ + return self.user.is_active and self.status == NORMAL_CONTRIBUTOR + + @property + def is_duplicate(self): + return self.duplicate_of is not None + @property def is_currently_available(self): """Check if Contributor is currently not marked as unavailable.""" diff --git a/scipost/templates/partials/scipost/personal_page/admin_actions.html b/scipost/templates/partials/scipost/personal_page/admin_actions.html index df57362281e9c20806169554dcc75c898773388a..3377ca73701f94b4e8011098329eba22c071b00d 100644 --- a/scipost/templates/partials/scipost/personal_page/admin_actions.html +++ b/scipost/templates/partials/scipost/personal_page/admin_actions.html @@ -11,7 +11,7 @@ </div> <div class="row"> - {% if perms.scipost.can_vet_registration_requests or perms.scipost.can_create_registration_invitations or perms.scipost.can_resend_registration_requests %} + {% if perms.scipost.can_vet_registration_requests or perms.scipost.can_create_registration_invitations or perms.scipost.can_resend_registration_requests or perms.scipost.can_manage_news %} <div class="col-md-4"> <h3>Registration actions</h3> <ul> @@ -26,6 +26,13 @@ {% endif %} </ul> + {% if perms.scipost.can_manage_news %} + <h3>News management</h3> + <ul> + <li><a href="{% url 'news:manage' %}">Manage News Items and Newsletters</a></li> + </ul> + {% endif %} + {% if perms.scipost.can_manage_registration_invitations %} <h3>Notifications</h3> <ul> diff --git a/scipost/templates/scipost/ExpSustDrive2018.html b/scipost/templates/scipost/ExpSustDrive2018.html index 9b51c95cfa9984463c73befb8b8b577c64a525c7..7f6f085cd0bbe831d23f6d3e51293809a6f3465e 100644 --- a/scipost/templates/scipost/ExpSustDrive2018.html +++ b/scipost/templates/scipost/ExpSustDrive2018.html @@ -23,6 +23,7 @@ <p> We are very thankful for the immense support we have received from the scientific community during our initial phase. In view of this success, the time is now ripe for us to be bold, and unleash the next steps in our implementation plans. Read on to see how you can concretely help us bring forth a new age in publishing. </p> + <p><span class="text-danger">Update [2018-11-13]</span>: see our broader expansion plans at our <a href="{% url 'scipost:PlanSciPost' %}">Plan SciPost</a> page.</p> </div> <div class="col-md-7 col-lg-5"> <div class="embed-responsive embed-responsive-16by9"> @@ -43,7 +44,7 @@ </p> <ul> <li>Expansion of our <a href="{% url 'scipost:about' %}#editorial_college_physics">Editorial College</a>,</li> - <li>Expansion of our <a href="{% url 'partners:partners' %}" target="_blank">Supporting Partners</a> Board.</li> + <li>Expansion of our <a href="{% url 'sponsors:sponsors' %}" target="_blank">Sponsors</a> Board.</li> </ul> <p> The first aims to ensure we can process the increasing editorial workflow. The second aims to ensure that we can expand the support team needed to run the infrastructure underlying all our operations (at the moment this still relies on a large amount of pro bono work from the core team). @@ -52,7 +53,7 @@ <p>By the end of 2018, we wish to have achieved:</p> <ul> <li><strong>Expansion</strong>: <i>to have a minimum of 200 Fellows in the <a href="{% url 'scipost:about' %}#editorial_college_physics">Editorial College (Physics)</a>, with a minimum of 8 in each specialization;</i></li><br/> - <li><strong>Sustainability</strong>: <i>to achieve €200 000 yearly income from <a href="{% url 'partners:partners' %}" target="_blank">Supporting Partners</a> or other benefactors.</i></li> + <li><strong>Sustainability</strong>: <i>to achieve €200 000 yearly income from <a href="{% url 'sponsors:sponsors' %}" target="_blank">Sponsors</a> or other benefactors.</i></li> </ul> <p> Our estimates are that we can fully process around 500 publications per year with this upscaling of our cost-slashing infrastructure. @@ -65,8 +66,8 @@ <li><i>Is your field insufficiently represented in our current <a href="{% url 'scipost:about' %}#editorial_college_physics">Editorial College</a>?</i><br/>We are looking for world-class researchers to become Fellows. Send us your nominations at <a href="mailto:admin@scipost.org">admin@scipost.org</a>.<br/>Are you a professorial-level researcher working as editor for non-<a href="https://jscaux.org/blog/post/2018/05/05/genuine-open-access/" target="_blank">Genuine OA</a>-compliant publishers? Looking to invest your expertise in a more community-friendly alternative? Get in touch.<br/>Please note that in view of our development plans, we also welcome nominations in fields beyond Physics.</li> <br/> <li</li> - <li><i>Is your institution or funding agency not listed on our <a href="{% url 'partners:partners' %}" target="_blank">Partners page</a>?</i><br/>Encourage them (through a librarian, Open Access officer, director, ...) to join by <a href="{% url 'petitions:petition' slug='join-SPB' %}" target="_blank">signing our petition</a>, and by personally emailing them directly using this <a href="mailto:?subject=Petition to support SciPost&body={% autoescape on %}{% include 'petitions/petition_email.html' %}{% endautoescape %}&cc=partners@scipost.org">email template</a>. Experience shows that such personal testimonies and statements of support from active scientists constitute the most persuasive means to convince institutions to support us. - <br/>Funders which have been acknowledged in SciPost publications are listed at <a href="https://scipost.org/funders/" target="_blank">this link</a>; clicking on a funder will show how many publications they are related to, which have been produced at no direct cost to them by our cost-slashing operations. Seeing this might also help convincing them to become Partners.</li> + <li><i>Is your institution or funding agency not listed on our <a href="{% url 'sponsors:sponsors' %}" target="_blank">Sponsors page</a>?</i><br/>Encourage them (through a librarian, Open Access officer, director, ...) to join by personally emailing them directly using this <a href="mailto:?subject=Petition to support SciPost&body={% autoescape on %}{% include 'petitions/petition_email.html' %}{% endautoescape %}&cc=sponsors@scipost.org">email template</a>. Experience shows that such personal testimonies and statements of support from active scientists constitute the most persuasive means to convince institutions to support us. + <br/>Funders which have been acknowledged in SciPost publications are listed at <a href="https://scipost.org/organizations/" target="_blank">this link</a>; clicking on a funder will show how many publications they are related to, which have been produced at no direct cost to them by our cost-slashing operations. Seeing this might also help convincing them to become Sponsors.</li> <br/> <li><i>Are people in your surroundings and social network not yet sufficiently aware of SciPost?</i><br/>You can point them to our <a href="https://youtu.be/Pgvd7EvehCI" target="_blank">intro video</a> and mention this drive on social media using the <a href="https://twitter.com/hashtag/SciPostDrive2018">#SciPostDrive2018</a> and <a href="https://twitter.com/hashtag/SciPost">#SciPost</a> hashtags. </li> diff --git a/scipost/templates/scipost/PlanSciPost.html b/scipost/templates/scipost/PlanSciPost.html index a0c470e5f6d1bb9a589ebbbd23be07bfb27f3363..3b9704f6e5019bd11e049e605a03c912a315ceca 100644 --- a/scipost/templates/scipost/PlanSciPost.html +++ b/scipost/templates/scipost/PlanSciPost.html @@ -15,7 +15,7 @@ <div class="col-12"> <h1 class="highlight">Plan Scipost</h1> <p class="ml-2 mr-2">At SciPost, we believe that cleaning up the business of scientific publishing requires building new infrastructure. We also believe that this business is most appropriately left in the hands of scientists themselves. Our achievements so far in the field of Physics have demonstrated the success and further potential of this approach.</p> - <p class="ml-2 mr-2">Increasing numbers of governments, funding agencies, universities and other academic instances have taken position in favour of a faster, larger-scale transition to Open Access. SciPost applauds these encouraging and empowering statements.</p> + <p class="ml-2 mr-2">Increasing numbers of governments, funding agencies, universities and other academic instances have taken position in favour of a faster, larger-scale transition to Open Access (see in particular the recently-announced <a href="https://www.scienceeurope.org/making-open-access-a-reality-by-2020/">Plan S</a>). SciPost applauds these encouraging and empowering statements.</p> <p class="ml-2 mr-2">Our initiative volunteers to provide the missing element in the proposed solutions: <strong>the infrastructure</strong>.</p> <h2 class="highlight">Our vision for the future</h2> diff --git a/scipost/templates/scipost/_public_info_as_table.html b/scipost/templates/scipost/_public_info_as_table.html index 1f0f93e93ff44d17a377c2632e39254646ebc0d8..a3b73a41a3193ec35862d22f53d11d96c0e412dc 100644 --- a/scipost/templates/scipost/_public_info_as_table.html +++ b/scipost/templates/scipost/_public_info_as_table.html @@ -15,4 +15,13 @@ </td> </tr> <tr><td>Personal web page: </td><td>{{ contributor.personalwebpage|default:'-' }}</td></tr> + + {% if perms.scipost.can_vet_registration_requests %} + <tr class="text-muted"><td>Username</td><td>{{ contributor.user.username }}</td></tr> + <tr class="text-muted"><td>Email (from User)</td><td>{{ contributor.user.email }}</td></tr> + <tr class="text-muted"><td>Date joined / last login</td><td>{{ contributor.user.date_joined }} / {{ contributor.user.last_login }}</td></tr> + <tr class="text-muted"><td>Status</td><td>{{ contributor.get_status_display }}</td></tr> + <tr class="text-muted"><td>User active?</td><td>{{ contributor.user.is_active }}</td></tr> + <tr class="text-muted"><td>Id</td><td>{{ contributor.id }}{% if contributor.profile %} <a href="{% url 'profiles:profile_detail' pk=contributor.profile.id %}">View Profile <i class="fa fa-arrow-right"></i></a>{% endif %}</td></tr> + {% endif %} </table> diff --git a/scipost/templates/scipost/contributor_duplicate_list.html b/scipost/templates/scipost/contributor_duplicate_list.html new file mode 100644 index 0000000000000000000000000000000000000000..ae58665e3dc65fff6c90b73f60603e10bf23b2e3 --- /dev/null +++ b/scipost/templates/scipost/contributor_duplicate_list.html @@ -0,0 +1,44 @@ +{% extends 'profiles/base.html' %} + +{% load bootstrap %} + +{% block breadcrumb_items %} + {{ block.super }} +<span class="breadcrumb-item">Contributor duplicates</span> +{% endblock %} + +{% load scipost_extras %} + +{% block pagetitle %}: Contributor duplicates{% endblock pagetitle %} + +{% block content %} +<div class="row"> + <div class="col-12"> + <h1 class="highlight">Potentially duplicate Contributors</h1> + {% if merge_form %} + <form action="{% url 'scipost:contributor_merge' %}" method="get"> + {{ merge_form|bootstrap }} + <input class="btn btn-outline-secondary" type="submit" value="Check"> + </form> + {% endif %} + + <br> + <h3>All found duplicates</h3> + <ul> + {% for contrib_dup in object_list %} + <li>{{ contrib_dup }} (<em>id={{ contrib_dup.id }}</em>)</li> + {% empty %} + <li<em>No duplicates found</em></li> + {% endfor %} + </ul> + + {% if is_paginated %} + <div class="col-12"> + {% include 'partials/pagination.html' with page_obj=page_obj %} + </div> + {% endif %} + + </div> +</div> + +{% endblock content %} diff --git a/scipost/templates/scipost/contributor_info.html b/scipost/templates/scipost/contributor_info.html index d65751ed7db2636235ff7754b3725448eed99fb8..0969da038368d52d0d76039dba9bbc15143e6018 100644 --- a/scipost/templates/scipost/contributor_info.html +++ b/scipost/templates/scipost/contributor_info.html @@ -6,9 +6,16 @@ <h1 class="highlight mb-4">Contributor info: {{ contributor.get_title_display }} {{ contributor.user.first_name }} {{ contributor.user.last_name }}</h1> -{% include "scipost/_public_info_as_table.html" with contributor=contributor %} -<br> +<div class="card"> + <div class="card-header"> + Details + </div> + <div class="card-body"> + {% include "scipost/_public_info_as_table.html" with contributor=contributor %} + </div> +</div> + {% if contributor_publications %} <div class="row"> diff --git a/scipost/templates/scipost/contributor_merge.html b/scipost/templates/scipost/contributor_merge.html new file mode 100644 index 0000000000000000000000000000000000000000..8589170711c51d3a0eeb77853ac0668b7d551a5f --- /dev/null +++ b/scipost/templates/scipost/contributor_merge.html @@ -0,0 +1,50 @@ +{% extends 'scipost/base.html' %} + +{% load bootstrap %} + +{% block breadcrumb_items %} + {{ block.super }} +<span class="breadcrumb-item"><a href="{% url 'scipost:contributor_duplicates' %}">Duplicates</a></span> +<span class="breadcrumb-item">Merge Contributors {{ contributor_to_merge.id }} and {{ contributor_to_merge_into.id }}</span> +{% endblock %} + +{% load scipost_extras %} + +{% block pagetitle %}: Contributor duplicates: merge{% endblock pagetitle %} + +{% block content %} +<div class="row"> + <div class="col-12"> + <h1 class="highlight">Merge Contributors {{ contributor_to_merge.id }} and {{ contributor_to_merge_into.id }}</h1> + {% if contributor_to_merge.user.is_active and not contributor_to_merge_into.user.is_active %} + <h3 class="text-danger">Warning: the contributor to merge is active, while the one to merge into is not</h3> + <p>Consider <a href="{% url 'scipost:contributor_merge' %}?to_merge={{ contributor_to_merge_into.id }}&to_merge_into={{ contributor_to_merge.id }}" method="get">merging the other way around</a></p> + {% endif %} + </div> +</div> +<div class="row"> + <div class="col-12"> + <h3 class="highlight">Contributor {{ contributor_to_merge.id }}</h3> + {% include "scipost/_public_info_as_table.html" with contributor=contributor_to_merge %} + </div> +</div> +<div class="row"> + <div class="col-12"> + <h3 class="highlight">Contributor {{ contributor_to_merge_into.id }}</h3> + {% include "scipost/_public_info_as_table.html" with contributor=contributor_to_merge_into %} + </div> +</div> + +<div class="row"> + <div class="col-12"> + <h3 class="highlight">Merge:</h3> + <form method="post"> + {% csrf_token %} + {{ merge_form|bootstrap }} + <input class="btn btn-primary" type="submit" value="Confirm merge"> + <a class="text-warning" href="{% url 'scipost:contributor_merge' %}?to_merge={{ contributor_to_merge_into.id }}&to_merge_into={{ contributor_to_merge.id }}" method="get">Merge the other way around</a></p> + </form> + </div> +</div> + +{% endblock content %} diff --git a/scipost/templates/scipost/footer.html b/scipost/templates/scipost/footer.html index a657122bdf1ea9eae0e7316b67d87ad05ba55ace..a3567ffb2be657a052892a4b91083edc4efa04f7 100644 --- a/scipost/templates/scipost/footer.html +++ b/scipost/templates/scipost/footer.html @@ -11,23 +11,18 @@ <a href="{% url 'scipost:terms_and_conditions' %}">Terms and conditions</a> <table class="mt-2 social-media"> - <tr> - <td> - <a href="//www.facebook.com/scipost" target="_blank" title="Facebook"> - <i class="fa fa-facebook" aria-hidden="true"></i> - </a> + <tr> + <td> + <a href="//twitter.com/scipost_dot_org" target="_blank" title="Twitter"> + <i class="fa fa-twitter" aria-hidden="true"></i> + </a> </td> - <td> - <a href="//twitter.com/scipost_dot_org" target="_blank" title="Twitter"> - <i class="fa fa-twitter" aria-hidden="true"></i> - </a> + <td> + <a href="{% url 'scipost:feeds' %}" title="RSS feeds"> + <i class="fa fa-rss" aria-hidden="true"></i> + </a> </td> - <td> - <a href="{% url 'scipost:feeds' %}" title="RSS feeds"> - <i class="fa fa-rss" aria-hidden="true"></i> - </a> - </td> - </tr> + </tr> </table> </div> <div class="col-md-4 mb-3 mb-md-0"> diff --git a/scipost/templates/widgets/nocaptcha.html b/scipost/templates/widgets/nocaptcha.html new file mode 100644 index 0000000000000000000000000000000000000000..b9ab0f308d84f9421a03e3ce26a7adbfb3569858 --- /dev/null +++ b/scipost/templates/widgets/nocaptcha.html @@ -0,0 +1,23 @@ +<script src="https://www.recaptcha.net/recaptcha/api.js{% if lang %}?hl={{ lang }}{% endif %}"></script> +<div class="g-recaptcha" data-sitekey="{{ public_key }}" {% for option,value in widget.attrs.items %}data-{{ option }}="{{ value }}" {% endfor %}></div> +<noscript> + <div style="width: 302px; height: 352px;"> + <div style="width: 302px; height: 352px; position: relative;"> + <div style="width: 302px; height: 352px; position: absolute;"> + <iframe src="https://www.recaptcha.net/recaptcha/api/fallback?k={{ public_key }}" + frameborder="0" scrolling="no" + style="width: 302px; height:352px; border-style: none;"> + </iframe> + </div> + <div style="width: 250px; height: 80px; position: absolute; border-style: none; + bottom: 21px; left: 25px; margin: 0px; padding: 0px; right: 25px;"> + <textarea id="g-recaptcha-response" name="g-recaptcha-response" + class="recaptcha_challenge_field" + style="width: 250px; height: 80px; border: 1px solid #c1c1c1; + margin: 0px; padding: 0px; resize: none;" value=""> + </textarea> + <input type="hidden" name="recaptcha_response_field" value="manual_challenge" /> + </div> + </div> + </div> +</noscript> diff --git a/scipost/urls.py b/scipost/urls.py index 57645d31d6a32ef5c5603f30b750cccef320f986..bf8b31a84836303c92a879faac47137e18af328e 100644 --- a/scipost/urls.py +++ b/scipost/urls.py @@ -158,6 +158,13 @@ urlpatterns = [ url(r'^vet_authorship_claim/(?P<claim_id>[0-9]+)/(?P<claim>[0-1])$', views.vet_authorship_claim, name='vet_authorship_claim'), + # Potential duplicates + url(r'contributor_duplicates/$', + views.ContributorDuplicateListView.as_view(), + name='contributor_duplicates'), + url(r'contributor_merge/$', + views.contributor_merge, + name='contributor_merge'), #################### # Email facilities # diff --git a/scipost/views.py b/scipost/views.py index 4123e60638e87ddc2c41e09443aa31c56fca7236..9159ae59caf960e4acff15f65e27c1b910cda34a 100644 --- a/scipost/views.py +++ b/scipost/views.py @@ -17,6 +17,7 @@ from django.core.exceptions import PermissionDenied from django.core.mail import EmailMessage, EmailMultiAlternatives from django.core.paginator import Paginator from django.core.urlresolvers import reverse, reverse_lazy +from django.db import transaction from django.http import Http404 from django.shortcuts import redirect from django.template import Context, Template @@ -38,7 +39,9 @@ from .models import Contributor, UnavailabilityPeriod, AuthorshipClaim, Editoria from .forms import ( AuthenticationForm, UnavailabilityPeriodForm, RegistrationForm, AuthorshipClaimForm, SearchForm, VetRegistrationForm, reg_ref_dict, UpdatePersonalDataForm, UpdateUserDataForm, - PasswordChangeForm, EmailGroupMembersForm, EmailParticularForm, SendPrecookedEmailForm) + PasswordChangeForm, ContributorMergeForm, + EmailGroupMembersForm, EmailParticularForm, SendPrecookedEmailForm) +from .mixins import PermissionsMixin, PaginationMixin from .utils import Utils, EMAIL_FOOTER, SCIPOST_SUMMARY_FOOTER, SCIPOST_SUMMARY_FOOTER_HTML from affiliations.forms import AffiliationsFormset @@ -154,6 +157,7 @@ def feeds(request): # Contributors: ################ +@transaction.atomic def register(request): """ Contributor registration form page. @@ -965,6 +969,86 @@ def contributor_info(request, contributor_id): return render(request, 'scipost/contributor_info.html', context) +class ContributorDuplicateListView(PermissionsMixin, PaginationMixin, ListView): + """ + List Contributors with potential (not yet handled) duplicates. + Two sources of duplicates are separately considered: + - duplicate full names (last name + first name) + - duplicate email addresses. + + """ + permission_required = 'scipost.can_vet_registration_requests' + model = Contributor + template_name = 'scipost/contributor_duplicate_list.html' + + def get_queryset(self): + queryset = Contributor.objects.all() + if self.request.GET.get('kind') == 'names': + queryset = queryset.with_duplicate_names() + elif self.request.GET.get('kind') == 'emails': + queryset = queryset.with_duplicate_emails() + else: + queryset = queryset.with_duplicate_names() + return queryset + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + + if len(context['object_list']) > 1: + initial = { + 'to_merge': context['object_list'][0].id, + 'to_merge_into': context['object_list'][1].id + } + context['merge_form'] = ContributorMergeForm(initial=initial) + return context + + +@transaction.atomic +@permission_required('scipost.can_vet_registration_requests') +def contributor_merge(request): + """ + Handles the merging of data from one Contributor instance to another, + to solve one person - multiple registrations issues. + + Both instances are preserved, but the merge_from instance's + status is set to DOUBLE_ACCOUNT and its User is set to inactive. + + If both Contributor instances were active, then the account owner + is emailed with information about the merge. + """ + merge_form = ContributorMergeForm(request.POST or None, initial=request.GET) + context = {'merge_form': merge_form} + + if request.method == 'POST': + if merge_form.is_valid(): + contributor = merge_form.save() + messages.success(request, 'Contributors merged') + return redirect(reverse('scipost:contributor_duplicates')) + else: + try: + context.update({ + 'contributor_to_merge': get_object_or_404( + Contributor, pk=merge_form.cleaned_data['to_merge'].id), + 'contributor_to_merge_into': get_object_or_404( + Contributor, pk=merge_form.cleaned_data['to_merge_into'].id) + }) + except ValueError: + raise Http404 + + elif request.method == 'GET': + try: + context.update({ + 'contributor_to_merge': get_object_or_404(Contributor, + pk=int(request.GET['to_merge'])), + 'contributor_to_merge_into': get_object_or_404(Contributor, + pk=int(request.GET['to_merge_into'])), + }) + except ValueError: + raise Http404 + + return render(request, 'scipost/contributor_merge.html', context) + + #################### # Email facilities # #################### diff --git a/scipost/widgets.py b/scipost/widgets.py index ee2eadc4fceefe78194bcb77fb28de22f730662d..45cc00630bc22ebc04ca4a7533e69a9c8d054be3 100644 --- a/scipost/widgets.py +++ b/scipost/widgets.py @@ -1,9 +1,49 @@ __copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" +import json -from django.forms.widgets import CheckboxSelectMultiple +from django.forms.widgets import CheckboxSelectMultiple, Widget +from django.utils.safestring import mark_safe class SelectButtonWidget(CheckboxSelectMultiple): template_name = 'widgets/checkbox_as_btn.html' + + +class ReCaptcha(Widget): + recaptcha_response_name = 'g-recaptcha-response' + recaptcha_challenge_name = 'g-recaptcha-response' + template_name = 'widgets/nocaptcha.html' + + def __init__(self, public_key, *args, **kwargs): + super().__init__(*args, **kwargs) + self.public_key = public_key + + def value_from_datadict(self, data, files, name): + return [ + data.get(self.recaptcha_challenge_name, None), + data.get(self.recaptcha_response_name, None) + ] + + def get_context(self, name, value, attrs): + try: + lang = attrs['lang'] + except KeyError: + # Get the generic language code + lang = 'en' + + try: + context = super().get_context(name, value, attrs) + except AttributeError: + context = { + "widget": { + "attrs": self.build_attrs(attrs) + } + } + context.update({ + 'public_key': self.public_key, + 'lang': lang, + 'options': mark_safe(json.dumps(self.attrs, indent=2)), + }) + return context diff --git a/submissions/admin.py b/submissions/admin.py index dde74c300a5d2a86f0ca058fbc7b0602a993c470..43a8cf5f2693715798f60c5fa9be8f2c55555fa7 100644 --- a/submissions/admin.py +++ b/submissions/admin.py @@ -35,6 +35,8 @@ class SubmissionAdminForm(forms.ModelForm): authors_false_claims = forms.ModelMultipleChoiceField( required=False, queryset=Contributor.objects.order_by('user__last_name')) + is_resubmission_of = forms.ModelChoiceField( + queryset=Submission.objects.order_by('-preprint__identifier_w_vn_nr')) class Meta: model = Submission @@ -69,8 +71,10 @@ class SubmissionAdmin(GuardedModelAdmin): }), ('Versioning', { 'fields': ( + 'thread_hash', 'is_current', - 'is_resubmission', + '_is_resubmission', + 'is_resubmission_of', 'list_of_changes'), }), ('Submission details', { diff --git a/submissions/constants.py b/submissions/constants.py index 793889a9b64e663c5b2cb6109edf303e6c2ab300..3845028dc260c38a292a4555a0f4b1c64b54081d 100644 --- a/submissions/constants.py +++ b/submissions/constants.py @@ -46,6 +46,7 @@ NO_REQUIRED_ACTION_STATUSES = [ ] SUBMISSION_TYPE = ( + # ('', None), ('Letter', 'Letter (broad-interest breakthrough results)'), ('Article', 'Article (in-depth reports on specialized research)'), ('Review', 'Review (candid snapshot of current research in a given area)'), diff --git a/submissions/forms.py b/submissions/forms.py index 34c9ac176f4536769b28746883cb3670222640cf..d1409ffe6239699749a970d5d2c2e31b68663d8c 100644 --- a/submissions/forms.py +++ b/submissions/forms.py @@ -27,17 +27,14 @@ from .models import ( iThenticateReport, EditorialCommunication) from .signals import notify_manuscript_accepted -from common.helpers import get_new_secrets_key from colleges.models import Fellowship -from invitations.models import RegistrationInvitation from journals.models import Journal from journals.constants import SCIPOST_JOURNAL_PHYSICS_PROC, SCIPOST_JOURNAL_PHYSICS from mails.utils import DirectMailUtil -from preprints.helpers import generate_new_scipost_identifier, format_scipost_identifier +from preprints.helpers import generate_new_scipost_identifier from preprints.models import Preprint from production.utils import get_or_create_production_stream -from profiles.models import Profile -from scipost.constants import SCIPOST_SUBJECT_AREAS, INVITATION_REFEREEING +from scipost.constants import SCIPOST_SUBJECT_AREAS from scipost.services import ArxivCaller from scipost.models import Contributor, Remark import strings @@ -98,180 +95,239 @@ class SubmissionPoolFilterForm(forms.Form): # Submission and resubmission # ############################### -class SubmissionChecks: - """Mixin with checks run at least the Submission creation form.""" +class SubmissionService: + """ + Object to run checks for prefiller and submit manuscript forms. + """ - use_arxiv_preprint = True - arxiv_data = {} - is_resubmission = False - last_submission = None + metadata = {} - def __init__(self, *args, **kwargs): - self.requested_by = kwargs.pop('requested_by', None) - 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(preprint__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 __init__(self, requested_by, preprint_server, identifier=None, resubmission_of_id=None): + self.requested_by = requested_by + self.preprint_server = preprint_server + self.identifier = identifier + self.resubmission_of_id = resubmission_of_id + self._arxiv_data = None + + @property + def latest_submission(self): + """ + Return latest version of preprint series or None. + """ + if hasattr(self, '_latest_submission'): + return self._latest_submission + + if self.identifier: + # Check if is resubmission when identifier data is submitted. + identifier = self.identifier.rpartition('v')[0] + self._latest_submission = Submission.objects.filter( + preprint__identifier_wo_vn_nr=identifier).order_by( + '-preprint__vn_nr').first() + elif self.resubmission_of_id: + # Resubmission (submission id) is selected by user. + try: + self._latest_submission = Submission.objects.filter( + id=int(self.resubmission_of_id)).order_by('-preprint__vn_nr').first() + except ValueError: + self._latest_submission = None + else: + self._latest_submission = None + return self._latest_submission + + @property + def arxiv_data(self): + if self._arxiv_data is None: + self._call_arxiv() + return self._arxiv_data + + def run_checks(self): + """ + Do several pre-checks (using the arXiv API if needed). + + This is needed for both the prefill and submission forms. + """ + self._submission_already_exists() + self._submission_previous_version_is_valid_for_submission() + + if self.preprint_server == 'arxiv': + self._submission_is_already_published() + + def _call_arxiv(self): + """ + Retrieve all data from the ArXiv database for `identifier`. + """ + if self.preprint_server != 'arxiv': + # Do the call here to prevent multiple calls to the arXiv API in one request. + self._arxiv_data = {} + return + if not self.identifier: + print('crap', self.identifier) + return + + caller = ArxivCaller(self.identifier) - def _call_arxiv(self, identifier): - caller = ArxivCaller(identifier) if caller.is_valid: - self.arxiv_data = caller.data + self._arxiv_data = caller.data self.metadata = caller.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'] + def get_latest_submission_data(self): + """ + Return initial form data originating from earlier Submission. + """ + if self.is_resubmission(): + return { + 'title': self.latest_submission.title, + 'abstract': self.latest_submission.abstract, + 'author_list': self.latest_submission.author_list, + 'discipline': self.latest_submission.discipline, + 'domain': self.latest_submission.domain, + 'referees_flagged': self.latest_submission.referees_flagged, + 'referees_suggested': self.latest_submission.referees_suggested, + 'secondary_areas': self.latest_submission.secondary_areas, + 'subject_area': self.latest_submission.subject_area, + 'submitted_to': self.latest_submission.submitted_to, + 'submission_type': self.latest_submission.submission_type, + } + return {} - 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 is_resubmission(self): + """ + Check if Submission is a SciPost or arXiv resubmission. + """ + return self.latest_submission is not None - 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(preprint__identifier_wo_vn_nr=identifiers['identifier_wo_vn_nr']) - .order_by('preprint__vn_nr').last()) - - # If submissions are found; check their statuses - if submission: - self.last_submission = submission - if submission.open_for_resubmission: - self.is_resubmission = True - if self.requested_by.contributor not in submission.authors.all(): - error_message = ('There exists a preprint with this arXiv identifier ' - 'but an earlier version number. Resubmission is only possible' - ' if you are a registered author of this manuscript.') - raise forms.ValidationError(error_message) - elif submission.status == STATUS_REJECTED: - 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 identifier_matches_regex(self, journal_code): + """ + Check if identifier is valid for the Journal submitting to. + """ + if self.preprint_server != 'arxiv': + # Only check arXiv identifiers + return - def identifier_matches_regex(self, identifier, journal_code): - """Check if arXiv identifier is valid for the Journal submitting to.""" if journal_code in EXPLICIT_REGEX_MANUSCRIPT_CONSTRAINTS.keys(): regex = EXPLICIT_REGEX_MANUSCRIPT_CONSTRAINTS[journal_code] else: regex = EXPLICIT_REGEX_MANUSCRIPT_CONSTRAINTS['default'] pattern = re.compile(regex) - if not pattern.match(identifier): + if not pattern.match(self.identifier): # No match object returned, identifier is invalid error_message = ('The journal you want to submit to does not allow for this' - ' arXiv identifier. Please contact SciPost if you have' + ' identifier. Please contact SciPost if you have' ' any further questions.') raise forms.ValidationError(error_message, code='submitted_to') - def submission_is_resubmission(self): - """Check if the Submission is a resubmission.""" - return self.is_resubmission + def process_resubmission_procedure(self, submission): + """ + Update all fields for new and old Submission and EditorialAssignments to comply with + the resubmission procedures. - def identifier_into_parts(self, identifier): - """Split the preprint identifier into parts.""" - data = { - 'identifier_w_vn_nr': identifier, - 'identifier_wo_vn_nr': identifier.rpartition('v')[0], - 'vn_nr': int(identifier.rpartition('v')[2]) - } - return data + -- submission: the new version of the Submission series. + """ + if not self.latest_submission: + raise Submission.DoesNotExist - def do_pre_checks(self, identifier): - """Group call of different checks.""" - self._submission_already_exists(identifier) - if self.use_arxiv_preprint: - self._call_arxiv(identifier) - self._submission_is_already_published(identifier) - self._submission_previous_version_is_valid_for_submission(identifier) + # Close last submission + Submission.objects.filter(id=self.latest_submission.id).update( + is_current=False, open_for_reporting=False, status=STATUS_RESUBMITTED) + # Copy Topics + submission.topics.add(*self.latest_submission.topics.all()) -class SubmissionIdentifierForm(SubmissionChecks, forms.Form): - """Prefill SubmissionForm using this form that takes an arXiv ID only.""" + # Open for comment and reporting and copy EIC info + Submission.objects.filter(id=submission.id).update( + open_for_reporting=True, + open_for_commenting=True, + is_resubmission_of=self.latest_submission, + visible_pool=True, + refereeing_cycle=CYCLE_UNDETERMINED, + editor_in_charge=self.latest_submission.editor_in_charge, + status=STATUS_EIC_ASSIGNED, + thread_hash=self.latest_submission.thread_hash) - IDENTIFIER_PLACEHOLDER = 'new style (with version nr) ####.####(#)v#(#)' + # Add author(s) (claim) fields + submission.authors.add(*self.latest_submission.authors.all()) + submission.authors_claims.add(*self.latest_submission.authors_claims.all()) + submission.authors_false_claims.add(*self.latest_submission.authors_false_claims.all()) - identifier_w_vn_nr = forms.RegexField( - regex=IDENTIFIER_PATTERN_NEW, strip=True, - error_messages={'invalid': strings.arxiv_query_invalid}, - widget=forms.TextInput({'placeholder': IDENTIFIER_PLACEHOLDER})) + # Create new EditorialAssigment for the current Editor-in-Charge + EditorialAssignment.objects.create( + submission=submission, + to=self.latest_submission.editor_in_charge, + status=STATUS_ACCEPTED) - def clean_identifier_w_vn_nr(self): - """Do basic prechecks based on the arXiv ID only.""" - identifier = self.cleaned_data['identifier_w_vn_nr'] - self.do_pre_checks(identifier) - return identifier + def _submission_already_exists(self): + """ + Check if preprint has already been submitted before. + """ + if Submission.objects.filter(preprint__identifier_w_vn_nr=self.identifier).exists(): + error_message = 'This preprint version has already been submitted to SciPost.' + raise forms.ValidationError(error_message, code='duplicate') - 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': self.last_submission.submitted_to, - 'submission_type': self.last_submission.submission_type, - } - return data or {} + def _submission_previous_version_is_valid_for_submission(self): + """ + Check if previous submitted versions have the appropriate status. + """ - def request_arxiv_preprint_form_prefill_data(self): - """Return dictionary to prefill `RequestSubmissionForm`.""" - form_data = self.arxiv_data - form_data['identifier_w_vn_nr'] = self.cleaned_data['identifier_w_vn_nr'] - if self.submission_is_resubmission(): - form_data.update(self._gather_data_from_last_submission()) - return form_data + if self.latest_submission: + if self.latest_submission.status == STATUS_REJECTED: + # Explicitly give rejected status warning. + error_message = ('This preprint has previously undergone refereeing ' + 'and has been rejected. Resubmission is only possible ' + 'if the manuscript has been substantially reworked into ' + 'a new submission with distinct identifier.') + raise forms.ValidationError(error_message) + elif self.latest_submission.open_for_resubmission: + # Check if verified author list contains current user. + if self.requested_by.contributor not in self.latest_submission.authors.all(): + error_message = ('There exists a preprint with this identifier ' + 'but an earlier version number. Resubmission is only possible' + ' if you are a registered author of this manuscript.') + raise forms.ValidationError(error_message) + else: + # Submission has not an appropriate status for resubmission. + error_message = ('There exists a preprint with this 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_already_published(self): + """ + Check if preprint number is already registered with a DOI in the *ArXiv* database. + """ + 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}) -class RequestSubmissionForm(SubmissionChecks, forms.ModelForm): - """Form to submit a new Submission.""" - scipost_identifier = None +class SubmissionForm(forms.ModelForm): + """ + Form to submit a new (re)Submission. + """ identifier_w_vn_nr = forms.CharField(widget=forms.HiddenInput()) - arxiv_link = forms.URLField( - widget=forms.TextInput(attrs={'placeholder': 'ex.: arxiv.org/abs/1234.56789v1'})) preprint_file = forms.FileField() class Meta: model = Submission fields = [ - 'is_resubmission', + 'is_resubmission_of', 'discipline', 'submitted_to', 'proceedings', @@ -286,43 +342,72 @@ class RequestSubmissionForm(SubmissionChecks, forms.ModelForm): 'list_of_changes', 'remarks_for_editors', 'referees_suggested', - 'referees_flagged' + 'referees_flagged', + 'arxiv_link', ] widgets = { - 'is_resubmission': forms.HiddenInput(), + 'is_resubmission_of': forms.HiddenInput(), 'secondary_areas': forms.SelectMultiple(choices=SCIPOST_SUBJECT_AREAS), - 'remarks_for_editors': forms.TextInput( - attrs={'placeholder': 'Any private remarks (for the editors only)', 'rows': 3}), - 'referees_suggested': forms.TextInput( - attrs={'placeholder': 'Optional: names of suggested referees', 'rows': 3}), - 'referees_flagged': forms.TextInput( - attrs={'placeholder': 'Optional: names of referees whose reports should be treated with caution (+ short reason)', 'rows': 3}), + 'arxiv_link': forms.TextInput( + attrs={'placeholder': 'ex.: arxiv.org/abs/1234.56789v1'}), + 'remarks_for_editors': forms.Textarea( + attrs={'placeholder': 'Any private remarks (for the editors only)', 'rows': 5}), + 'referees_suggested': forms.Textarea( + attrs={'placeholder': 'Optional: names of suggested referees', 'rows': 5}), + 'referees_flagged': forms.Textarea( + attrs={ + 'placeholder': 'Optional: names of referees whose reports should be treated with caution (+ short reason)', + 'rows': 5 + }), + 'author_comments': forms.Textarea( + attrs={'placeholder': 'Your resubmission letter (will be viewable online)'}), + 'list_of_changes': forms.Textarea( + attrs={'placeholder': 'Give a point-by-point list of changes (will be viewable online)'}), } def __init__(self, *args, **kwargs): - self.use_arxiv_preprint = kwargs.pop('use_arxiv_preprint', True) + self.requested_by = kwargs.pop('requested_by') + self.preprint_server = kwargs.pop('preprint_server', 'arxiv') + self.resubmission_preprint = kwargs['initial'].get('resubmission', False) + + data = args[0] if len(args) > 1 else kwargs.get('data', {}) + identifier = kwargs['initial'].get('identifier_w_vn_nr', None) or data.get('identifier_w_vn_nr') + + self.service = SubmissionService( + self.requested_by, self.preprint_server, + identifier=identifier, + resubmission_of_id=self.resubmission_preprint) + if self.preprint_server == 'scipost': + kwargs['initial'] = self.service.get_latest_submission_data() super().__init__(*args, **kwargs) - # Alter resubmission-dependent fields - if not self.submission_is_resubmission(): - # These fields are only available for resubmissions + if not self.preprint_server == 'arxiv': + # No arXiv-specific data required. + del self.fields['identifier_w_vn_nr'] + del self.fields['arxiv_link'] + elif not self.preprint_server == 'scipost': + # No need for a file upload if user is not using the SciPost preprint server. + del self.fields['preprint_file'] + + # Find all submission allowed to be resubmitted by current user. + self.fields['is_resubmission_of'].queryset = Submission.objects.candidate_for_resubmission( + self.requested_by) + + # Fill resubmission-dependent fields + if self.is_resubmission(): + self.fields['is_resubmission_of'].initial = self.service.latest_submission + else: + # 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)'}) - # ArXiv or SciPost preprint fields - if self.use_arxiv_preprint: - del self.fields['preprint_file'] - else: - del self.fields['arxiv_link'] - del self.fields['identifier_w_vn_nr'] + if not self.fields['is_resubmission_of'].initial: + # No intial nor submitted data found. + del self.fields['is_resubmission_of'] - self.fields['submitted_to'].queryset = Journal.objects.filter(active=True) + # Select Journal instances. + self.fields['submitted_to'].queryset = Journal.objects.active() self.fields['submitted_to'].label = 'Journal: submit to' # Proceedings submission fields @@ -331,24 +416,31 @@ class RequestSubmissionForm(SubmissionChecks, forms.ModelForm): self.fields['proceedings'].empty_label = None if not qs.exists(): # No proceedings issue to submit to, so adapt the form fields - self.fields['submitted_to'].queryset = self.fields['submitted_to'].exclude( + self.fields['submitted_to'].queryset = self.fields['submitted_to'].queryset.exclude( doi_label=SCIPOST_JOURNAL_PHYSICS_PROC) del self.fields['proceedings'] - # Submission type is optional - self.fields['submission_type'].required = False + def is_resubmission(self): + return self.service.is_resubmission() def clean(self, *args, **kwargs): - """Do all prechecks which are also done in the prefiller.""" + """ + Do all general checks for Submission. + """ cleaned_data = super().clean(*args, **kwargs) + + # SciPost preprints are auto-generated here. + self.scipost_identifier = None if 'identifier_w_vn_nr' not in cleaned_data: - # New series of SciPost preprints - identifier_str, self.scipost_identifier = generate_new_scipost_identifier() - cleaned_data['identifier_w_vn_nr'] = format_scipost_identifier(identifier_str) + self.service.identifier, self.scipost_identifier = generate_new_scipost_identifier( + cleaned_data.get('is_resubmission_of', None)) + # Also copy to the form data + self.cleaned_data['identifier_w_vn_nr'] = self.service.identifier - self.do_pre_checks(cleaned_data['identifier_w_vn_nr']) - self.identifier_matches_regex( - cleaned_data['identifier_w_vn_nr'], cleaned_data['submitted_to'].doi_label) + # Run checks again to clean any possible human intervention and run checks again + # with possibly newly generated identifier. + self.service.run_checks() + self.service.identifier_matches_regex(cleaned_data['submitted_to'].doi_label) if self.cleaned_data['submitted_to'].doi_label != SCIPOST_JOURNAL_PHYSICS_PROC: try: @@ -356,31 +448,22 @@ class RequestSubmissionForm(SubmissionChecks, forms.ModelForm): except KeyError: # No proceedings returned to data return cleaned_data - return cleaned_data def clean_author_list(self): - """Check if author list matches the Contributor submitting. - - 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. + """ + Check if author list matches the Contributor submitting. """ author_list = self.cleaned_data['author_list'] - if not self.use_arxiv_preprint: - # Using SciPost preprints, there is nothing to check with. - return 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') + self.add_error('author_list', error_message) return author_list def clean_submission_type(self): - """Validate Submission type. - - The SciPost Physics journal requires a Submission type to be specified. + """ + Validate Submission type for the SciPost Physics journal. """ submission_type = self.cleaned_data['submission_type'] journal_doi_label = self.cleaned_data['submitted_to'].doi_label @@ -388,39 +471,10 @@ class RequestSubmissionForm(SubmissionChecks, forms.ModelForm): self.add_error('submission_type', 'Please specify the submission type.') return submission_type - @transaction.atomic - def copy_and_save_data_from_resubmission(self, submission): - """Fill given Submission with data coming from last_submission.""" - if not self.last_submission: - raise Submission.DoesNotExist - - # Close last submission - Submission.objects.filter(id=self.last_submission.id).update( - is_current=False, open_for_reporting=False, status=STATUS_RESUBMITTED) - - # Copy Topics - submission.topics.add(*self.last_submission.topics.all()) - - # Open for comment and reporting and copy EIC info - Submission.objects.filter(id=submission.id).update( - open_for_reporting=True, - open_for_commenting=True, - is_resubmission=True, - visible_pool=True, - editor_in_charge=self.last_submission.editor_in_charge, - status=STATUS_EIC_ASSIGNED) - - # Add author(s) (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()) - - # Create new EditorialAssigment for the current Editor-in-Charge - EditorialAssignment.objects.create( - submission=submission, to=self.last_submission.editor_in_charge, status=STATUS_ACCEPTED) - def set_pool(self, submission): - """Set the default set of (guest) Fellows for this Submission.""" + """ + Set the default set of (guest) Fellows for this Submission. + """ qs = Fellowship.objects.active() fellows = qs.regular().filter( contributor__discipline=submission.discipline).return_active_for_submission(submission) @@ -434,46 +488,85 @@ class RequestSubmissionForm(SubmissionChecks, forms.ModelForm): @transaction.atomic def save(self): - """Fill, create and transfer data to the new Submission. - - 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. + """ + Create the new Submission and Preprint instances. """ submission = super().save(commit=False) submission.submitted_by = self.requested_by.contributor # Save identifiers - identifiers = self.identifier_into_parts(self.cleaned_data['identifier_w_vn_nr']) + identifiers = self.cleaned_data['identifier_w_vn_nr'].rpartition('v') preprint, __ = Preprint.objects.get_or_create( - identifier_w_vn_nr=identifiers['identifier_w_vn_nr'], - identifier_wo_vn_nr=identifiers['identifier_wo_vn_nr'], - vn_nr=identifiers['vn_nr'], + identifier_w_vn_nr=self.cleaned_data['identifier_w_vn_nr'], + identifier_wo_vn_nr=identifiers[0], + vn_nr=identifiers[2], url=self.cleaned_data.get('arxiv_link', ''), scipost_preprint_identifier=self.scipost_identifier, _file=self.cleaned_data.get('preprint_file', None), ) # Save metadata directly from ArXiv call without possible user interception - submission.metadata = self.metadata if hasattr(self, 'metadata') else {} + submission.metadata = self.service.metadata submission.preprint = preprint - if self.submission_is_resubmission(): - # Reset Refereeing Cycle. EIC needs to pick a cycle on resubmission. - submission.refereeing_cycle = CYCLE_UNDETERMINED - submission.save() # Save before filling from old Submission. - - self.copy_and_save_data_from_resubmission(submission) - else: - # Save! - submission.save() + submission.save() + if self.is_resubmission(): + self.service.process_resubmission_procedure(submission) # Gather first known author and Fellows. submission.authors.add(self.requested_by.contributor) self.set_pool(submission) # Return latest version of the Submission. It could be outdated by now. - return Submission.objects.get(id=submission.id) + submission.refresh_from_db() + return submission + + +class SubmissionIdentifierForm(forms.Form): + """ + Prefill SubmissionForm using this form that takes an arXiv ID only. + """ + + IDENTIFIER_PLACEHOLDER = 'new style (with version nr) ####.####(#)v#(#)' + + identifier_w_vn_nr = forms.RegexField( + label='arXiv identifier with version number', + regex=IDENTIFIER_PATTERN_NEW, strip=True, + error_messages={'invalid': strings.arxiv_query_invalid}, + widget=forms.TextInput({'placeholder': IDENTIFIER_PLACEHOLDER})) + + def __init__(self, *args, **kwargs): + self.requested_by = kwargs.pop('requested_by') + return super().__init__(*args, **kwargs) + + + def clean_identifier_w_vn_nr(self): + """ + Do basic prechecks based on the arXiv ID only. + """ + identifier = self.cleaned_data.get('identifier_w_vn_nr', None) + + self.service = SubmissionService(self.requested_by, 'arxiv', identifier=identifier) + self.service.run_checks() + return identifier + + def get_initial_submission_data(self): + """ + Return dictionary to prefill `SubmissionForm`. + """ + form_data = self.service.arxiv_data + form_data['identifier_w_vn_nr'] = self.cleaned_data['identifier_w_vn_nr'] + if self.service.is_resubmission(): + form_data.update({ + 'discipline': self.service.latest_submission.discipline, + 'domain': self.service.latest_submission.domain, + 'referees_flagged': self.service.latest_submission.referees_flagged, + 'referees_suggested': self.service.latest_submission.referees_suggested, + 'secondary_areas': self.service.latest_submission.secondary_areas, + 'subject_area': self.service.latest_submission.subject_area, + 'submitted_to': self.service.latest_submission.submitted_to, + 'submission_type': self.service.latest_submission.submission_type, + }) + return form_data class SubmissionReportsForm(forms.ModelForm): @@ -933,7 +1026,7 @@ class ReportForm(forms.ModelForm): required_fields_label = ['report', 'recommendation', 'qualification'] # If the Report is not a followup: Explicitly assign more fields as being required! - if not self.instance.is_followup_report: + if not self.instance.is_followup_report and self.submission.submitted_to.name != SCIPOST_JOURNAL_PHYSICS_PROC: required_fields_label += [ 'strengths', 'weaknesses', diff --git a/submissions/managers.py b/submissions/managers.py index 85cbf6d61c555fa38145bf793308359cb29be768..7c5a7cbf0f8ccec92840c4122bd2fb315fbb1976 100644 --- a/submissions/managers.py +++ b/submissions/managers.py @@ -17,6 +17,8 @@ now = timezone.now() class SubmissionQuerySet(models.QuerySet): def _newest_version_only(self, queryset): """ + TODO: Make more efficient... with agregation or whatever. + The current Queryset should return only the latest version of the Arxiv submissions known to SciPost. @@ -157,7 +159,7 @@ class SubmissionQuerySet(models.QuerySet): (including subsequent resubmissions, even if those came in later). """ identifiers = [] - for sub in self.filter(is_resubmission=False, + for sub in self.filter(is_resubmission_of__isnull=True, submission_date__range=(from_date, until_date)): identifiers.append(sub.preprint.identifier_wo_vn_nr) return self.filter(preprint__identifier_wo_vn_nr__in=identifiers) @@ -209,6 +211,19 @@ class SubmissionQuerySet(models.QuerySet): """Return Submissions that have EditorialAssignments that still need to be sent.""" return self.filter(editorial_assignments__status=constants.STATUS_PREASSIGNED) + def candidate_for_resubmission(self, user): + """ + Return all Submissions that are open for resubmission specialised for a certain User. + """ + if not hasattr(user, 'contributor'): + return self.none() + + return self.filter(is_current=True, status__in=[ + constants.STATUS_INCOMING, + constants.STATUS_UNASSIGNED, + constants.STATUS_EIC_ASSIGNED, + ], submitted_by=user.contributor) + class SubmissionEventQuerySet(models.QuerySet): def for_author(self): diff --git a/submissions/migrations/0044_auto_20181115_1009.py b/submissions/migrations/0044_auto_20181115_1009.py new file mode 100644 index 0000000000000000000000000000000000000000..7139126d8fb0582482a918c15959c8d1ddf28bc8 --- /dev/null +++ b/submissions/migrations/0044_auto_20181115_1009.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-11-15 09:09 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0043_remove_submission_submitted_to_journal'), + ] + + operations = [ + migrations.AlterField( + model_name='submission', + name='submitted_to', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='journals.Journal'), + ), + ] diff --git a/submissions/migrations/0045_submission_is_resubmission_of.py b/submissions/migrations/0045_submission_is_resubmission_of.py new file mode 100644 index 0000000000000000000000000000000000000000..a0ea4d2ebea14f5bcd7a740717c053fc4624bb36 --- /dev/null +++ b/submissions/migrations/0045_submission_is_resubmission_of.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-11-30 09:13 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0044_auto_20181115_1009'), + ] + + operations = [ + migrations.AddField( + model_name='submission', + name='is_resubmission_of', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='successor', to='submissions.Submission'), + ), + ] diff --git a/submissions/migrations/0046_auto_20181130_1013.py b/submissions/migrations/0046_auto_20181130_1013.py new file mode 100644 index 0000000000000000000000000000000000000000..f865766022750d3a8e850b6da93a1b2882e5e124 --- /dev/null +++ b/submissions/migrations/0046_auto_20181130_1013.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-11-30 09:13 +from __future__ import unicode_literals + +from django.db import migrations + + +def populate_explicit_resubmission_links(apps, schema_editor): + Submission = apps.get_model('submissions', 'Submission') + + for resubmission in Submission.objects.filter(preprint__vn_nr__gt=1): + resub_of = Submission.objects.filter( + preprint__identifier_wo_vn_nr=resubmission.preprint.identifier_wo_vn_nr, + preprint__vn_nr__lt=resubmission.preprint.vn_nr).order_by( + '-preprint__vn_nr').exclude(id=resubmission.id).first() + Submission.objects.filter(id=resubmission.id).update(is_resubmission_of=resub_of) + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0045_submission_is_resubmission_of'), + ] + + operations = [ + migrations.RunPython(populate_explicit_resubmission_links, reverse_code=migrations.RunPython.noop), + ] diff --git a/submissions/migrations/0047_auto_20181204_2011.py b/submissions/migrations/0047_auto_20181204_2011.py new file mode 100644 index 0000000000000000000000000000000000000000..15fe0d2cb0de419eb07beb1d6f8954efdf8a3bfc --- /dev/null +++ b/submissions/migrations/0047_auto_20181204_2011.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-12-04 19:11 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0046_auto_20181130_1013'), + ] + + operations = [ + migrations.RenameField( + model_name='submission', + old_name='is_resubmission', + new_name='_is_resubmission', + ), + migrations.AlterField( + model_name='submission', + name='submission_type', + field=models.CharField(blank=True, choices=[('Letter', 'Letter (broad-interest breakthrough results)'), ('Article', 'Article (in-depth reports on specialized research)'), ('Review', 'Review (candid snapshot of current research in a given area)')], max_length=10), + ), + ] diff --git a/submissions/migrations/0048_submission_thread_hash.py b/submissions/migrations/0048_submission_thread_hash.py new file mode 100644 index 0000000000000000000000000000000000000000..ddf50c99d378afb379eedca6278da7bf875a29b6 --- /dev/null +++ b/submissions/migrations/0048_submission_thread_hash.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-12-04 19:37 +from __future__ import unicode_literals + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0047_auto_20181204_2011'), + ] + + operations = [ + migrations.AddField( + model_name='submission', + name='thread_hash', + field=models.UUIDField(default=uuid.uuid4), + ), + ] diff --git a/submissions/migrations/0049_auto_20181204_2040.py b/submissions/migrations/0049_auto_20181204_2040.py new file mode 100644 index 0000000000000000000000000000000000000000..de5091d216afdce2fb398d7c36e78ac64ded3b89 --- /dev/null +++ b/submissions/migrations/0049_auto_20181204_2040.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.4 on 2018-12-04 19:40 +from __future__ import unicode_literals + +import uuid + +from django.db import migrations + + +def get_thread_ids(parent, submissions_list=[]): + successor = parent.successor.first() + if not successor: + return submissions_list + + submissions_list.append(successor.id) + return get_thread_ids(successor, submissions_list) + + +def populate_thread_hashes(apps, schema_editor): + Submission = apps.get_model('submissions', 'Submission') + + for original_submission in Submission.objects.filter(is_resubmission_of__isnull=True): + children_ids = get_thread_ids(original_submission, [original_submission.id]) + Submission.objects.filter(id__in=children_ids).update(thread_hash=original_submission.thread_hash) + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0048_submission_thread_hash'), + ] + + operations = [ + migrations.RunPython(populate_thread_hashes, reverse_code=migrations.RunPython.noop), + ] diff --git a/submissions/models.py b/submissions/models.py index 8d54ceed41cf4133a290839c073220ea6af258da..426281dfa2868ac8cd1312331284abb9896a9fe6 100644 --- a/submissions/models.py +++ b/submissions/models.py @@ -4,6 +4,7 @@ __license__ = "AGPL v3" import datetime import feedparser +import uuid from django.contrib.postgres.fields import JSONField from django.contrib.contenttypes.fields import GenericRelation @@ -78,7 +79,10 @@ class Submission(models.Model): 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 = models.BooleanField(default=False) + is_resubmission_of = models.ForeignKey( + 'self', blank=True, null=True, 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) @@ -87,14 +91,13 @@ class Submission(models.Model): subject_area = models.CharField(max_length=10, choices=SCIPOST_SUBJECT_AREAS, verbose_name='Primary subject area', default='Phys:QP') - submission_type = models.CharField(max_length=10, choices=SUBMISSION_TYPE) + 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, - blank=True, null=True) + submitted_to = models.ForeignKey('journals.Journal', on_delete=models.CASCADE) proceedings = models.ForeignKey('proceedings.Proceedings', null=True, blank=True, related_name='submissions') title = models.CharField(max_length=300) @@ -210,6 +213,10 @@ class Submission(models.Model): 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.""" @@ -261,7 +268,7 @@ class Submission(models.Model): def original_submission_date(self): """Return the submission_date of the first Submission in the thread.""" return Submission.objects.filter( - preprint__identifier_wo_vn_nr=self.preprint.identifier_wo_vn_nr).first().submission_date + thread_hash=self.thread_hash, is_resubmission_of__isnull=True).first().submission_date @property def in_refereeing_phase(self): @@ -306,9 +313,8 @@ class Submission(models.Model): @property def thread(self): """Return all (public) Submissions in the database in this ArXiv identifier series.""" - return Submission.objects.filter( - preprint__identifier_wo_vn_nr=self.preprint.identifier_wo_vn_nr).order_by( - '-preprint__vn_nr') + return Submission.objects.public().filter(thread_hash=self.thread_hash).order_by( + '-preprint__vn_nr', '-submission_date') @cached_property def other_versions(self): @@ -317,8 +323,7 @@ class Submission(models.Model): def get_other_versions(self): """Return queryset of other Submissions with this ArXiv identifier series.""" - return Submission.objects.filter( - preprint__identifier_wo_vn_nr=self.preprint.identifier_wo_vn_nr).exclude(pk=self.id) + 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.""" @@ -486,12 +491,13 @@ class EditorialAssignment(SubmissionRelatedObjectMixin, models.Model): # Only send if status is appropriate to prevent double sending return False - EditorialAssignment.objects.filter( - id=self.id).update(date_invited=timezone.now(), status=STATUS_INVITED) - # Send mail mail_sender = DirectMailUtil(mail_code='eic/assignment_request', instance=self) mail_sender.send() + + EditorialAssignment.objects.filter( + id=self.id).update(date_invited=timezone.now(), status=STATUS_INVITED) + return True diff --git a/submissions/templates/submissions/select_referee.html b/submissions/templates/submissions/select_referee.html index a74aff6d40f28a4d0a4ea123165c928aa8dd28d4..b647ba97475092a231d5e1f178569cf327c5fbd4 100644 --- a/submissions/templates/submissions/select_referee.html +++ b/submissions/templates/submissions/select_referee.html @@ -81,13 +81,23 @@ <tr> <th>Name</th> <th>Registered<br/>Contributor?</th> - <th>Action<br/><span class="text-muted"><small>(Unregistered people will also receive a registration invitation)</small></span></th> + <th>Email<br/>known?</th> + <th>Action<br/><span class="text-muted"><small>(Unregistered people will also automatically receive a registration invitation)</small></span></th> </tr> {% for profile in profiles_found %} <tr> <td>{{ profile }}</td> <td>{% if profile.contributor %}<i class="fa fa-check-circle text-success"></i>{% else %}<i class="fa fa-times-circle text-danger"></i>{% endif %}</td> - <td>Send refereeing invitation <a href="{% url 'submissions:invite_referee' identifier_w_vn_nr=submission.preprint.identifier_w_vn_nr profile_id=profile.id auto_reminders_allowed=1 %}">with</a> or <a href="{% url 'submissions:invite_referee' identifier_w_vn_nr=submission.preprint.identifier_w_vn_nr profile_id=profile.id auto_reminders_allowed=0 %}">without</a> auto-reminders {% include 'partials/submissions/refinv_auto_reminders_tooltip.html' %}</td> + <td>{% if profile.email %}<i class="fa fa-check-circle text-success"></i>{% else %}<i class="fa fa-times-circle text-danger"></i>{% endif %}</td> + <td>{% if profile.email %}Send refereeing invitation <a href="{% url 'submissions:invite_referee' identifier_w_vn_nr=submission.preprint.identifier_w_vn_nr profile_id=profile.id auto_reminders_allowed=1 %}">with</a> or <a href="{% url 'submissions:invite_referee' identifier_w_vn_nr=submission.preprint.identifier_w_vn_nr profile_id=profile.id auto_reminders_allowed=0 %}">without</a> auto-reminders {% include 'partials/submissions/refinv_auto_reminders_tooltip.html' %}{% else %}<span class="text-danger">Cannot send an invitation without an email</span> <i class="fa fa-arrow-right"></i> Add one: + <form class="form-inline" action="{% url 'profiles:add_profile_email' profile_id=profile.id %}" method="post"> + {% csrf_token %} + {{ profile_email_form|bootstrap }} + <input type="hidden" name="next" value="{{ request.get_full_path }}"> + <input class="btn btn-outline-secondary" type="submit" value="Add"> + </form> + {% endif %} + </td> </tr> {% empty %} <tr> diff --git a/submissions/templates/submissions/submission_prefill_form.html b/submissions/templates/submissions/submission_prefill_form.html index 4037fcc74dffc4f3cc06062fd10d0e9ecaf3d333..238eaab6d4dd5dd57e81bcde55ac24d97e5d4994 100644 --- a/submissions/templates/submissions/submission_prefill_form.html +++ b/submissions/templates/submissions/submission_prefill_form.html @@ -39,7 +39,8 @@ <div class='card-body'> <h3>Please provide the arXiv identifier for your Submission</h3> <p><em>(give the identifier without prefix but with version number, as per the placeholder)</em></p> - <form action="{% url 'submissions:submit_manuscript_arxiv' %}" method="get"> + <form action="{% url 'submissions:prefill_using_identifier' %}" method="post"> + {% csrf_token %} {{ form|bootstrap }} <input type="submit" class="btn btn-outline-secondary" value="Query arXiv"/> <br> diff --git a/submissions/templates/submissions/submission_resubmission_candidates.html b/submissions/templates/submissions/submission_resubmission_candidates.html new file mode 100644 index 0000000000000000000000000000000000000000..0a26172c5ff5943c7571244b373f03f51e7cd184 --- /dev/null +++ b/submissions/templates/submissions/submission_resubmission_candidates.html @@ -0,0 +1,39 @@ +{% extends 'scipost/base.html' %} + +{% load bootstrap %} + +{% block pagetitle %}: submit manuscript{% endblock pagetitle %} + +{% block content %} + +<div class="row"> + <div class="col-12"> + <form method="post"> + {% csrf_token %} + <h1 class="highlight">Possible Resubmissions</h1> + <p>The system has found {{ submissions|length|pluralize:'a Submission,Submissions' }} for which you are a verified author. If you wish to submit a new version for {{ submissions|length|pluralize:'this Submission,one of these Submissions' }}, please use the "Resubmit this Submission" buttton below.</p> + <ul> + {% for submission in submissions %} + <li class="py-2"> + <strong>{{ submission.title }}</strong> + <br> + {{ submission.author_list }} + <br> + Preprint number: {{ submission.preprint.identifier_w_vn_nr }} + <br> + {% if not submission.open_for_resubmission %} + <strong class="text-danger">This submission 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.</strong> + {% else %} + <button type="submit" name="submission" value="{{ submission.id }}"class="btn btn-primary py-1 mt-1">Resubmit this Submission</button> + {% endif %} + </li> + {% endfor %} + </ul> + <p> + If you wish to submit a new Submission, please <button type="submit" name="submission" value="new" class="btn btn-primary py-1 mr-1">submit a new Submission here</button>. + </p> + </form> + </div> +</div> + +{% endblock content %} diff --git a/submissions/test_views.py b/submissions/test_views.py index 6f4b9fbdf9d9cc43b6c27a32b4db70bb9efa1fad..c2bf1dba5b4cb2cc410c96699a10c3925e4a2f40 100644 --- a/submissions/test_views.py +++ b/submissions/test_views.py @@ -15,7 +15,7 @@ from .factories import UnassignedSubmissionFactory, EICassignedSubmissionFactory ResubmittedSubmissionFactory, ResubmissionFactory,\ PublishedSubmissionFactory, DraftReportFactory,\ AcceptedRefereeInvitationFactory -from .forms import RequestSubmissionForm, SubmissionIdentifierForm, ReportForm +from .forms import SubmissionIdentifierForm, ReportForm. SubmissionForm from .models import Submission, Report, RefereeInvitation from journals.models import Journal @@ -96,7 +96,7 @@ class PrefillUsingIdentifierTest(BaseContributorTestCase): {'identifier': TEST_SUBMISSION['identifier_w_vn_nr']}) self.assertEqual(response.status_code, 200) - self.assertIsInstance(response.context['form'], RequestSubmissionForm) + # self.assertIsInstance(response.context['form'], SubmissionForm) # Explicitly compare fields instead of assertDictEqual as metadata field may be outdated # self.assertEqual(TEST_SUBMISSION['is_resubmission'], @@ -189,7 +189,7 @@ class SubmitManuscriptTest(BaseContributorTestCase): # Submit new Submission form response = client.post(reverse('submissions:submit_manuscript'), params) self.assertEqual(response.status_code, 200) - self.assertIsInstance(response.context['form'], RequestSubmissionForm) + self.assertIsInstance(response.context['form'], SubmissionForm) self.assertFalse(response.context['form'].is_valid()) self.assertIn('author_list', response.context['form'].errors.keys()) diff --git a/submissions/urls.py b/submissions/urls.py index 9b2f9985cbf8c22e4f8c9bd2600efb1c94925991..5591c3ff0cb1661be5172ecc0563d6057c21da2e 100644 --- a/submissions/urls.py +++ b/submissions/urls.py @@ -68,6 +68,7 @@ urlpatterns = [ url(r'^admin/reports/(?P<report_id>[0-9]+)/compile$', views.report_pdf_compile, name='report_pdf_compile'), + url(r'^resubmit_manuscript$', views.resubmit_manuscript, name='resubmit_manuscript'), url(r'^submit_manuscript$', views.prefill_using_arxiv_identifier, name='submit_manuscript'), url(r'^submit_manuscript/scipost$', views.RequestSubmissionUsingSciPostView.as_view(), name='submit_manuscript_scipost'), diff --git a/submissions/views.py b/submissions/views.py index 583544cee207701ed035eaf21cecff19764bd2f7..65f82868b4005d024527aa5bed8a5125d7531f8d 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -1,10 +1,8 @@ __copyright__ = "Copyright 2016-2018, Stichting SciPost (SciPost Foundation)" __license__ = "AGPL v3" - import datetime import feedparser -import json import strings from django.contrib import messages @@ -32,7 +30,7 @@ from .models import ( Submission, EICRecommendation, EditorialAssignment, RefereeInvitation, Report, SubmissionEvent) from .mixins import SubmissionAdminViewMixin from .forms import ( - SubmissionIdentifierForm, RequestSubmissionForm, SubmissionSearchForm, RecommendationVoteForm, + SubmissionIdentifierForm, SubmissionForm, SubmissionSearchForm, RecommendationVoteForm, ConsiderAssignmentForm, InviteEditorialAssignmentForm, EditorialAssignmentForm, VetReportForm, SetRefereeingDeadlineForm, RefereeSearchForm, #RefereeSelectForm, iThenticateReportForm, VotingEligibilityForm, @@ -58,26 +56,58 @@ from ontology.models import Topic from ontology.forms import SelectTopicForm from production.forms import ProofsDecisionForm from profiles.models import Profile -from profiles.forms import SimpleProfileForm +from profiles.forms import SimpleProfileForm, ProfileEmailForm from scipost.constants import INVITATION_REFEREEING +from scipost.decorators import is_contributor_user from scipost.forms import RemarkForm from scipost.mixins import PaginationMixin from scipost.models import Contributor, Remark -from submissions.models import RefereeInvitation - -# from notifications.views import is_test_user # Temporarily until release ############### # SUBMISSIONS: ############### +@login_required +@is_contributor_user() +def resubmit_manuscript(request): + """ + Choose which Submission to resubmit if Submission is available. + + On POST, redirect to submit page. + """ + submissions = get_list_or_404( + Submission.objects.candidate_for_resubmission(request.user)) + if request.POST and request.POST.get('submission'): + if request.POST['submission'] == 'new': + return redirect(reverse('submissions:submit_manuscript_scipost') + '?resubmission=false') + + last_submission = Submission.objects.candidate_for_resubmission(request.user).filter( + id=request.POST['submission']).first() + + if last_submission: + if last_submission.preprint.scipost_preprint_identifier: + # Determine right preprint-view. + extra_param = '?resubmission={}'.format(request.POST['submission']) + return redirect(reverse('submissions:submit_manuscript_scipost') + extra_param) + else: + extra_param = '?identifier_w_vn_nr={}'.format( + last_submission.preprint.identifier_w_vn_nr) + return redirect(reverse('submissions:submit_manuscript') + extra_param) + else: + # POST request invalid. Try again with GET request. + return redirect('submissions:resubmit_manuscript') + context = { + 'submissions': submissions, + } + return render(request, 'submissions/submission_resubmission_candidates.html', context) + class RequestSubmissionView(LoginRequiredMixin, PermissionRequiredMixin, CreateView): """Formview to submit a new manuscript (Submission).""" permission_required = 'scipost.can_submit_manuscript' success_url = reverse_lazy('scipost:personal_page') - form_class = RequestSubmissionForm + form_class = SubmissionForm template_name = 'submissions/submission_form.html' def get_context_data(self, *args, **kwargs): @@ -90,8 +120,8 @@ class RequestSubmissionView(LoginRequiredMixin, PermissionRequiredMixin, CreateV """Form requires extra kwargs.""" kwargs = super().get_form_kwargs() kwargs['requested_by'] = self.request.user - if hasattr(self, 'initial_data'): - kwargs['initial'] = self.initial_data + kwargs['initial'] = getattr(self, 'initial_data', {}) + kwargs['initial']['resubmission'] = self.request.GET.get('resubmission') return kwargs @transaction.atomic @@ -105,7 +135,7 @@ class RequestSubmissionView(LoginRequiredMixin, PermissionRequiredMixin, CreateV 'Your Submission will soon be handled by an Editor.') messages.success(self.request, text) - if form.submission_is_resubmission(): + if form.is_resubmission(): # Send emails SubmissionUtils.load({'submission': submission}, self.request) SubmissionUtils.send_authors_resubmission_ack_email() @@ -127,35 +157,41 @@ class RequestSubmissionUsingArXivView(RequestSubmissionView): """Formview to submit a new Submission using arXiv.""" def get(self, request): - """Redirect to the arXiv prefill form if arXiv ID is not known.""" + """ + Redirect to the arXiv prefill form if arXiv ID is not known. + """ form = SubmissionIdentifierForm(request.GET or None, requested_by=self.request.user) if form.is_valid(): # Gather data from ArXiv API if prefill form is valid - self.initial_data = form.request_arxiv_preprint_form_prefill_data() + self.initial_data = form.get_initial_submission_data() return super().get(request) else: + for code, err in form.errors.items(): + messages.warning(request, err[0]) return redirect('submissions:prefill_using_identifier') def get_form_kwargs(self): """Form requires extra kwargs.""" kwargs = super().get_form_kwargs() - kwargs['use_arxiv_preprint'] = True + kwargs['preprint_server'] = 'arxiv' return kwargs class RequestSubmissionUsingSciPostView(RequestSubmissionView): """Formview to submit a new Submission using SciPost's preprint server.""" - def dispatch(self, request, *args, **kwargs): - """TEMPORARY: Not accessible unless in test group.""" - # if not is_test_user(request.user): - # raise Http404 - return super().dispatch(request, *args, **kwargs) + def get(self, request): + """Check for possible Resubmissions before dispatching.""" + if Submission.objects.candidate_for_resubmission(request.user).exists(): + if not request.GET.get('resubmission'): + return redirect('submissions:resubmit_manuscript') + return super().get(request) def get_form_kwargs(self): """Form requires extra kwargs.""" kwargs = super().get_form_kwargs() - kwargs['use_arxiv_preprint'] = False + # kwargs['use_arxiv_preprint'] = False + kwargs['preprint_server'] = 'scipost' return kwargs @@ -165,13 +201,10 @@ def prefill_using_arxiv_identifier(request): """Form view asking for the arXiv ID related to the new Submission to submit.""" query_form = SubmissionIdentifierForm(request.POST or None, initial=request.GET or None, requested_by=request.user) - if query_form.is_valid(): - prefill_data = query_form.request_arxiv_preprint_form_prefill_data() - form = RequestSubmissionForm( - initial=prefill_data, requested_by=request.user, use_arxiv_preprint=True) + if query_form.is_valid(): # Submit message to user - if query_form.submission_is_resubmission(): + if query_form.service.is_resubmission(): resubmessage = ('There already exists a preprint with this arXiv identifier ' 'but a different version number. \nYour Submission will be ' 'handled as a resubmission.') @@ -179,14 +212,10 @@ def prefill_using_arxiv_identifier(request): else: messages.success(request, strings.acknowledge_arxiv_query, fail_silently=True) - context = { - 'form': form, - } response = redirect('submissions:submit_manuscript_arxiv') response['location'] += '?identifier_w_vn_nr={}'.format( query_form.cleaned_data['identifier_w_vn_nr']) - # return render(request, 'submissions/submission_form.html', context) - return reponse + return response context = { 'form': query_form, @@ -915,6 +944,7 @@ def select_referee(request, identifier_w_vn_nr): 'workdays_left_to_report': workdays_between(timezone.now(), submission.reporting_deadline), 'referee_search_form': referee_search_form, 'queryresults': queryresults, + 'profile_email_form': ProfileEmailForm(initial={'primary': True}), }) return render(request, 'submissions/select_referee.html', context) @@ -1620,7 +1650,8 @@ def prepare_for_voting(request, rec_id): Q(contributor__expertises__contains=[recommendation.submission.subject_area]) | Q(contributor__expertises__contains=secondary_areas)).order_by( 'contributor__user__last_name') - coauthorships = recommendation.submission.flag_coauthorships_arxiv(fellows_with_expertise) + #coauthorships = recommendation.submission.flag_coauthorships_arxiv(fellows_with_expertise) + coauthorships = None context = { 'recommendation': recommendation, diff --git a/templates/email/contributors/inform_contributor_duplicate_accounts_merged.html b/templates/email/contributors/inform_contributor_duplicate_accounts_merged.html new file mode 100644 index 0000000000000000000000000000000000000000..13ddb815260a104d4936b83a1172b93bba1759f7 --- /dev/null +++ b/templates/email/contributors/inform_contributor_duplicate_accounts_merged.html @@ -0,0 +1,18 @@ +<p> + Dear {{ contrib_from.duplicate_of.get_title_display }} {{ contrib_from.duplicate_of.user.last_name }}, +</p> +<p> + We noticed that you had two separate registrations at SciPost, and have consolidated your two accounts into a single active one, namely your account with username <strong><em style="color: green;">{{ contrib_from.duplicate_of.user.username }}</em></strong>. +</p> +<p> + Your alternate account with username <strong><em style="color: red;">{{ contrib_from.user.username }}</em></strong> has been deactivated, but all the data associated to it has been transferred to your active account. +</p> +<p> + Please get in touch with us at <a href="mailto:techsupport@scipost.org">SciPost techsupport</a> (or by simply replying to this email) if you have any questions. +</p> +<p> + Many thanks, + <br><br> + The SciPost Team +</p> +{% include 'email/_footer.html' %} diff --git a/templates/email/contributors/inform_contributor_duplicate_accounts_merged.json b/templates/email/contributors/inform_contributor_duplicate_accounts_merged.json new file mode 100644 index 0000000000000000000000000000000000000000..f7987957d8141c238f624e2b2de5029267866efe --- /dev/null +++ b/templates/email/contributors/inform_contributor_duplicate_accounts_merged.json @@ -0,0 +1,8 @@ +{ + "subject": "SciPost: duplicate accounts merged", + "to_address": "user.email", + "bcc_to": "admin@scipost.org", + "from_address_name": "SciPost Admin", + "from_address": "admin@scipost.org", + "context_object": "contrib_from" +} diff --git a/templates/email/partners_followup_mail.html b/templates/email/partners_followup_mail.html index cea1dce97c43ceafa3ea6039a435a207799f9277..71a0920ecb6b68fe351b148e166be8099f62c2a2 100644 --- a/templates/email/partners_followup_mail.html +++ b/templates/email/partners_followup_mail.html @@ -3,15 +3,15 @@ </p> <p> - We recently contacted you concerning SciPost, and to probe your interest in joining its Supporting Partners Board. With this follow-up email, I would simply like to check whether you got the original message. + We recently contacted you concerning SciPost, and to probe your interest in joining its Sponsors Board. With this follow-up email, I would simply like to check whether you got the original message. </p> <p> - <a href="https://scipost.org">SciPost</a> is a next-generation publication portal aiming to transform the business of scientific publishing. You can find a one-page summary in <a href="https://scipost.org/static/scipost/SPB/SciPost_Supporting_Partners_Board_Prospectus.pdf">our online prospectus</a> outlining the reasons why joining would be beneficial for your institution. + <a href="https://scipost.org">SciPost</a> is a next-generation publication portal aiming to transform the business of scientific publishing. You can find a one-page summary in <a href="https://scipost.org/static/sponsors/SciPost_Sponsors_Board_Prospectus.pdf">our online prospectus</a> outlining the reasons why joining would be beneficial for your institution. </p> <p> - I will be happy to provide any required further details. If you are interested, you can simply get in touch via this address (partners@scipost.org). I sincerely hope that SciPost will be able to count on your support. + I will be happy to provide any required further details. If you are interested, you can simply get in touch via this address (sponsors@scipost.org). I sincerely hope that SciPost will be able to count on your support. </p> <p> diff --git a/templates/email/partners_followup_mail.json b/templates/email/partners_followup_mail.json index e2a4c15705f549d843406715290456ef0ab61545..4de328173c68023d94babddefe0b0fd292103c9d 100644 --- a/templates/email/partners_followup_mail.json +++ b/templates/email/partners_followup_mail.json @@ -1,8 +1,8 @@ { - "subject": "SciPost: Supporting Partners Board", + "subject": "SciPost: Sponsors Board", "to_address": "email", - "bcc_to": "partners@scipost.org", - "from_address_name": "SciPost Supporting Partners", - "from_address": "partners@scipost.org", + "bcc_to": "sponsors@scipost.org", + "from_address_name": "SciPost Sponsors", + "from_address": "sponsors@scipost.org", "context_object": "contact" } diff --git a/templates/email/partners_initial_mail.html b/templates/email/partners_initial_mail.html index 97683fed31ac84a67f8afd649b53dc54966103ba..131c40fa31245a6189abbced14db85e53f72de36 100644 --- a/templates/email/partners_initial_mail.html +++ b/templates/email/partners_initial_mail.html @@ -12,11 +12,11 @@ </p> <p> - Crucially, as explained on our <a href="https://scipost.org/partners">Partners page</a>, SciPost follows a completely different funding model than traditional publishers, and provides a cost-slashing alternative to existing platforms. SciPost charges neither subscription fees, nor article processing charges; its activities are instead to be collectively financed through a Supporting Partners Board, formed by a worldwide consortium of institutions and organizations which directly or indirectly benefit from SciPost’s activities. + Crucially, as explained on our <a href="https://scipost.org/sponsors">Sponsors page</a>, SciPost follows a completely different funding model than traditional publishers, and provides a cost-slashing alternative to existing platforms. SciPost charges neither subscription fees, nor article processing charges; its activities are instead to be collectively financed through a Sponsors Board, formed by a worldwide consortium of institutions and organizations which directly or indirectly benefit from SciPost’s activities. </p> <p> - A short summary of important aspects of SciPost and reasons for you to join its Supporting Partners Board are given in <a href="https://scipost.org/static/scipost/SPB/SciPost_Supporting_Partners_Board_Prospectus.pdf">our one-page prospectus</a>. + A short summary of important aspects of SciPost and reasons for you to join its Sponsors Board are given in <a href="https://scipost.org/static/sponsors/SciPost_Sponsors_Board_Prospectus.pdf">our one-page prospectus</a>. </p> <p> @@ -24,15 +24,15 @@ </p> <p> - In <a href="https://scipost.org/static/scipost/SPB/SciPost_Supporting_Partner_Agreement.pdf">the agreement template</a>, you will find many more specific details about our operations, requirements and funding strategy. I would greatly appreciate if you took a few minutes to read through this document. + In <a href="https://scipost.org/static/sponsors/SciPost_Sponsorship_Agreement.pdf">the agreement template</a>, you will find many more specific details about our operations, requirements and funding strategy. I would greatly appreciate if you took a few minutes to read through this document. </p> <p> - It would be a privilege to welcome you as members of our Supporting Partners Board. I am hereby contacting you to enquire whether your institution would consider joining. Your support at this time is crucially required to make our initiative sustainable, and to help make it possible for the community to reap all the benefits deriving form its viable implementation. + It would be a privilege to welcome you as members of our Sponsors Board. I am hereby contacting you to enquire whether your institution would consider joining. Your support at this time is crucially required to make our initiative sustainable, and to help make it possible for the community to reap all the benefits deriving form its viable implementation. </p> <p> - I will be happy to provide any required further details. If you are interested, you can simply get in touch via this address (partners@scipost.org). I sincerely hope that SciPost will be able to count on your support. + I will be happy to provide any required further details. If you are interested, you can simply get in touch via this address (sponsors@scipost.org). I sincerely hope that SciPost will be able to count on your support. </p> <p> diff --git a/templates/email/partners_initial_mail.json b/templates/email/partners_initial_mail.json index e2a4c15705f549d843406715290456ef0ab61545..4de328173c68023d94babddefe0b0fd292103c9d 100644 --- a/templates/email/partners_initial_mail.json +++ b/templates/email/partners_initial_mail.json @@ -1,8 +1,8 @@ { - "subject": "SciPost: Supporting Partners Board", + "subject": "SciPost: Sponsors Board", "to_address": "email", - "bcc_to": "partners@scipost.org", - "from_address_name": "SciPost Supporting Partners", - "from_address": "partners@scipost.org", + "bcc_to": "sponsors@scipost.org", + "from_address_name": "SciPost Sponsors", + "from_address": "sponsors@scipost.org", "context_object": "contact" } diff --git a/templates/email/potentialfellowships/invite_potential_fellow_initial.html b/templates/email/potentialfellowships/invite_potential_fellow_initial.html index 71fae8d06fa3d724a0931b18989c4c29b4f08367..8c54bc307825d84575af609fe48ab257692f04d1 100644 --- a/templates/email/potentialfellowships/invite_potential_fellow_initial.html +++ b/templates/email/potentialfellowships/invite_potential_fellow_initial.html @@ -1,23 +1,19 @@ <p>Dear {{ potfel.profile.get_title_display }} {{ potfel.profile.last_name }},</p> -<p>Hopefully you've already come across <a href="https://scipost.org{% url 'scipost:index' %}">SciPost</a> and are aware of our mission to establish a healthier infrastructure for scientific publishing.</p> +<p>Hopefully you've already come across <a href="https://scipost.org{% url 'scipost:index' %}">SciPost</a> and are aware of our mission to establish a community-based infrastructure for scientific publishing.</p> -<p>On behalf of the SciPost Foundation and in view of your professional expertise and reputation, I hereby would like to invite you to join our Editorial College and become one of our Editorial Fellows. We are currently making a big push for expansion of our activities, see <a href="https://scipost.org{% url 'scipost:ExpSustDrive2018' %}">this page</a> for details.</p> +<p>On behalf of the SciPost Foundation and in view of your professional expertise and reputation, I hereby would like to invite you to join our Editorial College by becoming one of our Editorial Fellows.</p> <p> - Please note that only well-known and respected senior academics are being contacted for this purpose. - Academic reputation and involvement in the community are the most important criteria guiding our considerations of who should belong to the Editorial College. - The current list of Fellows can be found at <a href="https://scipost.org{% url 'scipost:about' %}">scipost.org/about</a>; on this page, you will also find basic information on SciPost and its guiding principles (you can also take a look at our <a href="https://scipost.org{% url 'scipost:FAQ' %}">frequently asked questions page</a>). + Academic reputation is the most important criterion guiding our considerations of who should belong to the Editorial College. The current list of Fellows can be found at <a href="https://scipost.org{% url 'scipost:about' %}">scipost.org/about</a>; on this page, you will also find basic information on SciPost and its guiding principles. </p> <p> - We do not pose any conditions on your involvement, and you would always remain in complete control of your level of commitment (devoting even a couple of hours per month would be enough to help out significantly). - Functioning of the College proceeds according to the by-laws set out at <a href="https://scipost.org{% url 'scipost:EdCol_by-laws' %}">scipost.org/EdCol_by-laws</a>, - and a short summary of the editorial workflow can be found at <a href="https://scipost.org{% url 'submissions:editorial_workflow' %}">this page</a>. + We do not pose any conditions on your involvement, and you will always remain in complete control of your level of commitment (a couple of hours per month would already be significant). Functioning of the College proceeds according to the by-laws set out at <a href="https://scipost.org{% url 'scipost:EdCol_by-laws' %}">scipost.org/EdCol_by-laws</a>, and a short summary of the editorial workflow can be found at <a href="https://scipost.org{% url 'submissions:editorial_workflow' %}">this page</a>. </p> <p> - I would be very happy to provide you with more information should you require it. Could I beg you to give us a response (by replying to this email) within the next couple of weeks? + I would be very happy to provide you with more information should you require it. Could I beg you to give us a response (by replying to this email) within the next couple of weeks? </p> <p> diff --git a/templates/email/referees/invite_unregistered_to_referee.json b/templates/email/referees/invite_unregistered_to_referee.json index 13b5f05ee65ba24bf27dd527f63381f8774128ad..d603bf8e2193fe984a74247759e9dc7cea763d4f 100644 --- a/templates/email/referees/invite_unregistered_to_referee.json +++ b/templates/email/referees/invite_unregistered_to_referee.json @@ -1,7 +1,7 @@ { "subject": "SciPost: refereeing and registration invitation", "to_address": "email_address", - "bcc_to": "invited_by.email,edadmin@scipost.org", + "bcc_to": "submission.editor_in_charge.user.email,edadmin@scipost.org", "from_address_name": "SciPost Refereeing", "from_address": "refereeing@scipost.org", "context_object": "invitation"