diff --git a/commentaries/admin.py b/commentaries/admin.py index 80f638be8c0bcd1ca9b9141c8a12e1022be9924b..93aee6375bdb1b36584042765d5e5220d7d1ca0e 100644 --- a/commentaries/admin.py +++ b/commentaries/admin.py @@ -1,12 +1,32 @@ from django.contrib import admin +from django import forms + from commentaries.models import Commentary +from scipost.models import Contributor + + +class CommentaryAdminForm(forms.ModelForm): + authors = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.order_by('user__last_name')) + authors_claims = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.order_by('user__last_name')) + authors_false_claims = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.order_by('user__last_name')) + + class Meta: + model = Commentary + fields = '__all__' + class CommentaryAdmin(admin.ModelAdmin): search_fields = ['author_list', 'pub_abstract'] list_display = ('__str__', 'vetted', 'latest_activity',) date_hierarchy = 'latest_activity' - + form = CommentaryAdminForm admin.site.register(Commentary, CommentaryAdmin) diff --git a/commentaries/urls.py b/commentaries/urls.py index 7d3280cb2d93dd748b51713f6c7f20cd390a8484..b3c4e999c7119ae5d1b063efd5c80c3820cffd71 100644 --- a/commentaries/urls.py +++ b/commentaries/urls.py @@ -6,7 +6,7 @@ from . import views urlpatterns = [ # Commentaries url(r'^$', views.CommentaryListView.as_view(), name='commentaries'), - url(r'^browse/(?P<discipline>[a-z]+)/(?P<nrweeksback>[0-9]+)/$', + url(r'^browse/(?P<discipline>[a-z]+)/(?P<nrweeksback>[0-9]{1,3})/$', views.CommentaryListView.as_view(), name='browse'), url(r'^howto$', TemplateView.as_view(template_name='commentaries/howto.html'), name='howto'), diff --git a/journals/admin.py b/journals/admin.py index 48f9f4ee5a90149ca010e5fe22edf9d85e75f7c6..8c82762bbe9cb7df533f9aec127ecb2ca1328fe7 100644 --- a/journals/admin.py +++ b/journals/admin.py @@ -1,7 +1,17 @@ from django.contrib import admin, messages +from django import forms -from journals.models import Journal, Volume, Issue, Publication, Deposit +from journals.models import UnregisteredAuthor, Journal, Volume, Issue, Publication, Deposit +from scipost.models import Contributor +from submissions.models import Submission + + +class UnregisteredAuthorAdmin(admin.ModelAdmin): + search_fields = ['last_name'] + ordering = ['last_name'] + +admin.site.register(UnregisteredAuthor, UnregisteredAuthorAdmin) class JournalAdmin(admin.ModelAdmin): @@ -26,12 +36,29 @@ class IssueAdmin(admin.ModelAdmin): admin.site.register(Issue, IssueAdmin) +class PublicationAdminForm(forms.ModelForm): + accepted_submission = forms.ModelChoiceField( + queryset=Submission.objects.order_by('-arxiv_identifier_w_vn_nr')) + authors = forms.ModelMultipleChoiceField( + queryset=Contributor.objects.order_by('user__last_name')) + authors_claims = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.order_by('user__last_name')) + authors_false_claims = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.order_by('user__last_name')) + + class Meta: + model = Publication + fields = '__all__' + + class PublicationAdmin(admin.ModelAdmin): search_fields = ['title', 'author_list'] list_display = ['title', 'author_list', 'in_issue', 'doi_string', 'publication_date'] date_hierarchy = 'publication_date' list_filter = ['in_issue'] - + form = PublicationAdminForm admin.site.register(Publication, PublicationAdmin) diff --git a/journals/utils.py b/journals/utils.py index bda96d34647ef5620842afed9113749834afe348..b1e7630d7fc7656e2a559f33977dedad3b42f3ba 100644 --- a/journals/utils.py +++ b/journals/utils.py @@ -22,6 +22,8 @@ class JournalUtils(object): + cls.publication.citation() + '.' '\n\nThe publication page is located at the permanent link ' 'https://scipost.org/' + cls.publication.doi_label + '.' + '\n\nThe permanent DOI for your publication is 10.21468/' + + cls.publication.doi_label + '.' '\n\nTo facilitate dissemination of your paper, we greatly encourage ' 'you to update the arXiv Journal-ref with this information.' '\n\nWe warmly congratulate you on this achievement and thank you ' diff --git a/mailing_lists/forms.py b/mailing_lists/forms.py index b8b73d6efffc49c37895448538631fac0282a3c8..b5fed11fb236bad305fcafd7e966675f202582cd 100644 --- a/mailing_lists/forms.py +++ b/mailing_lists/forms.py @@ -6,8 +6,6 @@ from mailchimp3 import MailChimp from .constants import MAIL_LIST_STATUS_ACTIVE, MAIL_LIST_STATUS_DEACTIVATED from .models import MailchimpList -from scipost.models import Contributor - class MailchimpUpdateForm(forms.Form): """ @@ -37,5 +35,4 @@ class MailchimpUpdateForm(forms.Form): return count def sync_members(self, _list): - contributors = Contributor.objects.active().filter(accepts_SciPost_emails=True) - return _list.update_membership(contributors) + return _list.update_members() diff --git a/mailing_lists/migrations/0005_mailchimplist_subscriber_count.py b/mailing_lists/migrations/0005_mailchimplist_subscriber_count.py new file mode 100644 index 0000000000000000000000000000000000000000..eba275dced47db292b60ef89881325ff0b913194 --- /dev/null +++ b/mailing_lists/migrations/0005_mailchimplist_subscriber_count.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-06-07 21:24 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mailing_lists', '0004_auto_20170423_2238'), + ] + + operations = [ + migrations.AddField( + model_name='mailchimplist', + name='subscriber_count', + field=models.PositiveIntegerField(default=0), + ), + ] diff --git a/mailing_lists/models.py b/mailing_lists/models.py index 262f50eb40ad059683aa59cd92928ad292bad0d2..e9e3660f3b207f9989286f540aa3f5c6de96f680 100644 --- a/mailing_lists/models.py +++ b/mailing_lists/models.py @@ -1,4 +1,5 @@ -from django.db import models +from django.db import models, transaction +from django.contrib.auth.models import User from django.conf import settings from django.contrib.auth.models import Group from django.urls import reverse @@ -10,6 +11,8 @@ from .constants import MAIL_LIST_STATUSES, MAIL_LIST_STATUS_ACTIVE,\ from .managers import MailListManager from scipost.behaviors import TimeStampedModel +from scipost.constants import CONTRIBUTOR_NORMAL +from scipost.models import Contributor class MailchimpList(TimeStampedModel): @@ -24,6 +27,7 @@ class MailchimpList(TimeStampedModel): mailchimp_list_id = models.CharField(max_length=255, unique=True) status = models.CharField(max_length=255, choices=MAIL_LIST_STATUSES, default=MAIL_LIST_STATUS_ACTIVE) + subscriber_count = models.PositiveIntegerField(default=0) open_for_subscription = models.BooleanField(default=False) allowed_groups = models.ManyToManyField(Group, related_name='allowed_mailchimp_lists') @@ -40,23 +44,79 @@ class MailchimpList(TimeStampedModel): def get_absolute_url(self): return reverse('mailing_lists:list_detail', args=[self.mailchimp_list_id]) - def update_membership(self, contributors, status='subscribed'): + @transaction.atomic + def update_members(self, status='subscribed'): + """ + Update the subscribers in the MailChimp account. + """ + # Extreme timeset value (1 minute) to allow for huge maillist subscribes client = MailChimp(settings.MAILCHIMP_API_USER, settings.MAILCHIMP_API_KEY) - for contributor in contributors: - if self.allowed_groups.filter(user__contributor=contributor).exists(): - payload = { - 'email_address': contributor.user.email, + try: + unsubscribe_emails = [] + # Find all campaigns on the account + campaigns = client.campaigns.all(get_all=True) + for campaign in campaigns['campaigns']: + + # All unsubscriptions are registered per campaign + # Should be improved later on + unsubscribers = client.reports.unsubscribes.all(campaign['id'], True) + for unsubscriber in unsubscribers['unsubscribes']: + if unsubscriber['list_id'] == self.mailchimp_list_id: + unsubscribe_emails.append(unsubscriber['email_address']) + except KeyError: + # Call with MailChimp went wrong, returned invalid data + return None + + # Unsubscribe *all* Contributors in the database if asked for + updated_contributors = (Contributor.objects + .filter(accepts_SciPost_emails=True, + user__email__in=unsubscribe_emails) + .update(accepts_SciPost_emails=False)) + + # Check the current list of subscribers in MailChimp account + subscribers_list = client.lists.members.all(self.mailchimp_list_id, True, + fields="members.email_address") + subscribers_list = [sub['email_address'] for sub in subscribers_list['members']] + + # Retrieve *users* that are in the right group and didn't unsubscribe and + # are not in the list yet. + db_subscribers = (User.objects + .filter(contributor__isnull=False) + .filter(is_active=True, contributor__status=CONTRIBUTOR_NORMAL) + .filter(contributor__accepts_SciPost_emails=True, + groups__in=self.allowed_groups.all(), + email__isnull=False, + first_name__isnull=False, + last_name__isnull=False) + .exclude(email__in=subscribers_list)) + + # Build batch data + batch_data = {'operations': []} + add_member_path = 'lists/%s/members' % self.mailchimp_list_id + for user in db_subscribers: + batch_data['operations'].append({ + 'method': 'POST', + 'path': add_member_path, + 'data': { 'status': status, 'status_if_new': status, + 'email_address': user.email, 'merge_fields': { - 'FNAME': contributor.user.first_name, - 'LNAME': contributor.user.last_name, + 'FNAME': user.first_name, + 'LNAME': user.last_name, }, } - client.lists.members.create_or_update(self.mailchimp_list_id, - payload['email_address'], - payload) - return True + }) + # Make the subscribe call + client.batches.create(data=batch_data) + + # No need to update Contributor field *yet*. MailChimp account is leading here. + # Contributor.objects.filter(user__in=db_subscribers).update(accepts_SciPost_emails=True) + + list_data = client.lists.get(list_id=self.mailchimp_list_id) + self.subscriber_count = list_data['stats']['member_count'] + self.save() + return (updated_contributors, len(db_subscribers),) class MailchimpSubscription(TimeStampedModel): diff --git a/mailing_lists/templates/mailing_lists/mailchimplist_form.html b/mailing_lists/templates/mailing_lists/mailchimplist_form.html index 88fd7fface6bf3186f8ff11edb3cb6d56d0f28a6..37347dc82164961bf6bdd427468983105174fda8 100644 --- a/mailing_lists/templates/mailing_lists/mailchimplist_form.html +++ b/mailing_lists/templates/mailing_lists/mailchimplist_form.html @@ -17,7 +17,7 @@ <div class="col-12"> <h1>Edit the mailing list <i>{{object}}</i></h1> <h3>Mailchimp configuration:</h3> - <pre><code>ID: {{object.mailchimp_list_id}}<br>name: {{object.name}}<br>Status: {{object.get_status_display}}</code></pre> + <pre><code>ID: {{object.mailchimp_list_id}}<br>Name: {{object.name}}<br>Status: {{object.get_status_display}}<br>Member count: {{object.subscriber_count}}</code></pre> <h3 class="mt-2">Actions:</h3> <ul class="actions"> <li><a href="{% url 'mailing_lists:sync_members' object.mailchimp_list_id %}">Syncronize members of the list</a></li> diff --git a/mailing_lists/views.py b/mailing_lists/views.py index 85396f413f8f982cd0683d587c1e1abc92929f05..a6952dc61e4661080127d4d588d091d3f0809ff3 100644 --- a/mailing_lists/views.py +++ b/mailing_lists/views.py @@ -48,8 +48,15 @@ def syncronize_members(request, list_id): """ _list = get_object_or_404(MailchimpList, mailchimp_list_id=list_id) form = MailchimpUpdateForm() - updated = form.sync_members(_list) - messages.success(request, '%i members have succesfully been updated.' % updated) + unsubscribed, subscribed = form.sync_members(_list) + + # Let the user know + text = '<h3>Syncronize members complete.</h3>' + if unsubscribed: + text += '<br>%i members have succesfully been unsubscribed.' % unsubscribed + if subscribed: + text += '<br>%i members have succesfully been subscribed.' % subscribed + messages.success(request, text) return redirect(_list.get_absolute_url()) diff --git a/partners/admin.py b/partners/admin.py index 2b1ddd057b5450a624f52bfba070d5de1bc36f56..d2277fbda497776fd96edc7b2723acb0e5be8f52 100644 --- a/partners/admin.py +++ b/partners/admin.py @@ -1,18 +1,26 @@ from django.contrib import admin -from .models import ContactPerson, Partner, Consortium,\ - ProspectivePartner, MembershipAgreement +from .models import Contact, Partner, Consortium, ProspectivePartner, MembershipAgreement,\ + ProspectiveContact -admin.site.register(ContactPerson) +class ProspectiveContactInline(admin.TabularInline): + model = ProspectiveContact + extra = 0 class PartnerAdmin(admin.ModelAdmin): - search_fields = ['institution', 'institution_acronym', - 'institution_address', 'contact_person'] + search_fields = ('institution', ) + + +class ProspectivePartnerAdmin(admin.ModelAdmin): + inlines = (ProspectiveContactInline,) + list_display = ('institution_name', 'date_received', 'status') + list_filter = ('kind', 'status') admin.site.register(Partner, PartnerAdmin) +admin.site.register(Contact) admin.site.register(Consortium) -admin.site.register(ProspectivePartner) +admin.site.register(ProspectivePartner, ProspectivePartnerAdmin) admin.site.register(MembershipAgreement) diff --git a/partners/constants.py b/partners/constants.py index ec05c2315596b05013b825e5c27531e75aaca543..2857a634b27058972118f0cd1f58b12e579daa9a 100644 --- a/partners/constants.py +++ b/partners/constants.py @@ -1,19 +1,57 @@ import datetime -PARTNER_TYPES = ( +PARTNER_KIND_UNI_LIBRARY = 'Univ. Library' +PARTNER_KINDS = ( + ('Res. Inst.', 'Research Institute'), ('Int. Fund. Agency', 'International Funding Agency'), ('Nat. Fund. Agency', 'National Funding Agency'), + ('Nat. Lab.', 'National Laboratory'), ('Nat. Library', 'National Library'), - ('Univ. Library', 'University Library'), + ('Nat. Acad.', 'National Academy'), + (PARTNER_KIND_UNI_LIBRARY, 'University (and its Library)'), ('Res. Library', 'Research Library'), + ('Prof. Soc.', 'Professional Society'), ('Foundation', 'Foundation'), ('Individual', 'Individual'), ) +PROSPECTIVE_PARTNER_REQUESTED = 'requested' +PROSPECTIVE_PARTNER_ADDED = 'added' +PROSPECTIVE_PARTNER_APPROACHED = 'approached' +PROSPECTIVE_PARTNER_NEGOTIATING = 'negotiating' +PROSPECTIVE_PARTNER_UNINTERESTED = 'uninterested' +PROSPECTIVE_PARTNER_PROCESSED = 'processed' +PROSPECTIVE_PARTNER_STATUS = ( + (PROSPECTIVE_PARTNER_REQUESTED, 'Requested (from online form)'), + (PROSPECTIVE_PARTNER_ADDED, 'Added internally'), + (PROSPECTIVE_PARTNER_APPROACHED, 'Approached'), + (PROSPECTIVE_PARTNER_NEGOTIATING, 'Negotiating'), + (PROSPECTIVE_PARTNER_UNINTERESTED, 'Uninterested'), + (PROSPECTIVE_PARTNER_PROCESSED, 'Processed into Partner'), +) + +PROSPECTIVE_PARTNER_EVENT_REQUESTED = 'requested' +PROSPECTIVE_PARTNER_EVENT_COMMENT = 'comment', +PROSPECTIVE_PARTNER_EVENT_EMAIL_SENT = 'email_sent' +PROSPECTIVE_PARTNER_EVENT_INITIATE_NEGOTIATION = 'negotiating' +PROSPECTIVE_PARTNER_EVENT_MARKED_AS_UNINTERESTED = 'marked_as_uninterested' +PROSPECTIVE_PARTNER_EVENT_PROMOTED = 'promoted' +PROSPECTIVE_PARTNER_EVENTS = ( + (PROSPECTIVE_PARTNER_EVENT_REQUESTED, 'Requested (from online form)'), + (PROSPECTIVE_PARTNER_EVENT_COMMENT, 'Comment added'), + (PROSPECTIVE_PARTNER_EVENT_EMAIL_SENT, 'Email sent'), + (PROSPECTIVE_PARTNER_EVENT_INITIATE_NEGOTIATION, 'Initiated negotiation'), + (PROSPECTIVE_PARTNER_EVENT_MARKED_AS_UNINTERESTED, 'Marked as uninterested'), + (PROSPECTIVE_PARTNER_EVENT_PROMOTED, 'Promoted to Partner'), +) + + PARTNER_STATUS = ( - ('Prospective', 'Prospective'), + ('Initiated', 'Initiated'), + ('Contacted', 'Contacted'), ('Negotiating', 'Negotiating'), + ('Uninterested', 'Uninterested'), ('Active', 'Active'), ('Inactive', 'Inactive'), ) @@ -26,8 +64,15 @@ CONSORTIUM_STATUS = ( ) +PARTNER_EVENTS = ( + ('initial', 'Contacted (initial)'), + ('status_update', 'Status updated'), + ('comment', 'Comment added'), +) + +MEMBERSHIP_SUBMITTED = 'Submitted' MEMBERSHIP_AGREEMENT_STATUS = ( - ('Submitted', 'Request submitted by Partner'), + (MEMBERSHIP_SUBMITTED, 'Request submitted by Partner'), ('Pending', 'Sent to Partner, response pending'), ('Signed', 'Signed by Partner'), ('Honoured', 'Honoured: payment of Partner received'), diff --git a/partners/forms.py b/partners/forms.py index 3aa5ed7fe1168577f6b676b20755236600e105d2..a09a6b424c8a93b01a17ef287fc432dc4ac27fd9 100644 --- a/partners/forms.py +++ b/partners/forms.py @@ -5,8 +5,8 @@ from django_countries import countries from django_countries.widgets import CountrySelectWidget from django_countries.fields import LazyTypedChoiceField -from .constants import PARTNER_TYPES -from .models import ContactPerson, Partner, ProspectivePartner, MembershipAgreement +from .constants import PARTNER_KINDS +from .models import Partner, ProspectivePartner, ProspectiveContact, ProspectivePartnerEvent from scipost.models import TITLE_CHOICES @@ -22,9 +22,57 @@ class PartnerForm(forms.ModelForm): class ProspectivePartnerForm(forms.ModelForm): + """ + This form is used to internally add a ProspectivePartner. + If an external agent requests membership of the SPB, + the MembershipQueryForm below is used instead. + """ class Meta: model = ProspectivePartner - exclude = ['date_received', 'date_processed', 'processed'] + fields = ('kind', 'institution_name', 'country') + + +class ProspectiveContactForm(forms.ModelForm): + class Meta: + model = ProspectiveContact + fields = '__all__' + widgets = {'prospartner': forms.HiddenInput()} + + +class EmailProspectivePartnerContactForm(forms.Form): + email_subject = forms.CharField(widget=forms.Textarea(), + initial='SciPost Supporting Partners Board') + message = forms.CharField(widget=forms.Textarea(), required=False) + include_SPB_summary = forms.BooleanField( + required=False, initial=False, + label='include SPB summary with message') + + def __init__(self, *args, **kwargs): + super(EmailProspectivePartnerContactForm, self).__init__(*args, **kwargs) + self.fields['email_subject'].widget.attrs.update( + {'rows': 1}) + self.fields['message'].widget.attrs.update( + {'placeholder': 'Write your message in this box (optional).'}) + + +# class ProspectivePartnerContactSelectForm(forms.Form): + +# def __init__(self, *args, **kwargs): +# prospartner_id = kwargs.pop('prospartner.id') +# super(ProspectivePartnerContactSelectForm, self).__init(*args, **kwargs) +# self.fields['contact'] = forms.ModelChoiceField( +# queryset=ProspectiveContact.objects.filter( +# prospartner__pk=prospartner_id).order_by('last_name'), +# required=True) + + +class ProspectivePartnerEventForm(forms.ModelForm): + class Meta: + model = ProspectivePartnerEvent + fields = ('event', 'comments') + widgets = { + 'comments': forms.Textarea(attrs={'cols': 16, 'rows': 3}), + } class MembershipQueryForm(forms.Form): @@ -37,7 +85,7 @@ class MembershipQueryForm(forms.Form): last_name = forms.CharField(label='* Your last name', max_length=100) email = forms.EmailField(label='* Your email address') role = forms.CharField(label='* Your role in your organization') - partner_type = forms.ChoiceField(choices=PARTNER_TYPES, label='* Partner type') + partner_kind = forms.ChoiceField(choices=PARTNER_KINDS, label='* Partner kind') institution_name = forms.CharField(label='* Name of your institution') country = LazyTypedChoiceField( choices=countries, label='* Country', initial='NL', diff --git a/partners/managers.py b/partners/managers.py new file mode 100644 index 0000000000000000000000000000000000000000..53729dc305a9312d29b98d3de6198e17c10f6084 --- /dev/null +++ b/partners/managers.py @@ -0,0 +1,8 @@ +from django.db import models + +from .constants import MEMBERSHIP_SUBMITTED + + +class MembershipAgreementManager(models.Manager): + def submitted(self): + return self.filter(status=MEMBERSHIP_SUBMITTED) diff --git a/partners/migrations/0005_auto_20170603_1646.py b/partners/migrations/0005_auto_20170603_1646.py new file mode 100644 index 0000000000000000000000000000000000000000..323c33155366c09fa911175f93585202187f00f9 --- /dev/null +++ b/partners/migrations/0005_auto_20170603_1646.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-06-03 14:46 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import django_countries.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('scipost', '0055_auto_20170519_0937'), + ('partners', '0004_auto_20170519_1425'), + ] + + operations = [ + migrations.CreateModel( + name='Contact', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(max_length=128)), + ('title', models.CharField(choices=[('PR', 'Prof.'), ('DR', 'Dr'), ('MR', 'Mr'), ('MRS', 'Mrs')], max_length=4)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Institution', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('Res. Inst.', 'Research Institute'), ('Int. Fund. Agency', 'International Funding Agency'), ('Nat. Fund. Agency', 'National Funding Agency'), ('Nat. Lab.', 'National Laboratory'), ('Nat. Library', 'National Library'), ('Nat. Acad.', 'National Academy'), ('Univ. Library', 'University (and its Library)'), ('Res. Library', 'Research Library'), ('Prof. Soc.', 'Professional Society'), ('Foundation', 'Foundation'), ('Individual', 'Individual')], max_length=32)), + ('name', models.CharField(max_length=256)), + ('acronym', models.CharField(max_length=16)), + ('address', models.CharField(blank=True, max_length=1000, null=True)), + ('country', django_countries.fields.CountryField(max_length=2)), + ], + ), + migrations.CreateModel( + name='PartnerEvent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.CharField(choices=[('initial', 'Contacted (initial)'), ('status_update', 'Status updated'), ('comment', 'Comment added')], max_length=64)), + ('comments', models.TextField(blank=True, null=True)), + ('noted_on', models.DateTimeField(default=django.utils.timezone.now)), + ('noted_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='scipost.Contributor')), + ], + ), + migrations.CreateModel( + name='ProspectiveContact', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(choices=[('PR', 'Prof.'), ('DR', 'Dr'), ('MR', 'Mr'), ('MRS', 'Mrs')], max_length=4)), + ('first_name', models.CharField(max_length=64)), + ('last_name', models.CharField(max_length=64)), + ('email', models.EmailField(max_length=254)), + ('role', models.CharField(max_length=128)), + ], + ), + migrations.CreateModel( + name='ProspectivePartnerEvent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.CharField(choices=[('comment', 'Comment added')], max_length=64)), + ('comments', models.TextField(blank=True, null=True)), + ('noted_on', models.DateTimeField(default=django.utils.timezone.now)), + ('duration', models.DurationField(blank=True, null=True)), + ('noted_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='scipost.Contributor')), + ], + ), + migrations.RemoveField( + model_name='contactperson', + name='user', + ), + migrations.RemoveField( + model_name='partner', + name='country', + ), + migrations.RemoveField( + model_name='partner', + name='institution_acronym', + ), + migrations.RemoveField( + model_name='partner', + name='institution_address', + ), + migrations.RemoveField( + model_name='partner', + name='institution_name', + ), + migrations.RemoveField( + model_name='partner', + name='partner_type', + ), + migrations.RemoveField( + model_name='prospectivepartner', + name='email', + ), + migrations.RemoveField( + model_name='prospectivepartner', + name='first_name', + ), + migrations.RemoveField( + model_name='prospectivepartner', + name='last_name', + ), + migrations.RemoveField( + model_name='prospectivepartner', + name='partner_type', + ), + migrations.RemoveField( + model_name='prospectivepartner', + name='role', + ), + migrations.RemoveField( + model_name='prospectivepartner', + name='title', + ), + migrations.AddField( + model_name='prospectivepartner', + name='kind', + field=models.CharField(choices=[('Res. Inst.', 'Research Institute'), ('Int. Fund. Agency', 'International Funding Agency'), ('Nat. Fund. Agency', 'National Funding Agency'), ('Nat. Lab.', 'National Laboratory'), ('Nat. Library', 'National Library'), ('Nat. Acad.', 'National Academy'), ('Univ. Library', 'University (and its Library)'), ('Res. Library', 'Research Library'), ('Prof. Soc.', 'Professional Society'), ('Foundation', 'Foundation'), ('Individual', 'Individual')], default='Univ. Library', max_length=32), + ), + migrations.AlterField( + model_name='partner', + name='financial_contact', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='partner_financial_contact', to='partners.Contact'), + ), + migrations.AlterField( + model_name='partner', + name='main_contact', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='partner_main_contact', to='partners.Contact'), + ), + migrations.AlterField( + model_name='partner', + name='technical_contact', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='partner_technical_contact', to='partners.Contact'), + ), + migrations.DeleteModel( + name='ContactPerson', + ), + migrations.AddField( + model_name='prospectivepartnerevent', + name='prospective_partner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partners.ProspectivePartner'), + ), + migrations.AddField( + model_name='prospectivecontact', + name='institution', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partners.ProspectivePartner'), + ), + migrations.AddField( + model_name='partnerevent', + name='partner', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partners.Partner'), + ), + migrations.AddField( + model_name='partner', + name='institution', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='partners.Institution'), + ), + ] diff --git a/partners/migrations/0006_auto_20170604_0459.py b/partners/migrations/0006_auto_20170604_0459.py new file mode 100644 index 0000000000000000000000000000000000000000..41f7126405b205015e8ad02b0d043bfe24c4ea14 --- /dev/null +++ b/partners/migrations/0006_auto_20170604_0459.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-06-04 02:59 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partners', '0005_auto_20170603_1646'), + ] + + operations = [ + migrations.RenameField( + model_name='prospectivecontact', + old_name='institution', + new_name='prospartner', + ), + migrations.RemoveField( + model_name='prospectivepartner', + name='processed', + ), + migrations.AddField( + model_name='prospectivepartner', + name='status', + field=models.CharField(choices=[('requested', 'Requested (from online form)'), ('added', 'Added internally'), ('processed', 'Processed into Partner object')], default='added', max_length=32), + ), + migrations.AlterField( + model_name='partner', + name='status', + field=models.CharField(choices=[('Initiated', 'Initiated'), ('Contacted', 'Contacted'), ('Negotiating', 'Negotiating'), ('Uninterested', 'Uninterested'), ('Active', 'Active'), ('Inactive', 'Inactive')], max_length=16), + ), + ] diff --git a/partners/migrations/0007_auto_20170604_0629.py b/partners/migrations/0007_auto_20170604_0629.py new file mode 100644 index 0000000000000000000000000000000000000000..85e41ea59b2757ae781413cbef9d859840286fb6 --- /dev/null +++ b/partners/migrations/0007_auto_20170604_0629.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-06-04 04:29 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('partners', '0006_auto_20170604_0459'), + ] + + operations = [ + migrations.RenameField( + model_name='prospectivepartnerevent', + old_name='prospective_partner', + new_name='prospartner', + ), + migrations.RemoveField( + model_name='prospectivepartnerevent', + name='duration', + ), + ] diff --git a/partners/migrations/0008_auto_20170604_2228.py b/partners/migrations/0008_auto_20170604_2228.py new file mode 100644 index 0000000000000000000000000000000000000000..c62a4d9c2e86276b8dd6d287e71930b3d689ea64 --- /dev/null +++ b/partners/migrations/0008_auto_20170604_2228.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-06-04 20:28 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partners', '0007_auto_20170604_0629'), + ] + + operations = [ + migrations.AlterField( + model_name='institution', + name='address', + field=models.CharField(blank=True, default='', max_length=1000), + preserve_default=False, + ), + migrations.AlterField( + model_name='partnerevent', + name='comments', + field=models.TextField(blank=True, default=''), + preserve_default=False, + ), + migrations.AlterField( + model_name='partnerevent', + name='noted_on', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='prospectivepartner', + name='date_received', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='prospectivepartnerevent', + name='comments', + field=models.TextField(blank=True, default=''), + preserve_default=False, + ), + migrations.AlterField( + model_name='prospectivepartnerevent', + name='noted_on', + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/partners/migrations/0009_auto_20170608_1710.py b/partners/migrations/0009_auto_20170608_1710.py new file mode 100644 index 0000000000000000000000000000000000000000..e3d8f434001df7378e5192d1d2a387ad737f2935 --- /dev/null +++ b/partners/migrations/0009_auto_20170608_1710.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-06-08 15:10 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partners', '0008_auto_20170604_2228'), + ] + + operations = [ + migrations.AlterField( + model_name='prospectivepartnerevent', + name='event', + field=models.CharField(choices=[('comment', 'Comment added'), ('email_sent', 'Email sent'), ('promoted', 'Promoted to Partner')], max_length=64), + ), + ] diff --git a/partners/migrations/0010_auto_20170608_2049.py b/partners/migrations/0010_auto_20170608_2049.py new file mode 100644 index 0000000000000000000000000000000000000000..0decd0ca9bdcf61c197861f0fa67c706e60a53fd --- /dev/null +++ b/partners/migrations/0010_auto_20170608_2049.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-06-08 18:49 +from __future__ import unicode_literals + +from django.db import migrations, models +import scipost.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partners', '0009_auto_20170608_1710'), + ] + + operations = [ + migrations.AlterField( + model_name='prospectivepartner', + name='status', + field=models.CharField(choices=[('requested', 'Requested (from online form)'), ('added', 'Added internally'), ('approached', 'Approached'), ('processed', 'Processed into Partner')], default='added', max_length=32), + ), + migrations.AlterField( + model_name='prospectivepartnerevent', + name='event', + field=models.CharField(choices=[('requested', 'Requested (from online form)'), (('comment',), 'Comment added'), ('email_sent', 'Email sent'), ('promoted', 'Promoted to Partner')], max_length=64), + ), + migrations.AlterField( + model_name='prospectivepartnerevent', + name='noted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=models.SET(scipost.models.get_sentinel_user), to='scipost.Contributor'), + ), + ] diff --git a/partners/migrations/0011_auto_20170609_2234.py b/partners/migrations/0011_auto_20170609_2234.py new file mode 100644 index 0000000000000000000000000000000000000000..dd89d1cf0b531a369289c80aef4e49b77ef72e4e --- /dev/null +++ b/partners/migrations/0011_auto_20170609_2234.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-06-09 20:34 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partners', '0010_auto_20170608_2049'), + ] + + operations = [ + migrations.AlterField( + model_name='prospectivepartner', + name='status', + field=models.CharField(choices=[('requested', 'Requested (from online form)'), ('added', 'Added internally'), ('approached', 'Approached'), ('negotiating', 'Negotiating'), ('uninterested', 'Uninterested'), ('processed', 'Processed into Partner')], default='added', max_length=32), + ), + migrations.AlterField( + model_name='prospectivepartnerevent', + name='event', + field=models.CharField(choices=[('requested', 'Requested (from online form)'), (('comment',), 'Comment added'), ('email_sent', 'Email sent'), ('negotiating', 'Initiated negotiation'), ('marked_as_uninterested', 'Marked as uninterested'), ('promoted', 'Promoted to Partner')], max_length=64), + ), + ] diff --git a/partners/models.py b/partners/models.py index 82450ab9946f58536bb1892d64b2d97b3a296401..075826f7a86405aba6cd7ebe72e3a7a862348d55 100644 --- a/partners/models.py +++ b/partners/models.py @@ -1,23 +1,118 @@ from django.contrib.auth.models import User from django.db import models -from django.utils import timezone from django_countries.fields import CountryField -from .constants import PARTNER_TYPES, PARTNER_STATUS, CONSORTIUM_STATUS,\ - MEMBERSHIP_AGREEMENT_STATUS, MEMBERSHIP_DURATION +from .constants import PARTNER_KINDS, PARTNER_STATUS, CONSORTIUM_STATUS, MEMBERSHIP_DURATION,\ + PROSPECTIVE_PARTNER_STATUS, PROSPECTIVE_PARTNER_EVENTS, PARTNER_EVENTS,\ + MEMBERSHIP_AGREEMENT_STATUS, PROSPECTIVE_PARTNER_ADDED,\ + PARTNER_KIND_UNI_LIBRARY +from .constants import PROSPECTIVE_PARTNER_EVENT_EMAIL_SENT,\ + PROSPECTIVE_PARTNER_APPROACHED,\ + PROSPECTIVE_PARTNER_EVENT_INITIATE_NEGOTIATION,\ + PROSPECTIVE_PARTNER_NEGOTIATING,\ + PROSPECTIVE_PARTNER_EVENT_MARKED_AS_UNINTERESTED,\ + PROSPECTIVE_PARTNER_UNINTERESTED,\ + PROSPECTIVE_PARTNER_EVENT_PROMOTED,\ + PROSPECTIVE_PARTNER_PROCESSED + +from .managers import MembershipAgreementManager from scipost.constants import TITLE_CHOICES +from scipost.models import get_sentinel_user -class ContactPerson(models.Model): +######################## +# Prospective Partners # +######################## + +class ProspectivePartner(models.Model): + """ + Created from the membership_request page, after submitting a query form. + """ + kind = models.CharField(max_length=32, choices=PARTNER_KINDS, default=PARTNER_KIND_UNI_LIBRARY) + institution_name = models.CharField(max_length=256) + country = CountryField() + date_received = models.DateTimeField(auto_now_add=True) + date_processed = models.DateTimeField(blank=True, null=True) + status = models.CharField(max_length=32, choices=PROSPECTIVE_PARTNER_STATUS, + default=PROSPECTIVE_PARTNER_ADDED) + + def __str__(self): + return '%s (received %s), %s' % (self.institution_name, + self.date_received.strftime("%Y-%m-%d"), + self.get_status_display()) + + def update_status_from_event(self, event): + if event == PROSPECTIVE_PARTNER_EVENT_EMAIL_SENT: + self.status = PROSPECTIVE_PARTNER_APPROACHED + elif event == PROSPECTIVE_PARTNER_EVENT_INITIATE_NEGOTIATION: + self.status = PROSPECTIVE_PARTNER_NEGOTIATING + elif event == PROSPECTIVE_PARTNER_EVENT_MARKED_AS_UNINTERESTED: + self.status = PROSPECTIVE_PARTNER_UNINTERESTED + elif event == PROSPECTIVE_PARTNER_EVENT_PROMOTED: + self.status = PROSPECTIVE_PARTNER_PROCESSED + self.save() + + +class ProspectiveContact(models.Model): + """ + A ProspectiveContact is a person's name and contact details, with a + link to a Prospective Partner and a role within it. + It does not have a corresponding User object. + It is meant to be used internally at SciPost, during Partner mining. + """ + prospartner = models.ForeignKey('partners.ProspectivePartner', on_delete=models.CASCADE) + title = models.CharField(max_length=4, choices=TITLE_CHOICES) + first_name = models.CharField(max_length=64) + last_name = models.CharField(max_length=64) + email = models.EmailField() + role = models.CharField(max_length=128) + + def __str__(self): + return "%s %s %s" % (self.get_title_display(), self.first_name, self.last_name) + + +class ProspectivePartnerEvent(models.Model): + prospartner = models.ForeignKey('partners.ProspectivePartner', on_delete=models.CASCADE) + event = models.CharField(max_length=64, choices=PROSPECTIVE_PARTNER_EVENTS) + comments = models.TextField(blank=True) + noted_on = models.DateTimeField(auto_now_add=True) + noted_by = models.ForeignKey('scipost.Contributor', + on_delete=models.SET(get_sentinel_user), + blank=True, null=True) + + def __str__(self): + return '%s: %s' % (str(self.prospective_partner), self.get_event_display()) + + +########################### +# Partner-related objects # +########################### + +class Institution(models.Model): + """ + An Institution is any form of academic organization which SciPost interacts with. """ - A ContactPerson is a simple form of User which is meant + kind = models.CharField(max_length=32, choices=PARTNER_KINDS) + name = models.CharField(max_length=256) + acronym = models.CharField(max_length=16) + address = models.CharField(max_length=1000, blank=True) + country = CountryField() + + def __str__(self): + return '%s (%s)' % (self.name, self.get_kind_display()) + + +class Contact(models.Model): + """ + A Contact is a simple form of User which is meant to be associated to Partner objects (main contact, financial/technical contact etc). - ContactPersons and Contributors have different rights. + Contacts and Contributors have different rights. """ user = models.OneToOneField(User, on_delete=models.CASCADE, unique=True) + kind = models.CharField(max_length=128) title = models.CharField(max_length=4, choices=TITLE_CHOICES) def __str__(self): @@ -29,24 +124,34 @@ class Partner(models.Model): Supporting Partners. These are the official Partner objects created by SciPost Admin. """ - partner_type = models.CharField(max_length=32, choices=PARTNER_TYPES) + institution = models.ForeignKey('partners.Institution', on_delete=models.CASCADE, + blank=True, null=True) status = models.CharField(max_length=16, choices=PARTNER_STATUS) - institution_name = models.CharField(max_length=256) - institution_acronym = models.CharField(max_length=10) - institution_address = models.CharField(max_length=1000, blank=True, null=True) - country = CountryField() - main_contact = models.ForeignKey(ContactPerson, on_delete=models.CASCADE, + main_contact = models.ForeignKey('partners.Contact', on_delete=models.CASCADE, blank=True, null=True, related_name='partner_main_contact') - financial_contact = models.ForeignKey(ContactPerson, on_delete=models.CASCADE, + financial_contact = models.ForeignKey('partners.Contact', on_delete=models.CASCADE, blank=True, null=True, related_name='partner_financial_contact') - technical_contact = models.ForeignKey(ContactPerson, on_delete=models.CASCADE, + technical_contact = models.ForeignKey('partners.Contact', on_delete=models.CASCADE, blank=True, null=True, related_name='partner_technical_contact') def __str__(self): - return self.institution_acronym + ' (' + self.get_status_display() + ')' + if self.institution: + return self.institution.acronym + ' (' + self.get_status_display() + ')' + return self.get_status_display() + + +class PartnerEvent(models.Model): + partner = models.ForeignKey('partners.Partner', on_delete=models.CASCADE) + event = models.CharField(max_length=64, choices=PARTNER_EVENTS) + comments = models.TextField(blank=True) + noted_on = models.DateTimeField(auto_now_add=True) + noted_by = models.ForeignKey('scipost.Contributor', on_delete=models.CASCADE) + + def __str__(self): + return '%s: %s' % (str(self.partner), self.get_event_display()) class Consortium(models.Model): @@ -54,51 +159,30 @@ class Consortium(models.Model): Collection of Partners. """ name = models.CharField(max_length=128) - partners = models.ManyToManyField(Partner, blank=True) + partners = models.ManyToManyField('partners.Partner', blank=True) status = models.CharField(max_length=16, choices=CONSORTIUM_STATUS) class Meta: verbose_name_plural = 'consortia' -class ProspectivePartner(models.Model): - """ - Created from the membership_request page, after submitting a query form. - """ - title = models.CharField(max_length=4, choices=TITLE_CHOICES) - first_name = models.CharField(max_length=32) - last_name = models.CharField(max_length=32) - email = models.EmailField() - role = models.CharField(max_length=128) - partner_type = models.CharField(max_length=32, choices=PARTNER_TYPES) - institution_name = models.CharField(max_length=256) - country = CountryField() - date_received = models.DateTimeField(default=timezone.now) - date_processed = models.DateTimeField(blank=True, null=True) - processed = models.BooleanField(default=False) - - def __str__(self): - resp = "processed" - if not self.processed: - resp = "unprocessed" - return '%s (received %s), %s' % (self.institution_name, - self.date_received.strftime("%Y-%m-%d"), - resp) - - class MembershipAgreement(models.Model): """ Agreement for membership of the Supporting Partners Board. A new instance is created each time an Agreement is made or renewed. """ - partner = models.ForeignKey(Partner, on_delete=models.CASCADE, blank=True, null=True) - consortium = models.ForeignKey(Consortium, on_delete=models.CASCADE, blank=True, null=True) + partner = models.ForeignKey('partners.Partner', on_delete=models.CASCADE, + blank=True, null=True) + consortium = models.ForeignKey('partners.Consortium', on_delete=models.CASCADE, + blank=True, null=True) status = models.CharField(max_length=16, choices=MEMBERSHIP_AGREEMENT_STATUS) date_requested = models.DateField() start_date = models.DateField() duration = models.DurationField(choices=MEMBERSHIP_DURATION) offered_yearly_contribution = models.SmallIntegerField(default=0) + objects = MembershipAgreementManager() + def __str__(self): return (str(self.partner) + ' [' + self.get_duration_display() + diff --git a/partners/templates/partners/_partner_card.html b/partners/templates/partners/_partner_card.html index 31175dccfad720d674489d66f3165abe4803c9af..0a5e7df5986d015e49134ef685943f99ef01ffee 100644 --- a/partners/templates/partners/_partner_card.html +++ b/partners/templates/partners/_partner_card.html @@ -3,12 +3,12 @@ <div class="card-block"> <div class="row"> <div class="col-1"> - <p>{{ partner.country }}</p> + <p>{{ partner.institution.country }}</p> </div> <div class="col-4"> - <h3>{{ partner.institution_name }}</h3> - <p>{{ partner.institution_acronym }}</p> - <p>({{ pp.get_partner_type_display }})</p> + <h3>{{ partner.institution.name }}</h3> + <p>{{ partner.institution.acronym }}</p> + <p>({{ pp.get_kind_display }})</p> </div> <div class="col-4"> {% if partner.main_contact %} diff --git a/partners/templates/partners/_prospartner_event_li.html b/partners/templates/partners/_prospartner_event_li.html new file mode 100644 index 0000000000000000000000000000000000000000..ec28cba2e3ed16d6ed1d1c0e91bc52acb0fef019 --- /dev/null +++ b/partners/templates/partners/_prospartner_event_li.html @@ -0,0 +1,7 @@ +<li id="{{ event.id }}"> + <div class="font-weight-bold">{{ event.get_event_display }} <small class="text-muted">noted {{ event.noted_on }} {% if event.noted_by %}by {{ event.noted_by }}{% endif %}</small> + </div> + {% if event.comments %} + <div>{{ event.comments|linebreaks }}</div> + {% endif %} +</li> diff --git a/partners/templates/partners/_prospective_partner_card.html b/partners/templates/partners/_prospective_partner_card.html index fe2624c247b05a878622743b0c213a80467220cc..5496f483c4504fa5c1916c9abef54c4e34111f8b 100644 --- a/partners/templates/partners/_prospective_partner_card.html +++ b/partners/templates/partners/_prospective_partner_card.html @@ -2,21 +2,65 @@ <div class="card-block"> <div class="row"> - <div class="col-1"> + <div class="col-md-1"> <p>{{ pp.country }}</p> </div> - <div class="col-4"> + <div class="col-md-4"> <h3>{{ pp.institution_name }}</h3> - <p>({{ pp.get_partner_type_display }})</p> + <p>({{ pp.get_kind_display }})</p> <p>Received {{ pp.date_received }}</p> + {% if pp.date_processed %} + <p>Processed {{ pp.date_processed }}</p> + {% endif %} + <p>{{ pp.get_status_display }}</p> </div> - <div class="col-4"> - <p>Contact: {{ pp.get_title_display }} {{ pp.first_name }} {{ pp.last_name }}</p> - <p>(role: {{ pp.role }})</p> - <p>{{ pp.email }}</p> + <div class="col-md-7"> + <h3>Contacts:</h3> + <a class="d-inline-block mb-2" href="{% url 'partners:add_prospartner_contact' prospartner_id=pp.id %}">Add a contact</a> + <table class="table"> + <thead> + <th>Role</th> + <th>Name</th> + <th>Email</th> + <th>Actions</th> + </thead> + <tbody> + {% for contact in pp.prospectivecontact_set.all %} + <tr> + <td>{{ contact.role }}</td> + <td>{{ contact.get_title_display }} {{ contact.first_name }} {{ contact.last_name }}</td> + <td>{{ contact.email }}</td> + <td><a href="{% url 'partners:email_prospartner_contact' contact_id=contact.id %}">Compose email</a></td> + </tr> + {% empty %} + <tr> + <td colspan="3">No contacts found, <a href="{% url 'partners:add_prospartner_contact' prospartner_id=pp.id %}">add a contact</a>.</td> + </tr> + {% endfor %} + </tbody> + </table> </div> - <div class="col-3"> - <p>Edit</p> + </div> + + <div class="row"> + <div class="col-1"></div> + <div class="col-6"> + <h3>Events</h3> + <ul> + {% for event in pp.prospectivepartnerevent_set.all %} + {% include 'partners/_prospartner_event_li.html' with event=event %} + {% empty %} + <li>No events were found.</li> + {% endfor %} + </ul> + </div> + <div class="col-5"> + <h3>Add an event for this Prospective Partner</h3> + <form action="{% url 'partners:add_prospartner_event' prospartner_id=pp.id %}" method="post"> + {% csrf_token %} + {{ ppevent_form|bootstrap }} + <input type="submit" name="submit" value="Submit" class="btn btn-secondary"> + </form> </div> </div> </div> diff --git a/partners/templates/partners/add_prospartner_contact.html b/partners/templates/partners/add_prospartner_contact.html new file mode 100644 index 0000000000000000000000000000000000000000..d0f72fcd09f2a36e5fe08711de296e60c9d5a98b --- /dev/null +++ b/partners/templates/partners/add_prospartner_contact.html @@ -0,0 +1,31 @@ +{% extends 'scipost/base.html' %} + +{% block pagetitle %}: Supporting Partners: add contact{% endblock pagetitle %} + +{% load bootstrap %} + +{% block content %} + +<div class="row"> + <div class="col-12"> + <h1 class="highlight">Add a Contact for a Prospective Partner</h1> + </div> +</div> + +<div class="row"> + <div class="col-12"> + <h3>Add a contact for {{ prospartner.institution_name }}:</h3> + + <form action="{% url 'partners:add_prospartner_contact' prospartner_id=prospartner.id %}" method="post"> + {% csrf_token %} + {{ form|bootstrap }} + <input class="btn btn-primary" type="submit" value="Submit"/> + </form> + + {% if errormessage %} + <p class="text-danger">{{ errormessage }}</p> + {% endif %} + </div> +</div> + +{% endblock content %} diff --git a/partners/templates/partners/add_prospective_partner.html b/partners/templates/partners/add_prospective_partner.html index 737780443f5c8da12d23699d2845e666fbf1ce14..8475f83747ab690382c23741fa560f66f28e0986 100644 --- a/partners/templates/partners/add_prospective_partner.html +++ b/partners/templates/partners/add_prospective_partner.html @@ -6,13 +6,15 @@ {% block content %} -<section> - <div class="flex-container"> - <div class="flex-greybox"> - <h1>Add a Prospective Partner</h1> +<div class="row"> + <div class="col-12"> + <h1 class="highlight">Add a Prospective Partner</h1> </div> - </div> - <p>Please provide contact details of an appropriate representative, and details about the potential Partner.</p> +</div> + +<div class="row"> + <div class="col-12"> + <p>Please provide contact details of an appropriate representative, and details about the potential Partner.</p> <form action="{% url 'partners:add_prospective_partner' %}" method="post"> {% csrf_token %} @@ -21,9 +23,10 @@ </form> {% if errormessage %} - <p class="text-danger">{{ errormessage }}</p> + <p class="text-danger">{{ errormessage }}</p> {% endif %} -</section> + </div> +</div> {% endblock content %} diff --git a/partners/templates/partners/email_prospartner_contact.html b/partners/templates/partners/email_prospartner_contact.html new file mode 100644 index 0000000000000000000000000000000000000000..f32ea6ca65401d1c4db8458cf6ee21605f49abb2 --- /dev/null +++ b/partners/templates/partners/email_prospartner_contact.html @@ -0,0 +1,29 @@ +{% extends 'scipost/base.html' %} + +{% block pagetitle %}: Supporting Partners: email contact{% endblock pagetitle %} + +{% load bootstrap %} + +{% block content %} + +<div class="row"> + <div class="col-12"> + <h1 class="highlight">Email a Prospective Partner Contact</h1> + </div> +</div> + +<div class="row"> + <div class="col-12"> + <form action="{% url 'partners:email_prospartner_contact' contact_id=contact.id %}" method="post"> + {% csrf_token %} + {{ form|bootstrap }} + <input class="btn btn-primary" type="submit" value="Submit"/> + </form> + + {% if errormessage %} + <p class="text-danger">{{ errormessage }}</p> + {% endif %} + </div> +</div> + +{% endblock content %} diff --git a/partners/templates/partners/manage_partners.html b/partners/templates/partners/manage_partners.html index 95fb172639561b9c03f3d03c7a97711b0ccdf18a..903a78b0501082011c6ce97c8d598eacdabbab64 100644 --- a/partners/templates/partners/manage_partners.html +++ b/partners/templates/partners/manage_partners.html @@ -1,55 +1,104 @@ {% extends 'scipost/base.html' %} +{% load partners_extras %} + {% block pagetitle %}: Supporting Partners: manage{% endblock pagetitle %} {% block content %} -<div class="flex-container"> - <div class="flex-greybox"> - <h1>Partners Management Page</h1> - </div> +<div class="row"> + <div class="col-12"> + <h1 class="highlight">Partners Management Page</h1> + </div> </div> -<section> - <div class="flex-container"> - <div class="flex-greybox"> - <h2>Partners</h2> + +<div class="row"> + <div class="col-12"> + <div class="tab-nav-container"> + <div class="tab-nav-inner"> + <ul class="nav btn-group personal-page-nav" role="tablist"> + <li class="nav-item btn btn-secondary"> + <a href="#prospartners" class="nav-link active" data-toggle="tab">Prospective Partners</a> + </li> + <li class="nav-item btn btn-secondary"> + <a href="#partners" class="nav-link" data-toggle="tab">Partners</a> + </li> + <li class="nav-item btn btn-secondary"> + <a href="#agreements" class="nav-link" data-toggle="tab">Agreements</a> + </li> + </ul> + </div> + </div> </div> - </div> - <ul class="list-group list-group-flush"> - {% for partner in partners %} - <li class="list-group-item">{% include 'partners/_partner_card.html' with partner=partner %}</li> - {% endfor %} - </ul> -</section> - -<section> - <div class="flex-container"> - <div class="flex-greybox"> - <h2>Prospective Partners (not yet processed)</h2> +</div> + + <div class="tab-content"> + <div class="tab-pane active" id="prospartners" role="tabpanel"> + <div class="row"> + <div class="col-12"> + <h2 class="highlight">Prospective Partners</h2> + </div> + </div> + <h3><a href="{% url 'partners:add_prospective_partner' %}">Add a prospective partner</a></h3> + <br/> + + <table class="table table-hover"> + <thead class="thead-default"> + <tr> + <th>Country</th> + <th>Institution name</th> + <th>Kind</th> + <th>Status</th> + <th>Date received</th> + </tr> + </thead> + + <tbody id="accordion" role="tablist" aria-multiselectable="true"> + {% for partner in prospective_partners %} + <tr data-toggle="collapse" data-parent="#accordion" href="#collapse{{ partner.id }}" aria-expanded="true" aria-controls="collapse{{ partner.id }}" style="cursor: pointer;"> + <td>{{ partner.get_country_display }}</td> + <td>{{ partner.institution_name }}</td> + <td>{{ partner.get_kind_display }}</td> + <td style="background-color:{{ partner.status|partnerstatuscolor }}">{{ partner.get_status_display }}</td> + <td>{{ partner.date_received|date:"Y-m-d" }}</td> + </tr> + <tr id="collapse{{ partner.id }}" class="collapse" role="tabpanel" aria-labelledby="heading{{ partner.id}}" style="background-color: #fff;"> + <td colspan="5"> + {% include 'partners/_prospective_partner_card.html' with pp=partner %} + </td> + </tr> + {% endfor %} + </tbody> + </table> </div> - </div> - <h3><a href="{% url 'partners:add_prospective_partner' %}">Add a prospective partner</a></h3> - <br/> - <ul class="list-group list-group-flush"> - {% for partner in prospective_partners %} - <li class="list-group-item">{% include 'partners/_prospective_partner_card.html' with pp=partner %}</li> - {% endfor %} - </ul> -</section> - -<section> - <div class="flex-container"> - <div class="flex-greybox"> - <h2>Agreements</h2> + + <div class="tab-pane" id="partners" role="tabpanel"> + <div class="row"> + <div class="col-12"> + <h2 class="highlight">Partners</h2> + </div> + </div> + <ul class="list-group list-group-flush"> + {% for partner in partners %} + <li class="list-group-item">{% include 'partners/_partner_card.html' with partner=partner %}</li> + {% endfor %} + </ul> + </div> + + <div class="tab-pane" id="agreements" role="tabpanel"> + <div class="row"> + <div class="col-12"> + <h2 class="highlight">Agreements</h2> + </div> + </div> + <ul> + {% for agreement in agreements %} + <li>{{ agreement }}</li> + {% endfor %} + </ul> </div> </div> - <ul> - {% for agreement in agreements %} - <li>{{ agreement }}</li> - {% endfor %} - </ul> -</section> {% endblock content %} diff --git a/partners/templates/partners/supporting_partners.html b/partners/templates/partners/supporting_partners.html index eb9090b0e15fd8dc913865bad540cae996dc77f2..e7ee9a584c71293a392f65cd3249abaad3bb7bc1 100644 --- a/partners/templates/partners/supporting_partners.html +++ b/partners/templates/partners/supporting_partners.html @@ -107,9 +107,9 @@ <h3>Activation procedure</h3> <p>In order to become a Supporting Partner, one must: <ul> - <li>Fill in the online <a href="{% url 'partners:membership_request' %}">membership request form</a> (the form must be filled in by an authorized agent employed by or associated to the prospective Partner; personal contact details of this person will be treated confidentially).</li> - <li>Wait for the email response from the SciPost administration, containing a Partnership Agreement offer including detailed terms (start date, duration, financial contribution).</li> - <li>Email a scan of the signed copy of the Partnership Agreement to SciPost.</li> + <li>Fill in the online <a href="{% url 'partners:membership_request' %}">membership request form</a> (the form must be filled in by an employee or associate of the prospective Partner acting as an authorized agent for the latter; personal contact details of this person will be treated confidentially).</li> + <li>Wait for the email response from the SciPost administration, containing a Partnership Agreement offer including suggested detailed terms (start date, duration, financial contribution).</li> + <li>After all detailed terms have been agreed upon, email a scan of the signed copy of the Partnership Agreement to SciPost.</li> <li>Proceed with the payment of the financial contribution, following invoicing from the SciPost Foundation.</li> </ul> </p> @@ -118,20 +118,19 @@ </div> -{% if request.user|is_in_group:'Editorial Administrators' %} +{% if perms.scipost.can_manage_SPB %} +{% if prospective_partners %} <div class="row"> <div class="col-12"> - <h1 class="highglight">Prospective Partners</h1> - <ul> - {% for agreement in prospective_agreements %} - <li>{{ agreement }}</li> - {% empty %} - <li>No agreements found</li> - {% endfor %} - </ul> + <h1 class="highlight">Prospective Partners</h1> + <ul> + {% for agreement in prospective_agreements %} + <li>{{ agreement }}</li> + {% endfor %} + </ul> </div> </div> - +{% endif %} {% endif %} {% endblock %} diff --git a/partners/templatetags/partners_extras.py b/partners/templatetags/partners_extras.py new file mode 100644 index 0000000000000000000000000000000000000000..6ad6a981314dbac1435dddc2f84adf951d0b9093 --- /dev/null +++ b/partners/templatetags/partners_extras.py @@ -0,0 +1,28 @@ +from django import template + +from ..constants import PROSPECTIVE_PARTNER_REQUESTED,\ + PROSPECTIVE_PARTNER_ADDED,\ + PROSPECTIVE_PARTNER_APPROACHED,\ + PROSPECTIVE_PARTNER_NEGOTIATING,\ + PROSPECTIVE_PARTNER_UNINTERESTED,\ + PROSPECTIVE_PARTNER_PROCESSED + +register = template.Library() + + +@register.filter(name='partnerstatuscolor') +def partnerstatuscolor(status): + color = '#333333' + if status == PROSPECTIVE_PARTNER_REQUESTED: + color = '#3399ff' + elif status == PROSPECTIVE_PARTNER_ADDED: + color = '#6699cc' + elif status == PROSPECTIVE_PARTNER_APPROACHED: + color = '#ffcc33' + elif status == PROSPECTIVE_PARTNER_NEGOTIATING: + color = '#ff8c00' + elif status == PROSPECTIVE_PARTNER_UNINTERESTED: + color = '#ee0000' + elif status == PROSPECTIVE_PARTNER_PROCESSED: + color = '#32cd32' + return color diff --git a/partners/urls.py b/partners/urls.py index 5558aaaed1659597f7511d16fc50b80acc947868..ed50ba7595ef25515bfe6da02467211056449627 100644 --- a/partners/urls.py +++ b/partners/urls.py @@ -3,12 +3,15 @@ from django.conf.urls import url from . import views urlpatterns = [ - - url(r'^$', views.supporting_partners, - name='partners'), - url(r'^membership_request$', views.membership_request, - name='membership_request'), + url(r'^$', views.supporting_partners, name='partners'), + url(r'^membership_request$', views.membership_request, name='membership_request'), url(r'^manage$', views.manage, name='manage'), url(r'^add_prospective_partner$', views.add_prospective_partner, name='add_prospective_partner'), + url(r'^add_prospartner_contact/(?P<prospartner_id>[0-9]+)$', + views.add_prospartner_contact, name='add_prospartner_contact'), + url(r'^email_prospartner_contact/(?P<contact_id>[0-9]+)$', + views.email_prospartner_contact, name='email_prospartner_contact'), + url(r'^add_prospartner_event/(?P<prospartner_id>[0-9]+)$', + views.add_prospartner_event, name='add_prospartner_event'), ] diff --git a/partners/utils.py b/partners/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..fc40882c002bf695b7e219a08a302faf44e966f0 --- /dev/null +++ b/partners/utils.py @@ -0,0 +1,18 @@ +from common.utils import BaseMailUtil + + + +class PartnerUtils(BaseMailUtil): + mail_sender = 'partners@scipost.org' + mail_sender_title = 'SciPost Supporting Partners' + + @classmethod + def email_prospartner_contact(cls): + """ + Email a contact of a ProspectivePartner, + for example to establish a first contact + and invite participation to the Supporting Partners Board. + """ + cls._send_mail(cls, 'email_prospartner_contact', + [cls._context['contact'].email,], + cls._context['email_subject']) diff --git a/partners/views.py b/partners/views.py index 7473a5352a699f7b2c940ad97dcffaab3554fd68..111dda8d8190b60812b4991328984d0519ee8984 100644 --- a/partners/views.py +++ b/partners/views.py @@ -1,38 +1,59 @@ from django.contrib import messages -from django.shortcuts import render, reverse, redirect +from django.db import transaction +from django.shortcuts import get_object_or_404, render, reverse, redirect from django.utils import timezone from guardian.decorators import permission_required -from .models import Partner, ProspectivePartner, MembershipAgreement -from .forms import ProspectivePartnerForm, MembershipQueryForm +from .constants import PROSPECTIVE_PARTNER_REQUESTED, PROSPECTIVE_PARTNER_ADDED,\ + PROSPECTIVE_PARTNER_APPROACHED, PROSPECTIVE_PARTNER_ADDED,\ + PROSPECTIVE_PARTNER_EVENT_REQUESTED, PROSPECTIVE_PARTNER_EVENT_EMAIL_SENT +from .models import Partner, ProspectivePartner, ProspectiveContact,\ + ProspectivePartnerEvent, MembershipAgreement +from .forms import ProspectivePartnerForm, ProspectiveContactForm,\ + EmailProspectivePartnerContactForm,\ + ProspectivePartnerEventForm, MembershipQueryForm +from common.utils import BaseMailUtil +from .utils import PartnerUtils def supporting_partners(request): - prospective_agreements = MembershipAgreement.objects.filter( - status='Submitted').order_by('date_requested') - context = {'prospective_partners': prospective_agreements, } + context = {} + if request.user.groups.filter(name='Editorial Administrators').exists(): + # Show Agreements to Administrators only! + prospective_agreements = MembershipAgreement.objects.submitted().order_by('date_requested') + context['prospective_partners'] = prospective_agreements return render(request, 'partners/supporting_partners.html', context) +@transaction.atomic def membership_request(request): query_form = MembershipQueryForm(request.POST or None) if query_form.is_valid(): - query = ProspectivePartner( + prospartner = ProspectivePartner( + kind=query_form.cleaned_data['partner_kind'], + institution_name=query_form.cleaned_data['institution_name'], + country=query_form.cleaned_data['country'], + date_received=timezone.now(), + status=PROSPECTIVE_PARTNER_REQUESTED, + ) + prospartner.save() + contact = ProspectiveContact( + prospartner=prospartner, title=query_form.cleaned_data['title'], first_name=query_form.cleaned_data['first_name'], last_name=query_form.cleaned_data['last_name'], email=query_form.cleaned_data['email'], - partner_type=query_form.cleaned_data['partner_type'], - institution_name=query_form.cleaned_data['institution_hame'], - country=query_form.cleaned_data['country'], - date_received=timezone.now(), ) - query.save() + contact.save() + prospartnerevent = ProspectivePartnerEvent( + prospartner = prospartner, + event = PROSPECTIVE_PARTNER_EVENT_REQUESTED,) + prospartnerevent.save() ack_message = ('Thank you for your SPB Membership query. ' 'We will get back to you in the very near future ' 'with further details.') - context = {'ack_message': ack_message, } + context = {'ack_message': ack_message} return render(request, 'scipost/acknowledgement.html', context) context = {'query_form': query_form} return render(request, 'partners/membership_request.html', context) @@ -43,12 +64,13 @@ def manage(request): """ Lists relevant info regarding management of Supporting Partners Board. """ - partners = Partner.objects.all().order_by('country', 'institution_name') - prospective_partners = ProspectivePartner.objects.filter( - processed=False).order_by('date_received') - agreements = MembershipAgreement.objects.all().order_by('date_requested') + partners = Partner.objects.all() + prospective_partners = ProspectivePartner.objects.order_by('country', 'institution_name') + ppevent_form = ProspectivePartnerEventForm() + agreements = MembershipAgreement.objects.order_by('date_requested') context = {'partners': partners, 'prospective_partners': prospective_partners, + 'ppevent_form': ppevent_form, 'agreements': agreements, } return render(request, 'partners/manage_partners.html', context) @@ -57,8 +79,73 @@ def manage(request): def add_prospective_partner(request): form = ProspectivePartnerForm(request.POST or None) if form.is_valid(): - form.save() - messages.success(request, 'Prospective Partners successfully added') - return redirect(reverse('partners:manage')) + pp = form.save() + messages.success(request, 'Prospective Partner successfully added') + return redirect(reverse('partners:add_prospartner_contact', + kwargs={'prospartner_id': pp.id})) context = {'form': form} return render(request, 'partners/add_prospective_partner.html', context) + + +@permission_required('scipost.can_manage_SPB', return_403=True) +def add_prospartner_contact(request, prospartner_id): + prospartner = get_object_or_404(ProspectivePartner, pk=prospartner_id) + form = ProspectiveContactForm(request.POST or None, initial={'prospartner': prospartner}) + if form.is_valid(): + form.save() + messages.success(request, 'Contact successfully added to Prospective Partner') + return redirect(reverse('partners:manage')) + context = {'form': form, 'prospartner': prospartner} + return render(request, 'partners/add_prospartner_contact.html', context) + + +@permission_required('scipost.can_email_prospartner_contact', return_403=True) +@transaction.atomic +def email_prospartner_contact(request, contact_id): + contact = get_object_or_404(ProspectiveContact, pk=contact_id) + form = EmailProspectivePartnerContactForm(request.POST or None) + if form.is_valid(): + comments = 'Email sent to %s.' % str(contact) + prospartnerevent = ProspectivePartnerEvent( + prospartner = contact.prospartner, + event = PROSPECTIVE_PARTNER_EVENT_EMAIL_SENT, + comments = comments, + noted_on = timezone.now(), + noted_by = request.user.contributor) + prospartnerevent.save() + if contact.prospartner.status in [PROSPECTIVE_PARTNER_REQUESTED, + PROSPECTIVE_PARTNER_ADDED]: + contact.prospartner.status = PROSPECTIVE_PARTNER_APPROACHED + contact.prospartner.save() + PartnerUtils.load({'contact': contact, + 'email_subject': form.cleaned_data['email_subject'], + 'message': form.cleaned_data['message'], + 'include_SPB_summary': form.cleaned_data['include_SPB_summary']}) + + PartnerUtils.email_prospartner_contact() + messages.success(request, 'Email successfully sent') + return redirect(reverse('partners:manage')) + context = {'contact': contact, 'form': form} + return render(request, 'partners/email_prospartner_contact.html', context) + + + +@permission_required('scipost.can_manage_SPB', return_403=True) +@transaction.atomic +def add_prospartner_event(request, prospartner_id): + prospartner = get_object_or_404(ProspectivePartner, pk=prospartner_id) + if request.method == 'POST': + ppevent_form = ProspectivePartnerEventForm(request.POST) + if ppevent_form.is_valid(): + ppevent = ppevent_form.save(commit=False) + ppevent.prospartner = prospartner + ppevent.noted_by = request.user.contributor + ppevent.save() + prospartner.update_status_from_event(ppevent.event) + prospartner.save() + return redirect(reverse('partners:manage')) + else: + errormessage = 'The form was invalidly filled.' + return render(request, 'scipost/error.html', {'errormessage': errormessage}) + errormessage = 'This view can only be posted to.' + return render(request, 'scipost/error.html', {'errormessage': errormessage}) diff --git a/production/admin.py b/production/admin.py index 41ae12e06e49ec6a43fc034ed520b5708e3b2ff0..64b634398caf4f31004b7488a98a5615c673a8a9 100644 --- a/production/admin.py +++ b/production/admin.py @@ -1,7 +1,39 @@ from django.contrib import admin +from django import forms + from .models import ProductionStream, ProductionEvent +from submissions.models import Submission + + +class ProductionStreamAdminForm(forms.ModelForm): + submission = forms.ModelChoiceField( + queryset=Submission.objects.order_by('-arxiv_identifier_w_vn_nr')) + + class Meta: + model = ProductionStream + fields = '__all__' + +class ProductionStreamAdmin(admin.ModelAdmin): + search_fields = ['submission'] + list_display = ['submission', 'opened', 'status'] + form = ProductionStreamAdminForm + +admin.site.register(ProductionStream, ProductionStreamAdmin) + + +class ProductionEventAdminForm(forms.ModelForm): + stream = forms.ModelChoiceField( + queryset=ProductionStream.objects.order_by('-submission.arxiv_identifier_w_vn_nr')) + + class Meta: + model = ProductionEvent + fields = '__all__' + +class ProductionEventAdmin(admin.ModelAdmin): + search_field = ['stream', 'event', 'comment', 'noted_by'] + list_display = ['stream', 'event', 'noted_on', 'noted_by'] + form = ProductionEventAdminForm -admin.site.register(ProductionStream) -admin.site.register(ProductionEvent) +admin.site.register(ProductionEvent, ProductionEventAdmin) diff --git a/production/constants.py b/production/constants.py index 0a0b7ec8a445d3f04da78430cf52f28a55930147..ce9caee48c34a9a78db9235df621d04550b13e91 100644 --- a/production/constants.py +++ b/production/constants.py @@ -17,4 +17,5 @@ PRODUCTION_EVENTS = ( ('proofs_returned_by_authors', 'Proofs returned by Authors'), ('corrections_implemented', 'Corrections implemented'), ('authors_have_accepted_proofs', 'Authors have accepted proofs'), + ('paper_published', 'Paper has been published'), ) diff --git a/production/migrations/0004_auto_20170528_1526.py b/production/migrations/0004_auto_20170528_1526.py new file mode 100644 index 0000000000000000000000000000000000000000..c901494033b5772f07bfead5252165f5974d65fb --- /dev/null +++ b/production/migrations/0004_auto_20170528_1526.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-05-28 13:26 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('production', '0003_auto_20170522_1021'), + ] + + operations = [ + migrations.AlterField( + model_name='productionevent', + name='event', + field=models.CharField(choices=[('assigned_to_supervisor', 'Assigned by EdAdmin to Supervisor'), ('message_edadmin_to_supervisor', 'Message from EdAdmin to Supervisor'), ('message_supervisor_to_edadmin', 'Message from Supervisor to EdAdmin'), ('officer_tasked_with_proof_production', 'Supervisor tasked officer with proofs production'), ('message_supervisor_to_officer', 'Message from Supervisor to Officer'), ('message_officer_to_supervisor', 'Message from Officer to Supervisor'), ('proofs_produced', 'Proofs have been produced'), ('proofs_checked_by_supervisor', 'Proofs have been checked by Supervisor'), ('proofs_sent_to_authors', 'Proofs sent to Authors'), ('proofs_returned_by_authors', 'Proofs returned by Authors'), ('corrections_implemented', 'Corrections implemented'), ('authors_have_accepted_proofs', 'Authors have accepted proofs'), ('paper_published', 'Paper has been published')], max_length=64), + ), + ] diff --git a/production/migrations/0005_productionstream_status.py b/production/migrations/0005_productionstream_status.py new file mode 100644 index 0000000000000000000000000000000000000000..81236c4f5629b772eab6d7018fb29ccb699d4aca --- /dev/null +++ b/production/migrations/0005_productionstream_status.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-05-28 13:33 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('production', '0004_auto_20170528_1526'), + ] + + operations = [ + migrations.AddField( + model_name='productionstream', + name='status', + field=models.CharField(choices=[('ongoing', 'Ongoing'), ('completed', 'Completed')], default='ongoing', max_length=32), + ), + ] diff --git a/production/migrations/0006_productionstream_closed.py b/production/migrations/0006_productionstream_closed.py new file mode 100644 index 0000000000000000000000000000000000000000..214570de6029880b899ad87f70c15b062fab2514 --- /dev/null +++ b/production/migrations/0006_productionstream_closed.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-05-28 13:41 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('production', '0005_productionstream_status'), + ] + + operations = [ + migrations.AddField( + model_name='productionstream', + name='closed', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/production/models.py b/production/models.py index c2cb30209aa2153f2c6b3e2365ce3e1dd6fd2b95..8cd82781cdff1ae47ddb738ef3ecebe2aa3d6eef 100644 --- a/production/models.py +++ b/production/models.py @@ -1,7 +1,7 @@ from django.db import models from django.utils import timezone -from .constants import PRODUCTION_EVENTS +from .constants import PRODUCTION_STREAM_STATUS, PRODUCTION_EVENTS from scipost.models import Contributor @@ -13,6 +13,9 @@ from scipost.models import Contributor class ProductionStream(models.Model): submission = models.OneToOneField('submissions.Submission', on_delete=models.CASCADE) opened = models.DateTimeField() + closed = models.DateTimeField(default=timezone.now) + status = models.CharField(max_length=32, + choices=PRODUCTION_STREAM_STATUS, default='ongoing') def __str__(self): return str(self.submission) diff --git a/production/templates/production/_production_stream_card.html b/production/templates/production/_production_stream_card.html index fda086794d4c0faa5efcdf4bbc80d314b7dc2a26..c39436c5c30b365de02d699b205fcafdc0d38806 100644 --- a/production/templates/production/_production_stream_card.html +++ b/production/templates/production/_production_stream_card.html @@ -17,7 +17,11 @@ {% if stream.total_duration %} <h3>Total duration for this stream: {{ stream.total_duration|duration }}</h3> {% endif %} + {% if perms.scipost.can_publish_accepted_submission %} + <h3><a href="{% url 'production:mark_as_completed' stream_id=stream.id %}">Mark this stream as completed</a></h3> + {% endif %} </div> + {% if form %} <div class="col-5"> <h3>Add an event to this production stream:</h3> <form action="{% url 'production:add_event' stream_id=stream.id %}" method="post"> @@ -26,4 +30,6 @@ <input type="submit" name="submit" value="Submit"> </form> </div> + {% endif %} + </div> </div> diff --git a/production/templates/production/completed.html b/production/templates/production/completed.html new file mode 100644 index 0000000000000000000000000000000000000000..1cd7059cd206c97570a179c2e4c623619db68dd4 --- /dev/null +++ b/production/templates/production/completed.html @@ -0,0 +1,21 @@ +{% extends 'scipost/base.html' %} + +{% load bootstrap %} + +{% block content %} + +<div class="row"> + <div class="col-12"> + <h3>Completed production streams</h3> + <ul class="list-group list-group-flush"> + {% for stream in streams %} + <li class="list-group-item"> + {% include 'production/_production_stream_card.html' with stream=stream %} + </li> + <hr/> + {% endfor %} + </ul> + </div> +</div> + +{% endblock content %} diff --git a/production/urls.py b/production/urls.py index 11007074f3a47e420c14b81f62009486dab8f23c..3ff66bdb86cc57bdd8a1bf8a79cf9457a0a60f14 100644 --- a/production/urls.py +++ b/production/urls.py @@ -4,6 +4,9 @@ from production import views as production_views urlpatterns = [ url(r'^$', production_views.production, name='production'), + url(r'^completed$', production_views.completed, name='completed'), url(r'^add_event/(?P<stream_id>[0-9]+)$', production_views.add_event, name='add_event'), + url(r'^mark_as_completed/(?P<stream_id>[0-9]+)$', + production_views.mark_as_completed, name='mark_as_completed'), ] diff --git a/production/views.py b/production/views.py index 4093d7751d30398ce87f78f36b3482b1b5dae76f..52363947aa75203c2957cd7a5a1cb920553275ad 100644 --- a/production/views.py +++ b/production/views.py @@ -21,18 +21,25 @@ def production(request): Overview page for the production process. All papers with accepted but not yet published status are included here. """ - accepted_submissions = Submission.objects.filter( - status='accepted').order_by('latest_activity') - streams = ProductionStream.objects.all().order_by('opened') + streams = ProductionStream.objects.filter(status='ongoing').order_by('opened') prodevent_form = ProductionEventForm() context = { - 'accepted_submissions': accepted_submissions, 'streams': streams, 'prodevent_form': prodevent_form, } return render(request, 'production/production.html', context) +@permission_required('scipost.can_view_production', return_403=True) +def completed(request): + """ + Overview page for closed production streams. + """ + streams = ProductionStream.objects.filter(status='completed').order_by('-opened') + context = {'streams': streams,} + return render(request, 'production/completed.html', context) + + @permission_required('scipost.can_view_production', return_403=True) @transaction.atomic def add_event(request, stream_id): @@ -57,6 +64,16 @@ def add_event(request, stream_id): return render(request, 'scipost/error.html', {'errormessage': errormessage}) +@permission_required('scipost.can_view_production', return_403=True) +@transaction.atomic +def mark_as_completed(request, stream_id): + stream = get_object_or_404(ProductionStream, pk=stream_id) + stream.status = 'completed' + stream.closed = timezone.now() + stream.save() + return redirect(reverse('production:production')) + + def upload_proofs(request): """ TODO diff --git a/scipost/admin.py b/scipost/admin.py index 2f137a42295438e82e056a70de4a15d600104ca4..5db931f3ef878d4e7cd3e23cd82a5ed1e2964acb 100644 --- a/scipost/admin.py +++ b/scipost/admin.py @@ -1,6 +1,7 @@ import datetime from django.contrib import admin +from django import forms from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User, Permission @@ -12,6 +13,9 @@ from scipost.models import Contributor, Remark,\ AuthorshipClaim, PrecookedEmail,\ EditorialCollege, EditorialCollegeFellowship +from journals.models import Publication +from submissions.models import Submission + class ContributorInline(admin.StackedInline): model = Contributor @@ -80,30 +84,62 @@ def get_remark_type(remark): return 'Recommendation' return '' +class RemarkAdminForm(forms.ModelForm): + submission = forms.ModelChoiceField( + required=False, + queryset=Submission.objects.order_by('-arxiv_identifier_w_vn_nr')) + + class Meta: + model = Remark + fields = '__all__' class RemarkAdmin(admin.ModelAdmin): search_fields = ['contributor', 'remark'] list_display = [remark_text, 'contributor', 'date', get_remark_type] date_hierarchy = 'date' list_filter = [RemarkTypeListFilter] - + form = RemarkAdminForm admin.site.register(Remark, RemarkAdmin) +class DraftInvitationAdminForm(forms.ModelForm): + cited_in_submission = forms.ModelChoiceField( + required=False, + queryset=Submission.objects.order_by('-arxiv_identifier_w_vn_nr')) + cited_in_publication = forms.ModelChoiceField( + required=False, + queryset=Publication.objects.order_by('-publication_date')) + + class Meta: + model = DraftInvitation + fields = '__all__' + class DraftInvitationAdmin(admin.ModelAdmin): search_fields = ['first_name', 'last_name', 'email', 'processed'] - + form = DraftInvitationAdminForm admin.site.register(DraftInvitation, DraftInvitationAdmin) +class RegistrationInvitationAdminForm(forms.ModelForm): + cited_in_submission = forms.ModelChoiceField( + required=False, + queryset=Submission.objects.order_by('-arxiv_identifier_w_vn_nr')) + cited_in_publication = forms.ModelChoiceField( + required=False, + queryset=Publication.objects.order_by('-publication_date')) + + class Meta: + model = RegistrationInvitation + fields = '__all__' + class RegistrationInvitationAdmin(admin.ModelAdmin): search_fields = ['first_name', 'last_name', 'email', 'invitation_key'] list_display = ['__str__', 'invitation_type', 'invited_by', 'responded'] list_filter = ['invitation_type', 'message_style', 'responded', 'declined'] date_hierarchy = 'date_sent' - + form = RegistrationInvitationAdminForm admin.site.register(RegistrationInvitation, RegistrationInvitationAdmin) diff --git a/scipost/forms.py b/scipost/forms.py index 4421124d7260880f3133f32230e32f226d33ba4c..209be897953fe9f4cf77b247e70681c5b62e2dbb 100644 --- a/scipost/forms.py +++ b/scipost/forms.py @@ -1,10 +1,9 @@ -import string -import random - 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.core.urlresolvers import reverse_lazy +from django.utils.http import is_safe_url from django_countries import countries from django_countries.widgets import CountrySelectWidget @@ -12,15 +11,13 @@ from django_countries.fields import LazyTypedChoiceField from captcha.fields import ReCaptchaField from ajax_select.fields import AutoCompleteSelectField -from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout, Div, Field, HTML from .constants import SCIPOST_DISCIPLINES, TITLE_CHOICES, SCIPOST_FROM_ADDRESSES from .models import Contributor, DraftInvitation, RegistrationInvitation,\ UnavailabilityPeriod, PrecookedEmail from journals.models import Publication -from mailing_lists.models import MailchimpList, MailchimpSubscription +# from mailing_lists.models import MailchimpList, MailchimpSubscription REGISTRATION_REFUSAL_CHOICES = ( @@ -154,7 +151,6 @@ class RegistrationInvitationForm(forms.ModelForm): {'placeholder': ('NOTE: a personal phrase or two.' ' The bulk of the text will be auto-generated.')}) - self.fields['cited_in_publication'] = forms.ModelChoiceField( queryset=Publication.objects.all().order_by('-publication_date'), required=False) @@ -232,6 +228,27 @@ class VetRegistrationForm(forms.Form): class AuthenticationForm(forms.Form): username = forms.CharField(label='Username', max_length=100) password = forms.CharField(label='Password', widget=forms.PasswordInput()) + next = forms.CharField(widget=forms.HiddenInput(), required=False) + + def authenticate(self): + """ + Authenticate will return an valid User if credentials are correct. + Else, None will be returned. + """ + username = self.cleaned_data['username'] + password = self.cleaned_data['password'] + return authenticate(username=username, password=password) + + def get_redirect_url(self, request): + """ + Check the url being valid the current request, else return + to the default link (personal page). + """ + personal_page_url = reverse_lazy('scipost:personal_page') + redirect_to = self.cleaned_data['next'] + if not is_safe_url(redirect_to, request.get_host()) or not redirect_to: + return personal_page_url + return redirect_to class PasswordChangeForm(forms.Form): diff --git a/scipost/management/commands/add_groups_and_permissions.py b/scipost/management/commands/add_groups_and_permissions.py index fa7432816fd27b9e6e0573a8189c2e31bf918e93..0bc175a31eafd7b7b2f2900ef9b5d7fefd1e0a42 100644 --- a/scipost/management/commands/add_groups_and_permissions.py +++ b/scipost/management/commands/add_groups_and_permissions.py @@ -27,6 +27,10 @@ class Command(BaseCommand): JuniorAmbassadors, created = Group.objects.get_or_create(name='Junior Ambassadors') ProductionOfficers, created = Group.objects.get_or_create(name='Production Officers') + PartnersAdmin, created = Group.objects.get_or_create(name='Partners Administrators') + PartnersOfficers, created = Group.objects.get_or_create(name='Partners Officers') + + # Create Permissions content_type = ContentType.objects.get_for_model(Contributor) @@ -35,6 +39,10 @@ class Command(BaseCommand): codename='can_manage_SPB', name='Can manage Supporting Partners Board', content_type=content_type) + can_email_prospartner_contact, created = Permission.objects.get_or_create( + codename='can_email_prospartner_contact', + name='Can email Prospective Partner Contact', + content_type=content_type) # Registration and invitations can_vet_registration_requests, created = Permission.objects.get_or_create( @@ -243,6 +251,13 @@ class Command(BaseCommand): can_view_production, ]) + PartnersAdministrators.permissions.set([ + can_manage_SPB, + can_email_prospartner_contact, + ]) + PartnersOfficers.permissions.set([ + can_manage_SPB, + ]) if verbose: self.stdout.write(self.style.SUCCESS('Successfully created groups and permissions.')) diff --git a/scipost/models.py b/scipost/models.py index 1b2fa091fee75a4422c4f6186913904e4c3a48a8..dd2bc50535f6bef22f63975dbebd01b167c19593 100644 --- a/scipost/models.py +++ b/scipost/models.py @@ -8,7 +8,6 @@ from django.contrib.postgres.fields import ArrayField from django.db import models from django.template import Template, Context from django.utils import timezone -from django.utils.safestring import mark_safe from django_countries.fields import CountryField diff --git a/scipost/static/scipost/SPB/SciPost_Supporting_Partner_Agreement.pdf b/scipost/static/scipost/SPB/SciPost_Supporting_Partner_Agreement.pdf index e8ad02b0b00bddb765b1934b499a21581cc02126..db013469658f2b128320a15750ff5ef25d2f71b3 100644 Binary files a/scipost/static/scipost/SPB/SciPost_Supporting_Partner_Agreement.pdf and b/scipost/static/scipost/SPB/SciPost_Supporting_Partner_Agreement.pdf differ diff --git a/scipost/templates/scipost/login.html b/scipost/templates/scipost/login.html index b7a3a64d5351fc4188fe78886cdfa02bab68a4ec..17a677c6bbdfe068c9f471a73a557980a42a9713 100644 --- a/scipost/templates/scipost/login.html +++ b/scipost/templates/scipost/login.html @@ -2,8 +2,6 @@ {% block pagetitle %}: login{% endblock pagetitle %} - - {% load bootstrap %} {% block content %} @@ -13,14 +11,8 @@ <h1 class="mb-md-2">Log in to SciPost</h1> <form action="{% url 'scipost:login' %}" method="post"> {% csrf_token %} - {{ form|bootstrap }} - <input class="btn btn-primary" type="submit" value="Login" /> - - {% if next %} - <input type="hidden" name="next" value="{{ next }}"/> - {% endif %} </form> <br/> <a href="{% url 'scipost:reset_password' %}">Forgot your password?</a> diff --git a/scipost/templates/scipost/login_error.html b/scipost/templates/scipost/login_error.html deleted file mode 100644 index 9f893a1079366b466494786308383e3551234305..0000000000000000000000000000000000000000 --- a/scipost/templates/scipost/login_error.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends 'scipost/base.html' %} - -{% block pagetitle %}: login error{% endblock pagetitle %} - -{% block bodysup %} - -<section> - <h3>login error</h3> - <p>Please try again.</p> -</section> - -{% endblock bodysup %} diff --git a/scipost/templates/scipost/personal_page.html b/scipost/templates/scipost/personal_page.html index 6f3c572d5f36abdf5f2296792252f41fb2af432a..883f8dcdfa54f0006aa954ec0574c7fa7cf2763b 100644 --- a/scipost/templates/scipost/personal_page.html +++ b/scipost/templates/scipost/personal_page.html @@ -37,12 +37,15 @@ <li class="nav-item btn btn-secondary"> <a href="#editorial-actions" class="nav-link" data-toggle="tab">Editorial Actions</a> </li> - {% endif %} + {% endif %} + {% if perms.scipost.can_view_production %} + <li class="nav-item btn btn-secondary"> + <a href="#production" class="nav-link" data-toggle="tab">Production</a> + </li> + {% endif %} {% if perms.scipost.can_referee %} <li class="nav-item btn btn-secondary"> - {% with pending_count=pending_ref_tasks|length %} - <a class="nav-link" data-toggle="tab" href="#refereeing">Refereeing {% if nr_ref_inv_to_consider|add:pending_count %}({{nr_ref_inv_to_consider|add:pending_count}}){% endif %}</a> - {% endwith %} + <a class="nav-link" data-toggle="tab" href="#refereeing">Refereeing {% if refereeing_tab_total_count %}({{refereeing_tab_total_count}}){% endif %}</a> </li> {% endif %} <li class="nav-item btn btn-secondary"> @@ -310,6 +313,30 @@ </div><!-- End tab --> {% endif %} + {% if perms.scipost.can_view_production %} + <!-- Tab: Production --> + <div class="tab-pane" id="production" role="tabpanel"> + <div class="row"> + <div class="col-12"> + <div class="card card-grey"> + <div class="card-block"> + <h2 class="card-title mb-0">Production Tasks</h2> + </div> + </div> + </div> + </div> + <div class="row"> + <div class="col-md-4"> + <h3>Production workflow</h3> + <ul> + <li><a href="{% url 'production:production' %}">Go to the production page</a></li> + <li><a href="{% url 'production:completed' %}">View completed streams</a></li> + </ul> + </div> + </div> + </div> + {% endif %} + {% if perms.scipost.can_referee %} <!-- Tab: Refereeing --> <div class="tab-pane" id="refereeing" role="tabpanel"> @@ -342,6 +369,24 @@ </ul> </div> </div> + + {% if unfinished_reports %} + <div class="row"> + <div class="col-12"> + <h3>Unfinished reports:</h3> + </div> + <div class="col-12"> + <ul class="list-group list-group-flush"> + {% for report in unfinished_reports %} + <li class="list-group-item"> + <div class="w-100">{% include 'submissions/_submission_card_content.html' with submission=report.submission %}</div> + <div class="px-2"><a class="px-1" href="{% url 'submissions:submit_report' report.submission.arxiv_identifier_w_vn_nr %}">Finish report</a></div> + </li> + {% endfor %} + </ul> + </div> + </div> + {% endif %} </div><!-- End tab --> {% endif %} diff --git a/scipost/views.py b/scipost/views.py index cef2c9c6e9d82fa468bcc12a7c1a5b220190a874..50c9966de7357698c456f2b2fc96fc8e0cba192c 100644 --- a/scipost/views.py +++ b/scipost/views.py @@ -3,7 +3,7 @@ import re from django.utils import timezone from django.shortcuts import get_object_or_404, render from django.contrib import messages -from django.contrib.auth import authenticate, login, logout +from django.contrib.auth import login, logout from django.contrib.auth.decorators import login_required from django.contrib.auth.models import Group from django.contrib.auth.views import password_reset, password_reset_confirm @@ -15,7 +15,6 @@ from django.core.urlresolvers import reverse from django.db.models import Q from django.shortcuts import redirect from django.template import Context, Template -from django.utils.http import is_safe_url from django.views.generic.list import ListView from django.db.models import Prefetch @@ -715,30 +714,47 @@ def mark_draft_inv_as_processed(request, draft_id): def login_view(request): - redirect_to = request.POST.get('next', reverse('scipost:personal_page')) - redirect_to = (redirect_to - if is_safe_url(redirect_to, request.get_host()) - else reverse('scipost:personal_page')) - if request.method == 'POST': - username = request.POST['username'] - password = request.POST['password'] - user = authenticate(username=username, password=password) - if user is not None and is_registered(user): - if user.is_active: - login(request, user) - return redirect(redirect_to) + """ + This view shows and processes a user's login session. + + The function based method login() is deprecated from + Django 1.11 and replaced by Class Based Views. + + See: + https://docs.djangoproject.com/en/1.11/releases/1.11/#django-contrib-auth + """ + form = AuthenticationForm(request.POST or None, initial=request.GET) + if form.is_valid(): + user = form.authenticate() + if user is not None: + if is_registered(user): + # This check seems redundant, however do not remove. + if user.is_active: + login(request, user) + redirect_to = form.get_redirect_url(request) + return redirect(redirect_to) + else: + form.add_error(None, 'Your account is disabled.') else: - return render(request, 'scipost/disabled_account.html') + form.add_error(None, ('Your account has not yet been vetted. ' + '(our admins will verify your credentials very soon)')) else: - return render(request, 'scipost/login_error.html') - else: - form = AuthenticationForm() - return render(request, 'scipost/login.html', {'form': form, 'next': redirect_to}) + form.add_error(None, 'Invalid username/password.') + context = {'form': form} + return render(request, 'scipost/login.html', context) def logout_view(request): + """ + The function based method logout() is deprecated from + Django 1.11 and replaced by Class Based Views. + + See: + https://docs.djangoproject.com/en/1.11/releases/1.11/#django-contrib-auth + """ logout(request) - messages.success(request, '<h3>Keep contributing!</h3>You are now logged out of SciPost.') + messages.success(request, ('<h3>Keep contributing!</h3>' + 'You are now logged out of SciPost.')) return redirect(reverse('scipost:index')) @@ -806,8 +822,8 @@ def personal_page(request): .count()) active_assignments = EditorialAssignment.objects.filter( to=contributor, accepted=True, completed=False) - nr_reports_to_vet = Report.objects.filter( - status=0, submission__editor_in_charge=contributor).count() + nr_reports_to_vet = (Report.objects.awaiting_vetting() + .filter(submission__editor_in_charge=contributor).count()) nr_commentary_page_requests_to_vet = 0 nr_comments_to_vet = 0 nr_thesislink_requests_to_vet = 0 @@ -818,10 +834,16 @@ def personal_page(request): nr_comments_to_vet = Comment.objects.filter(status=0).count() nr_thesislink_requests_to_vet = ThesisLink.objects.filter(vetted=False).count() nr_authorship_claims_to_vet = AuthorshipClaim.objects.filter(status='0').count() + + # Refereeing nr_ref_inv_to_consider = RefereeInvitation.objects.filter( referee=contributor, accepted=None, cancelled=False).count() pending_ref_tasks = RefereeInvitation.objects.filter( referee=contributor, accepted=True, fulfilled=False) + unfinished_reports = Report.objects.in_draft().filter(author=contributor) + refereeing_tab_total_count = nr_ref_inv_to_consider + len(pending_ref_tasks) + refereeing_tab_total_count += len(unfinished_reports) + # Verify if there exist objects authored by this contributor, # whose authorship hasn't been claimed yet own_submissions = (Submission.objects @@ -879,11 +901,14 @@ def personal_page(request): 'nr_thesis_authorships_to_claim': nr_thesis_authorships_to_claim, 'nr_ref_inv_to_consider': nr_ref_inv_to_consider, 'pending_ref_tasks': pending_ref_tasks, + 'refereeing_tab_total_count': refereeing_tab_total_count, + 'unfinished_reports': unfinished_reports, 'own_submissions': own_submissions, 'own_commentaries': own_commentaries, 'own_thesislinks': own_thesislinks, 'own_comments': own_comments, 'own_authorreplies': own_authorreplies, } + return render(request, 'scipost/personal_page.html', context) diff --git a/submissions/admin.py b/submissions/admin.py index c0d1156527056afdba29f1656ff3e712246e7afd..dfc3dff7b8c223034d8d13ac0136463d1d54be6d 100644 --- a/submissions/admin.py +++ b/submissions/admin.py @@ -1,34 +1,74 @@ from django.contrib import admin +from django import forms from guardian.admin import GuardedModelAdmin -from submissions.models import * +from submissions.models import Submission, EditorialAssignment, RefereeInvitation, Report,\ + EditorialCommunication, EICRecommendation + +from scipost.models import Contributor def submission_short_title(obj): return obj.submission.title[:30] +class SubmissionAdminForm(forms.ModelForm): + authors = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.order_by('user__last_name')) + authors_claims = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.order_by('user__last_name')) + authors_false_claims = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.order_by('user__last_name')) + + class Meta: + model = Submission + fields = '__all__' + + class SubmissionAdmin(GuardedModelAdmin): search_fields = ['submitted_by__user__last_name', 'title', 'author_list', 'abstract'] list_display = ('title', 'author_list', 'status', 'submission_date', 'publication',) date_hierarchy = 'submission_date' list_filter = ('status', 'discipline', 'submission_type', ) + form = SubmissionAdminForm admin.site.register(Submission, SubmissionAdmin) +class EditorialAssignmentAdminForm(forms.ModelForm): + submission = forms.ModelChoiceField( + queryset=Submission.objects.order_by('-arxiv_identifier_w_vn_nr')) + + class Meta: + model = EditorialAssignment + fields = '__all__' + + class EditorialAssignmentAdmin(admin.ModelAdmin): search_fields = ['submission__title', 'submission__author_list', 'to__user__last_name'] list_display = ('to', submission_short_title, 'accepted', 'completed', 'date_created',) date_hierarchy = 'date_created' list_filter = ('accepted', 'deprecated', 'completed', ) + form = EditorialAssignmentAdminForm admin.site.register(EditorialAssignment, EditorialAssignmentAdmin) +class RefereeInvitationAdminForm(forms.ModelForm): + submission = forms.ModelChoiceField( + queryset=Submission.objects.order_by('-arxiv_identifier_w_vn_nr')) + + class Meta: + model = RefereeInvitation + fields = '__all__' + + class RefereeInvitationAdmin(admin.ModelAdmin): search_fields = ['submission__title', 'submission__author_list', 'referee__user__last_name', @@ -36,17 +76,28 @@ class RefereeInvitationAdmin(admin.ModelAdmin): list_display = ('__str__', 'accepted', ) list_filter = ('accepted', 'fulfilled', 'cancelled',) date_hierarchy = 'date_invited' + form = RefereeInvitationAdminForm admin.site.register(RefereeInvitation, RefereeInvitationAdmin) +class ReportAdminForm(forms.ModelForm): + submission = forms.ModelChoiceField( + queryset=Submission.objects.order_by('-arxiv_identifier_w_vn_nr')) + + class Meta: + model = Report + fields = '__all__' + + class ReportAdmin(admin.ModelAdmin): search_fields = ['author__user__last_name', 'submission'] list_display = ('author', 'status', submission_short_title, 'date_submitted', ) list_display_links = ('author',) date_hierarchy = 'date_submitted' list_filter = ('status',) + form = ReportAdminForm admin.site.register(Report, ReportAdmin) @@ -59,8 +110,38 @@ class EditorialCommunicationAdmin(admin.ModelAdmin): admin.site.register(EditorialCommunication, EditorialCommunicationAdmin) +class EICRecommendationAdminForm(forms.ModelForm): + submission = forms.ModelChoiceField( + queryset=Submission.objects.order_by('-arxiv_identifier_w_vn_nr')) + eligible_to_vote = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.filter( + user__groups__name__in=['Editorial College'], + ).order_by('user__last_name')) + voted_for = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.filter( + user__groups__name__in=['Editorial College'], + ).order_by('user__last_name')) + voted_against = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.filter( + user__groups__name__in=['Editorial College'], + ).order_by('user__last_name')) + voted_abstain = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.filter( + user__groups__name__in=['Editorial College'], + ).order_by('user__last_name')) + + class Meta: + model = EICRecommendation + fields = '__all__' + + class EICRecommendationAdmin(admin.ModelAdmin): search_fields = ['submission__title'] + form = EICRecommendationAdminForm admin.site.register(EICRecommendation, EICRecommendationAdmin) diff --git a/submissions/constants.py b/submissions/constants.py index 84638110f30c4a2829a9f8a29c125b2a57baf46c..2b247ffa6a78a9c83e72934b3871e42c21ec3435 100644 --- a/submissions/constants.py +++ b/submissions/constants.py @@ -38,16 +38,24 @@ SUBMISSION_STATUS = ( SUBMISSION_HTTP404_ON_EDITORIAL_PAGE = [ 'assignment_failed', - 'published', + STATUS_PUBLISHED, 'withdrawn', - 'rejected', - 'rejected_visible', + STATUS_REJECTED, + STATUS_REJECTED_VISIBLE, ] SUBMISSION_STATUS_OUT_OF_POOL = SUBMISSION_HTTP404_ON_EDITORIAL_PAGE + [ 'resubmitted' ] +SUBMISSION_EXCLUDE_FROM_REPORTING = SUBMISSION_HTTP404_ON_EDITORIAL_PAGE + [ + STATUS_AWAITING_ED_REC, + STATUS_REVIEW_CLOSED, + STATUS_ACCEPTED, + 'voting_in_preparation', + 'put_to_EC_voting', + 'withdrawn', +] # Submissions which are allowed/required to submit a EIC Recommendation SUBMISSION_EIC_RECOMMENDATION_REQUIRED = [ @@ -156,23 +164,23 @@ REPORT_REC = ( # # Reports # -REPORT_ACTION_ACCEPT = 1 -REPORT_ACTION_REFUSE = 2 +REPORT_ACTION_ACCEPT = 'accept' +REPORT_ACTION_REFUSE = 'refuse' REPORT_ACTION_CHOICES = ( (REPORT_ACTION_ACCEPT, 'accept'), (REPORT_ACTION_REFUSE, 'refuse'), ) -STATUS_VETTED = 1 -STATUS_UNVETTED = 0 -STATUS_UNCLEAR = -1 -STATUS_INCORRECT = -2 -STATUS_NOT_USEFUL = -3 -STATUS_NOT_ACADEMIC = -4 +STATUS_DRAFT = 'draft' +STATUS_VETTED = 'vetted' +STATUS_UNVETTED = 'unvetted' +STATUS_UNCLEAR = 'unclear' +STATUS_INCORRECT = 'incorrect' +STATUS_NOT_USEFUL = 'notuseful' +STATUS_NOT_ACADEMIC = 'notacademic' -REPORT_REFUSAL_NONE = 0 REPORT_REFUSAL_CHOICES = ( - (STATUS_UNVETTED, '-'), + (None, '-'), (STATUS_UNCLEAR, 'insufficiently clear'), (STATUS_INCORRECT, 'not fully factually correct'), (STATUS_NOT_USEFUL, 'not useful for the authors'), @@ -180,6 +188,7 @@ REPORT_REFUSAL_CHOICES = ( ) REPORT_STATUSES = ( + (STATUS_DRAFT, 'Draft'), (STATUS_VETTED, 'Vetted'), (STATUS_UNVETTED, 'Unvetted'), (STATUS_INCORRECT, 'Rejected (incorrect)'), diff --git a/submissions/exceptions.py b/submissions/exceptions.py index 19c5684bd004f175ffe4169cc2938d312d66edf0..0e8794a8cc841af0df2896138ce2ec0209ee0c7e 100644 --- a/submissions/exceptions.py +++ b/submissions/exceptions.py @@ -4,3 +4,11 @@ class CycleUpdateDeadlineError(Exception): def __str__(self): return self.name + + +class InvalidReportVettingValue(Exception): + def __init__(self, name): + self.name = name + + def __str__(self): + return self.name diff --git a/submissions/factories.py b/submissions/factories.py index 6c602275452772293c4f8303caf37c5baf63152a..d9c32c745fd079caed4ab17d02fd3511c0605ff9 100644 --- a/submissions/factories.py +++ b/submissions/factories.py @@ -9,8 +9,10 @@ from journals.constants import SCIPOST_JOURNALS_DOMAINS from common.helpers import random_arxiv_identifier_without_version_number, random_scipost_journal from .constants import STATUS_UNASSIGNED, STATUS_EIC_ASSIGNED, STATUS_RESUBMISSION_INCOMING,\ - STATUS_PUBLISHED, SUBMISSION_TYPE, STATUS_RESUBMITTED -from .models import Submission + STATUS_PUBLISHED, SUBMISSION_TYPE, STATUS_RESUBMITTED, STATUS_VETTED,\ + REFEREE_QUALIFICATION, RANKING_CHOICES, QUALITY_SPEC, REPORT_REC,\ + REPORT_STATUSES, STATUS_UNVETTED, STATUS_DRAFT +from .models import Submission, Report, RefereeInvitation from faker import Faker @@ -142,3 +144,64 @@ class PublishedSubmissionFactory(SubmissionFactory): status = STATUS_PUBLISHED open_for_commenting = False open_for_reporting = False + + +class ReportFactory(factory.django.DjangoModelFactory): + class Meta: + model = Report + + status = factory.Iterator(REPORT_STATUSES, getter=lambda c: c[0]) + submission = factory.Iterator(Submission.objects.all()) + date_submitted = Faker().date_time_between(start_date="-3y", end_date="now", tzinfo=pytz.UTC) + vetted_by = factory.Iterator(Contributor.objects.all()) + author = factory.Iterator(Contributor.objects.all()) + qualification = factory.Iterator(REFEREE_QUALIFICATION, getter=lambda c: c[0]) + strengths = Faker().paragraph() + weaknesses = Faker().paragraph() + report = Faker().paragraph() + requested_changes = Faker().paragraph() + validity = factory.Iterator(RANKING_CHOICES, getter=lambda c: c[0]) + significance = factory.Iterator(RANKING_CHOICES, getter=lambda c: c[0]) + originality = factory.Iterator(RANKING_CHOICES, getter=lambda c: c[0]) + clarity = factory.Iterator(RANKING_CHOICES, getter=lambda c: c[0]) + formatting = factory.Iterator(QUALITY_SPEC, getter=lambda c: c[0]) + grammar = factory.Iterator(QUALITY_SPEC, getter=lambda c: c[0]) + recommendation = factory.Iterator(REPORT_REC, getter=lambda c: c[0]) + remarks_for_editors = Faker().paragraph() + + +class DraftReportFactory(ReportFactory): + status = STATUS_DRAFT + vetted_by = None + + +class UnVettedReportFactory(ReportFactory): + status = STATUS_UNVETTED + vetted_by = None + + +class VettedReportFactory(ReportFactory): + status = STATUS_VETTED + + +class RefereeInvitationFactory(factory.django.DjangoModelFactory): + class Meta: + model = RefereeInvitation + + submission = factory.SubFactory('submissions.factories.SubmissionFactory') + referee = factory.Iterator(Contributor.objects.all()) + + invitation_key = factory.Faker('md5') + invited_by = factory.Iterator(Contributor.objects.all()) + + @factory.post_generation + def contributor_fields(self, create, extracted, **kwargs): + self.title = self.referee.title + self.first_name = self.referee.user.first_name + self.last_name = self.referee.user.last_name + self.email_address = self.referee.user.email + + +class AcceptedRefereeInvitationFactory(RefereeInvitationFactory): + accepted = True + date_responded = Faker().date_time_between(start_date="-1y", end_date="now", tzinfo=pytz.UTC) diff --git a/submissions/forms.py b/submissions/forms.py index 71632fa47b2e7cbff6d728ad29ae18fc1a242166..7f2d39772f1086f15e09c39ef36facb7bda4cb61 100644 --- a/submissions/forms.py +++ b/submissions/forms.py @@ -1,13 +1,16 @@ from django import forms from django.contrib.auth.models import Group -from django.core.validators import RegexValidator -from django.db import models, transaction +from django.db import transaction +from django.utils import timezone from guardian.shortcuts import assign_perm from .constants import ASSIGNMENT_BOOL, ASSIGNMENT_REFUSAL_REASONS, STATUS_RESUBMITTED,\ REPORT_ACTION_CHOICES, REPORT_REFUSAL_CHOICES, STATUS_REVISION_REQUESTED,\ - STATUS_REJECTED, STATUS_REJECTED_VISIBLE, STATUS_RESUBMISSION_INCOMING + STATUS_REJECTED, STATUS_REJECTED_VISIBLE, STATUS_RESUBMISSION_INCOMING,\ + STATUS_DRAFT, STATUS_UNVETTED, REPORT_ACTION_ACCEPT, REPORT_ACTION_REFUSE,\ + STATUS_VETTED +from .exceptions import InvalidReportVettingValue from .models import Submission, RefereeInvitation, Report, EICRecommendation, EditorialAssignment from scipost.constants import SCIPOST_SUBJECT_AREAS @@ -56,6 +59,11 @@ class SubmissionChecks: if kwargs['initial'].get('is_resubmission', None): self.is_resubmission = kwargs['initial']['is_resubmission'] in ('True', True) + # `is_resubmission` property if data is coming from (POST) request + if kwargs.get('data', None): + if kwargs['data'].get('is_resubmission', None): + self.is_resubmission = kwargs['data']['is_resubmission'] in ('True', True) + def _submission_already_exists(self, identifier): if Submission.objects.filter(arxiv_identifier_w_vn_nr=identifier).exists(): error_message = 'This preprint version has already been submitted to SciPost.' @@ -88,7 +96,7 @@ class SubmissionChecks: identifiers = self.identifier_into_parts(identifier) submission = (Submission.objects .filter(arxiv_identifier_wo_vn_nr=identifiers['arxiv_identifier_wo_vn_nr']) - .order_by('-arxiv_vn_nr').last()) + .order_by('arxiv_vn_nr').last()) # If submissions are found; check their statuses if submission: @@ -413,17 +421,55 @@ class ReportForm(forms.ModelForm): def __init__(self, *args, **kwargs): super(ReportForm, self).__init__(*args, **kwargs) - self.fields['strengths'].widget.attrs.update( - {'placeholder': 'Give a point-by-point (numbered 1-, 2-, ...) list of the paper\'s strengths', - 'rows': 10, 'cols': 100}) - self.fields['weaknesses'].widget.attrs.update( - {'placeholder': 'Give a point-by-point (numbered 1-, 2-, ...) list of the paper\'s weaknesses', - 'rows': 10, 'cols': 100}) + self.fields['strengths'].widget.attrs.update({ + 'placeholder': ('Give a point-by-point ' + '(numbered 1-, 2-, ...) list of the paper\'s strengths'), + 'rows': 10, + 'cols': 100 + }) + self.fields['weaknesses'].widget.attrs.update({ + 'placeholder': ('Give a point-by-point ' + '(numbered 1-, 2-, ...) list of the paper\'s weaknesses'), + 'rows': 10, + 'cols': 100 + }) self.fields['report'].widget.attrs.update({'placeholder': 'Your general remarks', 'rows': 10, 'cols': 100}) - self.fields['requested_changes'].widget.attrs.update( - {'placeholder': 'Give a numbered (1-, 2-, ...) list of specifically requested changes', - 'cols': 100}) + self.fields['requested_changes'].widget.attrs.update({ + 'placeholder': 'Give a numbered (1-, 2-, ...) list of specifically requested changes', + 'cols': 100 + }) + + def save(self, submission, current_contributor): + """ + Update meta data if ModelForm is submitted (non-draft). + Possibly overwrite the default status if user asks for saving as draft. + """ + report = super().save(commit=False) + + report.submission = submission + report.author = current_contributor + report.date_submitted = timezone.now() + + # Save with right status asked by user + if 'save_draft' in self.data: + report.status = STATUS_DRAFT + elif 'save_submit' in self.data: + report.status = STATUS_UNVETTED + + # Update invitation and report meta data if exist + invitation = submission.referee_invitations.filter(referee=current_contributor).first() + if invitation: + invitation.fulfilled = True + invitation.save() + report.invited = True + + # Check if report author if the report is being flagged on the submission + if submission.referees_flagged: + if current_contributor.user.last_name in submission.referees_flagged: + report.flagged = True + report.save() + return report class VetReportForm(forms.Form): @@ -433,12 +479,41 @@ class VetReportForm(forms.Form): refusal_reason = forms.ChoiceField(choices=REPORT_REFUSAL_CHOICES, required=False) email_response_field = forms.CharField(widget=forms.Textarea(), label='Justification (optional)', required=False) + report = forms.ModelChoiceField(queryset=Report.objects.awaiting_vetting(), required=True, + widget=forms.HiddenInput()) def __init__(self, *args, **kwargs): super(VetReportForm, self).__init__(*args, **kwargs) - self.fields['email_response_field'].widget.attrs.update( - {'placeholder': 'Optional: give a textual justification (will be included in the email to the Report\'s author)', - 'rows': 5}) + self.fields['email_response_field'].widget.attrs.update({ + 'placeholder': ('Optional: give a textual justification ' + '(will be included in the email to the Report\'s author)'), + 'rows': 5 + }) + + def clean_refusal_reason(self): + '''Require a refusal reason if report is rejected.''' + reason = self.cleaned_data['refusal_reason'] + if self.cleaned_data['action_option'] == REPORT_ACTION_REFUSE: + if not reason: + self.add_error('refusal_reason', 'A reason must be given to refuse a report.') + return reason + + def process_vetting(self, current_contributor): + '''Set the right report status and update submission fields if needed.''' + report = self.cleaned_data['report'] + report.vetted_by = current_contributor + if self.cleaned_data['action_option'] == REPORT_ACTION_ACCEPT: + # Accept the report as is + report.status = STATUS_VETTED + report.submission.latest_activity = timezone.now() + report.submission.save() + elif self.cleaned_data['action_option'] == REPORT_ACTION_REFUSE: + # The report is rejected + report.status = self.cleaned_data['refusal_reason'] + else: + raise InvalidReportVettingValue(self.cleaned_data['action_option']) + report.save() + return report ################### diff --git a/submissions/managers.py b/submissions/managers.py index f1ce5d8a9ec241367cdfa27d31e7624bbea4c4ac..0b9d6cd8cf831490a0974b2ba14a815ac12e90b1 100644 --- a/submissions/managers.py +++ b/submissions/managers.py @@ -4,7 +4,8 @@ from django.db.models import Q from .constants import SUBMISSION_STATUS_OUT_OF_POOL, SUBMISSION_STATUS_PUBLICLY_UNLISTED,\ SUBMISSION_STATUS_PUBLICLY_INVISIBLE, STATUS_UNVETTED, STATUS_VETTED,\ STATUS_UNCLEAR, STATUS_INCORRECT, STATUS_NOT_USEFUL, STATUS_NOT_ACADEMIC,\ - SUBMISSION_HTTP404_ON_EDITORIAL_PAGE + SUBMISSION_HTTP404_ON_EDITORIAL_PAGE, STATUS_DRAFT,\ + SUBMISSION_EXCLUDE_FROM_REPORTING class SubmissionManager(models.Manager): @@ -73,6 +74,13 @@ class SubmissionManager(models.Manager): queryset = self.exclude(status__in=SUBMISSION_STATUS_PUBLICLY_INVISIBLE) return self._newest_version_only(queryset) + def open_for_reporting(self): + """ + This query should filter submissions that do not have the right status to receive + a new report. + """ + return self.exclude(status__in=SUBMISSION_EXCLUDE_FROM_REPORTING) + class EditorialAssignmentManager(models.Manager): def get_for_user_in_pool(self, user): @@ -110,7 +118,7 @@ class EICRecommendationManager(models.Manager): class ReportManager(models.Manager): def accepted(self): - return self.filter(status__gte=STATUS_VETTED) + return self.filter(status=STATUS_VETTED) def awaiting_vetting(self): return self.filter(status=STATUS_UNVETTED) @@ -118,3 +126,6 @@ class ReportManager(models.Manager): def rejected(self): return self.filter(status__in=[STATUS_UNCLEAR, STATUS_INCORRECT, STATUS_NOT_USEFUL, STATUS_NOT_ACADEMIC]) + + def in_draft(self): + return self.filter(status=STATUS_DRAFT) diff --git a/submissions/migrations/0044_auto_20170602_1836.py b/submissions/migrations/0044_auto_20170602_1836.py new file mode 100644 index 0000000000000000000000000000000000000000..329d7db77ef5620d4a4bc4ecb7c07d12d685660c --- /dev/null +++ b/submissions/migrations/0044_auto_20170602_1836.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-06-02 16:36 +from __future__ import unicode_literals + +from django.db import migrations, models + + +status_map_to_new = { + '1': 'vetted', + '0': 'unvetted', + '-1': 'unclear', + '-2': 'incorrect', + '-3': 'notuseful', + '-4': 'notacademic' +} +status_map_to_old = dict((v, int(k)) for k, v in status_map_to_new.items()) + + +def map_reports_to_new_status_codes(apps, schema_editor): + Report = apps.get_model('submissions', 'Report') + reports = Report.objects.all() + for report in reports: + try: + new_status = status_map_to_new[report.status] + except KeyError: + new_status = 'unvetted' + report.status = new_status + report.save() + print('\nUpdated %i reports.' % len(reports)) + + +def map_reports_to_old_status_codes(apps, schema_editor): + Report = apps.get_model('submissions', 'Report') + reports = Report.objects.all() + for report in reports: + try: + new_status = status_map_to_old[report.status] + except KeyError: + new_status = 0 + report.status = new_status + report.save() + print('\nUpdated %i reports.' % len(reports)) + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0043_auto_20170512_0836'), + ] + + operations = [ + migrations.AlterField( + model_name='report', + name='status', + field=models.CharField(choices=[('vetted', 'Vetted'), ('unvetted', 'Unvetted'), ('incorrect', 'Rejected (incorrect)'), ('unclear', 'Rejected (unclear)'), ('notuseful', 'Rejected (not useful)'), ('notacademic', 'Rejected (not academic in style)')], default='unvetted', max_length=16), + ), + migrations.RunPython(map_reports_to_new_status_codes, map_reports_to_old_status_codes), + ] diff --git a/submissions/migrations/0045_auto_20170608_1710.py b/submissions/migrations/0045_auto_20170608_1710.py new file mode 100644 index 0000000000000000000000000000000000000000..d21f01c8963888c5630e3965bca0403a6e0f0125 --- /dev/null +++ b/submissions/migrations/0045_auto_20170608_1710.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.3 on 2017-06-08 15:10 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0044_auto_20170602_1836'), + ] + + operations = [ + migrations.AlterField( + model_name='report', + name='status', + field=models.CharField(choices=[('draft', 'Draft'), ('vetted', 'Vetted'), ('unvetted', 'Unvetted'), ('incorrect', 'Rejected (incorrect)'), ('unclear', 'Rejected (unclear)'), ('notuseful', 'Rejected (not useful)'), ('notacademic', 'Rejected (not academic in style)')], default='unvetted', max_length=16), + ), + ] diff --git a/submissions/models.py b/submissions/models.py index 9e697af3908fdf117f182ae6a0f26c337a9c900b..17a3643441ffd49857f4e637262444881de02fb2 100644 --- a/submissions/models.py +++ b/submissions/models.py @@ -1,16 +1,15 @@ import datetime from django.utils import timezone -from django.db import models, transaction +from django.db import models from django.contrib.postgres.fields import JSONField from django.urls import reverse from .constants import ASSIGNMENT_REFUSAL_REASONS, ASSIGNMENT_NULLBOOL,\ SUBMISSION_TYPE, ED_COMM_CHOICES, REFEREE_QUALIFICATION, QUALITY_SPEC,\ RANKING_CHOICES, REPORT_REC, SUBMISSION_STATUS, STATUS_UNASSIGNED,\ - REPORT_STATUSES, STATUS_UNVETTED, STATUS_RESUBMISSION_INCOMING,\ - SUBMISSION_CYCLES, CYCLE_DEFAULT, CYCLE_SHORT, CYCLE_DIRECT_REC,\ - SUBMISSION_EIC_RECOMMENDATION_REQUIRED + REPORT_STATUSES, STATUS_UNVETTED, SUBMISSION_EIC_RECOMMENDATION_REQUIRED,\ + SUBMISSION_CYCLES, CYCLE_DEFAULT, CYCLE_SHORT, CYCLE_DIRECT_REC from .managers import SubmissionManager, EditorialAssignmentManager, EICRecommendationManager,\ ReportManager from .utils import ShortSubmissionCycle, DirectRecommendationSubmissionCycle,\ @@ -148,19 +147,19 @@ class Submission(ArxivCallable, models.Model): return self.referee_invitations.filter(accepted=None).count() def count_invited_reports(self): - return self.reports.filter(status=1, invited=True).count() + return self.reports.accepted().filter(invited=True).count() def count_contrib_reports(self): - return self.reports.filter(status=1, invited=False).count() + return self.reports.accepted().filter(invited=False).count() def count_obtained_reports(self): - return self.reports.filter(status=1, invited__isnull=False).count() + return self.reports.accepted().filter(invited__isnull=False).count() - def count_refused_resports(self): - return self.reports.filter(status__lte=-1).count() + def count_refused_reports(self): + return self.reports.rejected().count() def count_awaiting_vetting(self): - return self.reports.filter(status=0).count() + return self.reports.awaiting_vetting().count() ###################### @@ -236,7 +235,7 @@ class RefereeInvitation(models.Model): class Report(models.Model): """ Both types of reports, invited or contributed. """ - status = models.SmallIntegerField(choices=REPORT_STATUSES, default=STATUS_UNVETTED) + status = models.CharField(max_length=16, choices=REPORT_STATUSES, default=STATUS_UNVETTED) submission = models.ForeignKey('submissions.Submission', related_name='reports', on_delete=models.CASCADE) vetted_by = models.ForeignKey('scipost.Contributor', related_name="report_vetted_by", @@ -264,7 +263,7 @@ class Report(models.Model): verbose_name="Quality of paper formatting") grammar = models.SmallIntegerField(choices=QUALITY_SPEC, verbose_name="Quality of English grammar") - # + recommendation = models.SmallIntegerField(choices=REPORT_REC) remarks_for_editors = models.TextField(default='', blank=True, verbose_name='optional remarks for the Editors only') diff --git a/submissions/templates/submissions/_submission_refereeing_status.html b/submissions/templates/submissions/_submission_refereeing_status.html index 94e9ba3cbd35ab3eccca9bcba9e3fc9c4d9585d7..50066b96f9b564b3e9d18b1dc24d191c56e922f6 100644 --- a/submissions/templates/submissions/_submission_refereeing_status.html +++ b/submissions/templates/submissions/_submission_refereeing_status.html @@ -1,6 +1,6 @@ {% if submission.refereeing_cycle != 'direct_rec' %} <div class="card-block"> <p class="card-text">Nr referees invited: {{submission.referee_invitations.count}} <span>[{{submission.count_accepted_invitations}} acccepted / {{submission.count_declined_invitations}} declined / {{submission.count_pending_invitations}} response pending]</span></p> - <p class="card-text">Nr reports obtained: {{submission.count_obtained_reports}} [{{submission.count_invited_reports}} invited / {{submission.count_contrib_reports}} contributed], nr refused: {{submission.count_refused_resports}}, nr awaiting vetting: {{submission.count_awaiting_vetting}}</p> + <p class="card-text">Nr reports obtained: {{submission.count_obtained_reports}} [{{submission.count_invited_reports}} invited / {{submission.count_contrib_reports}} contributed], nr refused: {{submission.count_refused_reports}}, nr awaiting vetting: {{submission.count_awaiting_vetting}}</p> </div> {% endif %} diff --git a/submissions/templates/submissions/submission_detail.html b/submissions/templates/submissions/submission_detail.html index c10ff311dbc77527b3a1fd60c593d843f299b907..f255cf191d11632a6776dda1ba0baf10e76e4ffd 100644 --- a/submissions/templates/submissions/submission_detail.html +++ b/submissions/templates/submissions/submission_detail.html @@ -58,6 +58,12 @@ {% endfor %} </div> {% endif %} + + {% if unfinished_report_for_user %} + <blockquote class="blockquote"> + <p class="mb-0">You have an unfinished report for this submission, <a href="{% url 'submissions:submit_report' arxiv_identifier_w_vn_nr=submission.arxiv_identifier_w_vn_nr %}">finish your report here.</a></p> + </blockquote> + {% endif %} </div> </div> @@ -110,7 +116,8 @@ {% if submission.open_for_reporting %} {% if perms.scipost.can_referee and not is_author and not is_author_unchecked %} <li> - <h3><a href="{% url 'submissions:submit_report' arxiv_identifier_w_vn_nr=submission.arxiv_identifier_w_vn_nr %}">Contribute a Report</a></h3> + + <h3><a href="{% url 'submissions:submit_report' arxiv_identifier_w_vn_nr=submission.arxiv_identifier_w_vn_nr %}">{% if unfinished_report_for_user %}Finish your report{% else %}Contribute a Report{% endif %}</a></h3> <div class="text-danger">Deadline for reporting: {{ submission.reporting_deadline|date:"Y-m-d" }}</div> </li> {% elif is_author_unchecked %} diff --git a/submissions/templates/submissions/submit_report.html b/submissions/templates/submissions/submit_report.html index 72a253c84058ac31f315ed052402b541edd3d08b..63f80e447b69a04de026d1a51db389b58df83e29 100644 --- a/submissions/templates/submissions/submit_report.html +++ b/submissions/templates/submissions/submit_report.html @@ -88,8 +88,9 @@ <form action="{% url 'submissions:submit_report' arxiv_identifier_w_vn_nr=submission.arxiv_identifier_w_vn_nr %}" method="post"> {% csrf_token %} {{ form|bootstrap:'3,9' }} - <input class="btn btn-primary" type="submit" value="Submit your report"/> - <div class="mt-2"> + <input class="btn btn-primary" type="submit" name="save_submit" value="Submit your report"/> + <input class="btn btn-secondary ml-2" type="submit" name="save_draft" value="Save your report as draft"/> + <div class="my-4"> <em>By clicking on Submit, you state that you abide by the <a href="{% url 'journals:journals_terms_and_conditions' %}#referee_code_of_conduct">referee code of conduct</a>.</em> </div> </form> diff --git a/submissions/templates/submissions/submit_report_ack.html b/submissions/templates/submissions/submit_report_ack.html deleted file mode 100644 index 8e5b948e98fd58a108b0f265fa2b21ab02ce8ed1..0000000000000000000000000000000000000000 --- a/submissions/templates/submissions/submit_report_ack.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends 'scipost/base.html' %} - -{% block pagetitle %}: submit report (ack){% endblock pagetitle %} - -{% block bodysup %} - -<section> - {% if errormessage %} - <p>{{ errormessage }}</p> - {% else %} - <h1>Thank you for your Report.</h1> - {% endif %} -</section> - -{% endblock bodysup %} diff --git a/submissions/templates/submissions/vet_submitted_reports.html b/submissions/templates/submissions/vet_submitted_reports.html index d8fe13476f55abced58e18b00decfd07cbd72976..eb2278ad82292a53d9b7195ae33dc9ff30fc68c2 100644 --- a/submissions/templates/submissions/vet_submitted_reports.html +++ b/submissions/templates/submissions/vet_submitted_reports.html @@ -8,18 +8,15 @@ <script> $(document).ready(function(){ - - $('#refusal').hide(); - $('[name="action_option"]').on('change', function() { - if ($('#id_action_option_1').is(':checked')) { + if ($('[name="action_option"][value="refuse"]').is(':checked')) { $('#refusal').show(); } else { $('#refusal').hide(); } - }); - }); + }).trigger('change'); +}); </script> {% endblock headsup %} @@ -36,6 +33,7 @@ $(document).ready(function(){ <div class="col-12"> {% if not report_to_vet %} <h1>There are no Reports for you to vet.</h1> + <p>Go back to my <a href="{% url 'scipost:personal_page' %}">personal page</a>.</p> {% else %} <h1 class="highlight">SciPost Report to vet:</h1> @@ -52,8 +50,9 @@ $(document).ready(function(){ <hr class="small"> <h2>Please vet this Report:</h2> - <form action="{% url 'submissions:vet_submitted_report_ack' report_id=report_to_vet.id %}" method="post"> + <form action="{% url 'submissions:vet_submitted_reports' %}" method="post"> {% csrf_token %} + {{ form.report }} {{ form.action_option|bootstrap }} <div class="col-md-6" id="refusal"> {{ form.refusal_reason|bootstrap }} diff --git a/submissions/test_views.py b/submissions/test_views.py index 11e9c210a19bd604c2791a96a4359d6f1c7672b4..cc6905f3e018523147e0df80f8c49fecae9bd4c2 100644 --- a/submissions/test_views.py +++ b/submissions/test_views.py @@ -1,20 +1,21 @@ -import json - from django.core.urlresolvers import reverse -from django.test import TestCase +from django.test import TestCase, tag from django.test import Client from common.helpers import random_arxiv_identifier_without_version_number from common.helpers.test import add_groups_and_permissions from scipost.factories import ContributorFactory -# from scipost.models import Contributor -from .constants import STATUS_UNASSIGNED +from .constants import STATUS_UNASSIGNED, STATUS_DRAFT, STATUS_UNVETTED from .factories import UnassignedSubmissionFactory, EICassignedSubmissionFactory,\ ResubmittedSubmissionFactory, ResubmissionFactory,\ - PublishedSubmissionFactory -from .forms import SubmissionForm, SubmissionIdentifierForm -from .models import Submission + PublishedSubmissionFactory, DraftReportFactory,\ + AcceptedRefereeInvitationFactory +from .forms import RequestSubmissionForm, SubmissionIdentifierForm, ReportForm +from .models import Submission, Report, RefereeInvitation + +from faker import Faker + # This is content of a real arxiv submission. As long as it isn't published it should # be possible to run tests using this submission. @@ -54,7 +55,7 @@ class BaseContributorTestCase(TestCase): def setUp(self): add_groups_and_permissions() ContributorFactory.create_batch(5) - ContributorFactory.create( + self.current_contrib = ContributorFactory.create( user__last_name='Linder', # To pass the author check in create submissions view user__username='Test', user__password='testpw' @@ -79,6 +80,8 @@ class PrefillUsingIdentifierTest(BaseContributorTestCase): # Registered Contributor should get 200 response = self.client.get(self.url) + self.assertIsInstance(response.context['form'], SubmissionIdentifierForm) + self.assertFalse(response.context['form'].is_valid()) self.assertEqual(response.status_code, 200) def test_retrieving_existing_arxiv_paper(self): @@ -87,13 +90,11 @@ class PrefillUsingIdentifierTest(BaseContributorTestCase): {'identifier': TEST_SUBMISSION['arxiv_identifier_w_vn_nr']}) self.assertEqual(response.status_code, 200) - self.assertIsInstance(response.context['form'], SubmissionForm) - self.assertIsInstance(response.context['identifierform'], SubmissionIdentifierForm) - self.assertTrue(response.context['identifierform'].is_valid()) + self.assertIsInstance(response.context['form'], RequestSubmissionForm) # Explicitly compare fields instead of assertDictEqual as metadata field may be outdated - self.assertEqual(TEST_SUBMISSION['is_resubmission'], - response.context['form'].initial['is_resubmission']) + # self.assertEqual(TEST_SUBMISSION['is_resubmission'], + # response.context['form'].initial['is_resubmission']) self.assertEqual(TEST_SUBMISSION['title'], response.context['form'].initial['title']) self.assertEqual(TEST_SUBMISSION['author_list'], response.context['form'].initial['author_list']) @@ -138,7 +139,6 @@ class SubmitManuscriptTest(BaseContributorTestCase): 'submission_type': 'Article', 'domain': 'T' }) - params['metadata'] = json.dumps(params['metadata'], separators=(',', ':')) # Submit new Submission form response = client.post(reverse('submissions:submit_manuscript'), params) @@ -179,11 +179,13 @@ class SubmitManuscriptTest(BaseContributorTestCase): 'submission_type': 'Article', 'domain': 'T' }) - params['metadata'] = json.dumps(params['metadata'], separators=(',', ':')) # Submit new Submission form response = client.post(reverse('submissions:submit_manuscript'), params) - self.assertEqual(response.status_code, 302) + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.context['form'], RequestSubmissionForm) + self.assertFalse(response.context['form'].is_valid()) + self.assertIn('author_list', response.context['form'].errors.keys()) # No real check is done here to see if submission submit is aborted. # To be implemented after Arxiv caller. @@ -246,3 +248,169 @@ class SubmissionListTest(BaseContributorTestCase): returned_submissions_ids.sort() visible_submission_ids.sort() self.assertListEqual(returned_submissions_ids, visible_submission_ids) + + +class SubmitReportTest(BaseContributorTestCase): + TEST_DATA = { + 'anonymous': 'on', + 'clarity': '60', + 'formatting': '4', + 'grammar': '5', + 'originality': '100', + 'qualification': '3', + 'recommendation': '3', + 'remarks_for_editors': 'Lorem Ipsum1', + 'report': 'Lorem Ipsum', + 'requested_changes': 'Lorem Ipsum2', + 'significance': '0', + 'strengths': 'Lorem Ipsum3', + 'validity': '60', + 'weaknesses': 'Lorem Ipsum4' + } + + def setUp(self): + super().setUp() + self.client = Client() + report_deadline = Faker().date_time_between(start_date="now", end_date="+30d", tzinfo=None) + self.submission = EICassignedSubmissionFactory(reporting_deadline=report_deadline) + self.submission.authors.remove(self.current_contrib) + self.submission.authors_false_claims.add(self.current_contrib) + self.target = reverse('submissions:submit_report', + args=(self.submission.arxiv_identifier_w_vn_nr,)) + self.assertTrue(self.client.login(username="Test", password="testpw")) + + @tag('reports') + def test_status_code_200_no_report_set(self): + '''Test response for view if no report is submitted yet.''' + report_deadline = Faker().date_time_between(start_date="now", end_date="+30d", tzinfo=None) + submission = EICassignedSubmissionFactory(reporting_deadline=report_deadline) + submission.authors.remove(self.current_contrib) + submission.authors_false_claims.add(self.current_contrib) + + target = reverse('submissions:submit_report', args=(submission.arxiv_identifier_w_vn_nr,)) + client = Client() + + # Login and call view + self.assertTrue(client.login(username="Test", password="testpw")) + response = client.get(target) + + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.context['form'].instance.id) + + @tag('reports') + def test_status_code_200_report_in_draft(self): + '''Test response for view if report in draft exists.''' + report = DraftReportFactory(submission=self.submission, author=self.current_contrib) + response = self.client.get(self.target) + + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.context['form'], ReportForm) + self.assertEqual(response.context['form'].instance, report) + + @tag('reports') + def test_post_report_for_draft_status(self): + '''Test response of view if report is saved as draft.''' + response = self.client.post(self.target, {**self.TEST_DATA, 'save_draft': 'True'}) + + # Check if form is returned with saved report as instance + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.context['form'], ReportForm) + self.assertIsInstance(response.context['form'].instance, Report) + + # Briefly do cross checks if report submit is complete + report_db = Report.objects.last() + self.assertEqual(response.context['form'].instance, report_db) + self.assertTrue(report_db.anonymous) + self.assertEqual(report_db.status, STATUS_DRAFT) + self.assertFalse(report_db.invited) # Set by view only if non-draft + self.assertFalse(report_db.flagged) # Set by view only if non-draft + + self.assertEqual(report_db.clarity, 60) + self.assertEqual(report_db.formatting, 4) + self.assertEqual(report_db.grammar, 5) + self.assertEqual(report_db.originality, 100) + self.assertEqual(report_db.qualification, 3) + self.assertEqual(report_db.significance, 0) + self.assertEqual(report_db.validity, 60) + self.assertEqual(report_db.remarks_for_editors, 'Lorem Ipsum1') + self.assertEqual(report_db.requested_changes, 'Lorem Ipsum2') + self.assertEqual(report_db.strengths, 'Lorem Ipsum3') + self.assertEqual(report_db.weaknesses, 'Lorem Ipsum4') + + @tag('reports') + def test_post_report(self): + '''Test response of view if report submitted.''' + response = self.client.post(self.target, {**self.TEST_DATA, 'save_submit': 'True'}) + + # Check if user is redirected + self.assertEqual(response.status_code, 302) + + # Briefly do cross checks if report submit is complete + report_db = Report.objects.last() + self.assertEqual(report_db.status, STATUS_UNVETTED) + + # Check if invited value has only changed if valid to do so + self.assertIsNone(self.submission.referee_invitations + .filter(referee=self.current_contrib).first()) + self.assertFalse(report_db.invited) + + # Cross-check if flagged can't be assigned, as this should only happen if author is + # flagged on the submission involved + self.assertIsNone(self.submission.referees_flagged) + self.assertFalse(report_db.flagged) + + self.assertTrue(report_db.anonymous) + self.assertEqual(report_db.clarity, 60) + self.assertEqual(report_db.formatting, 4) + self.assertEqual(report_db.grammar, 5) + self.assertEqual(report_db.originality, 100) + self.assertEqual(report_db.qualification, 3) + self.assertEqual(report_db.significance, 0) + self.assertEqual(report_db.validity, 60) + self.assertEqual(report_db.remarks_for_editors, 'Lorem Ipsum1') + self.assertEqual(report_db.requested_changes, 'Lorem Ipsum2') + self.assertEqual(report_db.strengths, 'Lorem Ipsum3') + self.assertEqual(report_db.weaknesses, 'Lorem Ipsum4') + + @tag('reports') + def test_post_report_flagged_author(self): + '''Test if report is `flagged` if author is flagged on related submission.''' + report_deadline = Faker().date_time_between(start_date="now", end_date="+30d", tzinfo=None) + submission = EICassignedSubmissionFactory(reporting_deadline=report_deadline, + referees_flagged=str(self.current_contrib)) + submission.authors.remove(self.current_contrib) + submission.authors_false_claims.add(self.current_contrib) + + target = reverse('submissions:submit_report', args=(submission.arxiv_identifier_w_vn_nr,)) + client = Client() + + # Login and call view + self.assertTrue(client.login(username="Test", password="testpw")) + self.TEST_DATA['save_submit'] = 'Submit your report' + response = client.post(target, self.TEST_DATA) + self.assertEqual(response.status_code, 302) + + # Briefly checks if report is valid + report_db = Report.objects.last() + self.assertEqual(report_db.status, STATUS_UNVETTED) + self.assertTrue(report_db.flagged) + + @tag('reports') + def test_post_report_with_invitation(self): + '''Test if report is submission is valid using invitation.''' + AcceptedRefereeInvitationFactory(submission=self.submission, referee=self.current_contrib) + + # Post Data + response = self.client.post(self.target, {**self.TEST_DATA, 'save_submit': 'True'}) + self.assertEqual(response.status_code, 302) + + # Briefly checks if report is valid + report_db = Report.objects.last() + self.assertEqual(report_db.status, STATUS_UNVETTED) + self.assertTrue(report_db.invited) + + # Check if Invitation has changed correctly + invitation = RefereeInvitation.objects.last() + self.assertEqual(invitation.referee, self.current_contrib) + self.assertEqual(invitation.submission, self.submission) + self.assertTrue(invitation.fulfilled) diff --git a/submissions/urls.py b/submissions/urls.py index bdd6d86f6cc0619472dad96b3bd14996cf081713..6e776fcf71b60a8500c7f7fc8657ac7eb0f9e8e2 100644 --- a/submissions/urls.py +++ b/submissions/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import include, url +from django.conf.urls import url from django.views.generic import TemplateView from . import views @@ -6,7 +6,8 @@ from . import views urlpatterns = [ # Submissions url(r'^$', views.SubmissionListView.as_view(), name='submissions'), - url(r'^browse/(?P<discipline>[a-z]+)/(?P<nrweeksback>[0-9]+)/$', views.SubmissionListView.as_view(), name='browse'), + url(r'^browse/(?P<discipline>[a-z]+)/(?P<nrweeksback>[0-9]{1,3})/$', + views.SubmissionListView.as_view(), name='browse'), url(r'^sub_and_ref_procedure$', TemplateView.as_view(template_name='submissions/sub_and_ref_procedure.html'), name='sub_and_ref_procedure'), @@ -69,17 +70,12 @@ urlpatterns = [ views.communication, name='communication'), url(r'^eic_recommendation/(?P<arxiv_identifier_w_vn_nr>[0-9]{4,}.[0-9]{5,}v[0-9]{1,2})$', views.eic_recommendation, name='eic_recommendation'), - url(r'^cycle/(?P<arxiv_identifier_w_vn_nr>[0-9]{4,}.[0-9]{5,}v[0-9]{1,2})/submit$', views.cycle_form_submit, - name='cycle_confirmation'), + url(r'^cycle/(?P<arxiv_identifier_w_vn_nr>[0-9]{4,}.[0-9]{5,}v[0-9]{1,2})/submit$', + views.cycle_form_submit, name='cycle_confirmation'), # Reports url(r'^submit_report/(?P<arxiv_identifier_w_vn_nr>[0-9]{4,}.[0-9]{5,}v[0-9]{1,2})$', views.submit_report, name='submit_report'), - url(r'^submit_report_ack$', - TemplateView.as_view(template_name='submissions/submit_report_ack.html'), name='submit_report_ack'), - url(r'^vet_submitted_reports$', - views.vet_submitted_reports, name='vet_submitted_reports'), - url(r'^vet_submitted_report_ack/(?P<report_id>[0-9]+)$', - views.vet_submitted_report_ack, name='vet_submitted_report_ack'), + url(r'^vet_submitted_reports$', views.vet_submitted_reports, name='vet_submitted_reports'), # Voting url(r'^prepare_for_voting/(?P<rec_id>[0-9]+)$', views.prepare_for_voting, name='prepare_for_voting'), url(r'^vote_on_rec/(?P<rec_id>[0-9]+)$', views.vote_on_rec, name='vote_on_rec'), diff --git a/submissions/utils.py b/submissions/utils.py index 29ed7e85be4febc089c51bd4b476b66f3db4c21a..302496959f2b8ee166e16745553014af1323b828 100644 --- a/submissions/utils.py +++ b/submissions/utils.py @@ -4,7 +4,8 @@ from django.core.mail import EmailMessage, EmailMultiAlternatives from django.template import Context, Template from django.utils import timezone -from .constants import NO_REQUIRED_ACTION_STATUSES,\ +from .constants import NO_REQUIRED_ACTION_STATUSES, STATUS_VETTED, STATUS_UNCLEAR,\ + STATUS_INCORRECT, STATUS_NOT_USEFUL, STATUS_NOT_ACADEMIC,\ STATUS_REVISION_REQUESTED, STATUS_EIC_ASSIGNED,\ STATUS_RESUBMISSION_INCOMING, STATUS_AWAITING_ED_REC from .exceptions import CycleUpdateDeadlineError @@ -1023,7 +1024,7 @@ class SubmissionUtils(BaseMailUtil): '<p>Dear {{ ref_title }} {{ ref_last_name }},</p>' '<p>Many thanks for your Report on Submission</p>' '<p>{{ sub_title }}</p>\n<p>by {{ author_list }}.</p>') - if cls.report.status == 1: + if cls.report.status == STATUS_VETTED: email_text += ('\n\nYour Report has been vetted through and is viewable at ' 'https://scipost.org/submissions/' + cls.report.submission.arxiv_identifier_w_vn_nr + '.') @@ -1047,7 +1048,7 @@ class SubmissionUtils(BaseMailUtil): '\n\nThe SciPost Team.') email_text_html += ('<p>Many thanks for your collaboration,</p>' '<p>The SciPost Team.</p>') - if cls.report.status != 1: + if cls.report.status != STATUS_VETTED: if cls.email_response is not None: email_text += '\n\nAdditional info from the Editor-in-charge: \n' email_text += cls.email_response @@ -1078,7 +1079,8 @@ class SubmissionUtils(BaseMailUtil): 'requested_changes': cls.report.requested_changes, 'remarks_for_editors': cls.report.remarks_for_editors, }) - if cls.report.status < 0: + if cls.report.status in [STATUS_UNCLEAR, STATUS_INCORRECT, + STATUS_NOT_USEFUL, STATUS_NOT_ACADEMIC]: email_context['refusal_reason'] = cls.report.get_status_display() email_text_html += '<br/>' + EMAIL_FOOTER html_template = Template(email_text_html) diff --git a/submissions/views.py b/submissions/views.py index 56fc14ca6c9a17d72555f191f449c9b4eea76942..fbacb1185dc49a84233c28c8b83ab8c24e3cd59a 100644 --- a/submissions/views.py +++ b/submissions/views.py @@ -6,7 +6,7 @@ from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.models import Group from django.core.urlresolvers import reverse, reverse_lazy from django.db import transaction -from django.http import Http404 +from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404, render, redirect from django.template import Template, Context from django.utils import timezone @@ -15,8 +15,9 @@ from django.utils.decorators import method_decorator from guardian.decorators import permission_required_or_403 from guardian.shortcuts import assign_perm -from .constants import SUBMISSION_STATUS_VOTING_DEPRECATED,\ - SUBMISSION_STATUS_PUBLICLY_INVISIBLE, SUBMISSION_STATUS, ED_COMM_CHOICES +from .constants import SUBMISSION_STATUS_VOTING_DEPRECATED, STATUS_VETTED, STATUS_EIC_ASSIGNED,\ + SUBMISSION_STATUS_PUBLICLY_INVISIBLE, SUBMISSION_STATUS, ED_COMM_CHOICES,\ + STATUS_DRAFT from .models import Submission, EICRecommendation, EditorialAssignment,\ RefereeInvitation, Report, EditorialCommunication from .forms import SubmissionIdentifierForm, RequestSubmissionForm, SubmissionSearchForm,\ @@ -75,10 +76,9 @@ class RequestSubmission(CreateView): # Send emails SubmissionUtils.load({'submission': submission}) SubmissionUtils.send_authors_submission_ack_email() - return super().form_valid(form) + return HttpResponseRedirect(self.success_url) def form_invalid(self, form): - # r = form.errors for error_messages in form.errors.values(): messages.warning(self.request, *error_messages) return super().form_invalid(form) @@ -168,12 +168,18 @@ def submission_detail(request, arxiv_identifier_w_vn_nr): submission = get_object_or_404(Submission, arxiv_identifier_w_vn_nr=arxiv_identifier_w_vn_nr) try: is_author = request.user.contributor in submission.authors.all() - is_author_unchecked = (not is_author and not - (request.user.contributor in submission.authors_false_claims.all()) and - (request.user.last_name in submission.author_list)) + is_author_unchecked = (not is_author and + request.user.contributor not in submission.authors_false_claims.all() + and request.user.last_name in submission.author_list) + try: + unfinished_report_for_user = (submission.reports.in_draft() + .get(author=request.user.contributor)) + except Report.DoesNotExist: + unfinished_report_for_user = None except AttributeError: is_author = False is_author_unchecked = False + unfinished_report_for_user = None if (submission.status in SUBMISSION_STATUS_PUBLICLY_INVISIBLE and not request.user.groups.filter(name__in=['SciPost Administrators', 'Editorial Administrators', @@ -188,8 +194,10 @@ def submission_detail(request, arxiv_identifier_w_vn_nr): invited_reports = submission.reports.accepted().filter(invited=True) contributed_reports = submission.reports.accepted().filter(invited=False) - comments = submission.comments.vetted().filter(is_author_reply=False).order_by('-date_submitted') - author_replies = submission.comments.vetted().filter(is_author_reply=True).order_by('-date_submitted') + comments = (submission.comments.vetted() + .filter(is_author_reply=False).order_by('-date_submitted')) + author_replies = (submission.comments.vetted() + .filter(is_author_reply=True).order_by('-date_submitted')) try: recommendation = (EICRecommendation.objects.filter_for_user(request.user) @@ -203,6 +211,7 @@ def submission_detail(request, arxiv_identifier_w_vn_nr): 'comments': comments, 'invited_reports': invited_reports, 'contributed_reports': contributed_reports, + 'unfinished_report_for_user': unfinished_report_for_user, 'author_replies': author_replies, 'form': form, 'is_author': is_author, @@ -521,8 +530,8 @@ def editorial_page(request, arxiv_identifier_w_vn_nr): .filter(arxiv_identifier_wo_vn_nr=submission.arxiv_identifier_wo_vn_nr) .exclude(pk=submission.id)) ref_invitations = RefereeInvitation.objects.filter(submission=submission) - nr_reports_to_vet = (Report.objects - .filter(status=0, submission=submission, + nr_reports_to_vet = (Report.objects.awaiting_vetting() + .filter(submission=submission, submission__editor_in_charge=request.user.contributor) .count()) communications = (EditorialCommunication.objects @@ -862,9 +871,9 @@ def close_refereeing_round(request, arxiv_identifier_w_vn_nr): @permission_required('scipost.can_oversee_refereeing', raise_exception=True) def refereeing_overview(request): - submissions_under_refereeing = Submission.objects.filter( - status='EICassigned').order_by('submission_date') - context= {'submissions_under_refereeing': submissions_under_refereeing,} + submissions_under_refereeing = (Submission.objects.filter(status=STATUS_EIC_ASSIGNED) + .order_by('submission_date')) + context = {'submissions_under_refereeing': submissions_under_refereeing} return render(request, 'submissions/refereeing_overview.html', context) @@ -980,28 +989,42 @@ def eic_recommendation(request, arxiv_identifier_w_vn_nr): ########### # Reports ########### - @login_required @permission_required('scipost.can_referee', raise_exception=True) @transaction.atomic def submit_report(request, arxiv_identifier_w_vn_nr): - submission = get_object_or_404(Submission.objects.all(), + """ + A form to submit a report on a submission will be shown and processed here. + + Important checks to be aware of include an author check for the submission, + has the reporting deadline not been reached yet and does there exist any invitation + for the current user on this submission. + """ + submission = get_object_or_404(Submission.objects.open_for_reporting(), arxiv_identifier_w_vn_nr=arxiv_identifier_w_vn_nr) # Check whether the user can submit a report: - is_author = request.user.contributor in submission.authors.all() + current_contributor = request.user.contributor + is_author = current_contributor in submission.authors.all() is_author_unchecked = (not is_author and not - (request.user.contributor in submission.authors_false_claims.all()) and + (current_contributor in submission.authors_false_claims.all()) and (request.user.last_name in submission.author_list)) - try: - invitation = RefereeInvitation.objects.get(submission=submission, - referee=request.user.contributor) - except RefereeInvitation.DoesNotExist: - invitation = None + invitation = submission.referee_invitations.filter(referee=current_contributor).first() errormessage = None - if not invitation and timezone.now() > submission.reporting_deadline + datetime.timedelta(days=1): - errormessage = ('The reporting deadline has passed. You cannot submit' - ' a Report anymore.') + if not invitation: + if timezone.now() > submission.reporting_deadline + datetime.timedelta(days=1): + errormessage = ('The reporting deadline has passed. You cannot submit' + ' a Report anymore.') + elif not submission.open_for_reporting: + errormessage = ('Reporting for this submission has closed. You cannot submit' + ' a Report anymore.') + + if errormessage: + # Remove old drafts from the database + reports_in_draft_to_remove = (submission.reports.in_draft() + .filter(author=current_contributor)) + if reports_in_draft_to_remove: + reports_in_draft_to_remove.delete() if is_author: errormessage = 'You are an author of this Submission and cannot submit a Report.' if is_author_unchecked: @@ -1012,34 +1035,27 @@ def submit_report(request, arxiv_identifier_w_vn_nr): messages.warning(request, errormessage) return redirect(reverse('scipost:personal_page')) - form = ReportForm(request.POST or None) - if form.is_valid(): - author = request.user.contributor - newreport = form.save(commit=False) - newreport.submission = submission - newreport.author = request.user.contributor - if invitation: - invitation.fulfilled = True - newreport.invited = True - invitation.save() - - if submission.referees_flagged is not None: - if author.user.last_name in submission.referees_flagged: - newreport.flagged = True - - newreport.date_submitted = timezone.now() - newreport.save() + # Find and fill earlier version of report + try: + report_in_draft = submission.reports.in_draft().get(author=current_contributor) + except Report.DoesNotExist: + report_in_draft = None + form = ReportForm(request.POST or None, instance=report_in_draft) - # Update user stats - author.nr_reports = Report.objects.filter(author=author).count() - author.save() + # Check if data sent is valid + if form.is_valid(): + newreport = form.save(submission, current_contributor) + if newreport.status == STATUS_DRAFT: + messages.success(request, ('Your Report has been saved. ' + 'You may leave the page and finish it later.')) + context = {'submission': submission, 'form': form} + return render(request, 'submissions/submit_report.html', context) + + # Send mails if report is submitted SubmissionUtils.load({'report': newreport}, request) SubmissionUtils.email_EIC_report_delivered() SubmissionUtils.email_referee_report_delivered() - # Why is this session update? - request.session['arxiv_identifier_w_vn_nr'] = arxiv_identifier_w_vn_nr - messages.success(request, 'Thank you for your Report') return redirect(reverse('scipost:personal_page')) @@ -1050,42 +1066,39 @@ def submit_report(request, arxiv_identifier_w_vn_nr): @login_required @permission_required('scipost.can_take_charge_of_submissions', raise_exception=True) def vet_submitted_reports(request): - contributor = Contributor.objects.get(user=request.user) - report_to_vet = Report.objects.filter(status=0, - submission__editor_in_charge=contributor).first() - form = VetReportForm() - context = {'contributor': contributor, 'report_to_vet': report_to_vet, 'form': form} - return(render(request, 'submissions/vet_submitted_reports.html', context)) + """ + Reports with status `unvetted` will be shown one-by-one. An user may only + vet reports of submissions he/she is EIC of. + After vetting an email is sent to the report author, bcc EIC. If report + has not been refused, the submission author is also mailed. + """ + contributor = Contributor.objects.get(user=request.user) + report_to_vet = (Report.objects.awaiting_vetting() + .select_related('submission') + .filter(submission__editor_in_charge=contributor).first()) -@permission_required('scipost.can_take_charge_of_submissions', raise_exception=True) -@transaction.atomic -def vet_submitted_report_ack(request, report_id): - report = get_object_or_404(Report, pk=report_id, - submission__editor_in_charge=request.user.contributor) - form = VetReportForm(request.POST or None) + form = VetReportForm(request.POST or None, initial={'report': report_to_vet}) if form.is_valid(): - report.vetted_by = request.user.contributor - if form.cleaned_data['action_option'] == '1': - # accept the report as is - report.status = 1 - report.save() - report.submission.latest_activity = timezone.now() - report.submission.save() - elif form.cleaned_data['action_option'] == '2': - # the report is simply rejected - report.status = int(form.cleaned_data['refusal_reason']) - report.save() + report = form.process_vetting(request.user.contributor) + # email report author SubmissionUtils.load({'report': report, 'email_response': form.cleaned_data['email_response_field']}) SubmissionUtils.acknowledge_report_email() # email report author, bcc EIC - if report.status == 1: + if report.status == STATUS_VETTED: SubmissionUtils.send_author_report_received_email() - messages.success(request, 'Submitted Report vetted.') - return redirect(reverse('submissions:editorial_page', - args=[report.submission.arxiv_identifier_w_vn_nr])) - return redirect(reverse('submissions:vet_submitted_reports')) + + message = 'Submitted Report vetted for <a href="%s">%s</a>.' % ( + reverse('submissions:editorial_page', + args=(report.submission.arxiv_identifier_w_vn_nr,)), + report.submission.arxiv_identifier_w_vn_nr + ) + messages.success(request, message) + # Redirect instead of render to loose the POST call and make it a GET + return redirect(reverse('submissions:vet_submitted_reports')) + context = {'contributor': contributor, 'report_to_vet': report_to_vet, 'form': form} + return render(request, 'submissions/vet_submitted_reports.html', context) @permission_required('scipost.can_prepare_recommendations_for_voting', raise_exception=True) diff --git a/templates/email/email_prospartner_contact.html b/templates/email/email_prospartner_contact.html new file mode 100644 index 0000000000000000000000000000000000000000..a8ab2e30ae77fe9f3bbb56ddcaf793dc873488dc --- /dev/null +++ b/templates/email/email_prospartner_contact.html @@ -0,0 +1,29 @@ +Dear {{ contact.get_title_display }} {{ contact.last_name }}, \n\n + +{% if message %}{{ message }}{% endif %} + +{% if include_SPB_summary %} +You might by now have heard of SciPost, a recently-launched initiative aiming to bring disruptive change to current academic publishing practices. +\n\nIn summary, SciPost.org is a publication portal managed by professional scientists, offering (among others) high-quality Open Access journals with innovative forms of refereeing, and a means of commenting on all existing literature. SciPost is established as a not-for-profit foundation devoted to serving the interests of the international scientific community. +\n\nThe site is anchored at https://scipost.org. Many further details about SciPost, its principles, ideals and implementation can be found at https://scipost.org/about and https://scipost.org/FAQ. + +\n\nCrucially, as explained on our Partners page at https://scipost.org/partners, 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 financed through a Supporting Partners Board, formed by a worldwide collection of institutions and organizations which directly or indirectly benefit from SciPost’s activities. + +\n\nSupport takes the form of a small financial commitment, collectively pooled to enable SciPost to perform all its publication-related activities, maintain its online portal and implement its long-term development plan. + +\n\nIn the agreement template, which you can find online at https://scipost.org/static/scipost/SPB/SciPost_Supporting_Partner_Agreement.pdf, 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. + +\n\nIt would be a privilege to welcome you as members of our Supporting Partners Board, and are hereby contacting you to enquire whether your institution would consider joining. Your support at this time is crucially required to make our initiative viable, and to help make it possible for the community to reap all the benefits deriving form its viable implementation. + +\n\nI will be happy to provide any required further details. In the meantime, I send you my best wishes and sincerely hope that SciPost will be able to count on your support. + +\n\nOn behalf of the SciPost Foundation, +\n\nProf. dr Jean-Sébastien Caux +\n--------------------------------------------- +\nInstitute for Theoretial Physics\nUniversity of Amsterdam +\nScience Park 904\n1098 XH Amsterdam\nThe Netherlands +\n--------------------------------------------- +\ntel.: +31 (0)20 5255775\nfax: +31 (0)20 5255778 +\n--------------------------------------------- + +{% endif %} diff --git a/templates/email/email_prospartner_contact_html.html b/templates/email/email_prospartner_contact_html.html new file mode 100644 index 0000000000000000000000000000000000000000..b1db68a11e230cec99aebc217ca27c73733fbd85 --- /dev/null +++ b/templates/email/email_prospartner_contact_html.html @@ -0,0 +1,47 @@ +{% load staticfiles %} +<p>Dear {{ contact.get_title_display }} {{ contact.last_name }},</p> + +{% if message %} +<p> + {{ message|linebreaks }} +</p> +{% endif %} + +{% if include_SPB_summary %} +<p> + You might by now have heard of SciPost, a recently-launched initiative aiming to bring disruptive change to current academic publishing practices. +</p> +<p> + In summary, SciPost.org is a publication portal managed by professional scientists, offering (among others) high-quality Open Access journals with innovative forms of refereeing, and a means of commenting on all existing literature. SciPost is established as a not-for-profit foundation devoted to serving the interests of the international scientific community. +</p> +<p> + The site is anchored at <a href="https://scipost.org">SciPost.org</a>. Many further details about SciPost, its principles, ideals and implementation can be found on the <a href="https://scipost.org/about">about</a> and <a href="https://scipost.org/FAQ">FAQ</a> pages. +</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 financed through a Supporting Partners Board, formed by a worldwide collection of institutions and organizations which directly or indirectly benefit from SciPost’s activities. +</p> +<p> + Support takes the form of a small financial commitment, collectively pooled to enable SciPost to perform all its publication-related activities, maintain its online portal and implement its long-term development plan. +</p> +<p> + In the <a href="{% static 'scipost/SPB/SciPost_Supporting_Partner_Agreement.pdf' %}">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, and are hereby contacting you to enquire whether your institution would consider joining. Your support at this time is crucially required to make our initiative viable, 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. In the meantime, I send you my best wishes and sincerely hope that SciPost will be able to count on your support. +</p> +<p>On behalf of the SciPost Foundation,</p> +<br/>Prof. dr Jean-Sébastien Caux +<br/>--------------------------------------------- +<br/>Institute for Theoretial Physics +<br/>University of Amsterdam +<br/>Science Park 904 +<br/>1098 XH Amsterdam +<br/>The Netherlands +<br/>--------------------------------------------- +<br/>tel.: +31 (0)20 5255775 +<br/>fax: +31 (0)20 5255778 +<br/>--------------------------------------------- +{% endif %} diff --git a/theses/admin.py b/theses/admin.py index 61338e88abe0453a93254d180c9dbeff4dd463b7..762043c8deb4a77bade074668e87e1417f9c3db0 100644 --- a/theses/admin.py +++ b/theses/admin.py @@ -1,9 +1,29 @@ from django.contrib import admin +from django import forms + from theses.models import * +from scipost.models import Contributor + + +class ThesisLinkAdminForm(forms.ModelForm): + author_claims = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.order_by('user__last_name')) + author_false_claims = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.order_by('user__last_name')) + supervisor_as_cont = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.order_by('user__last_name')) + + class Meta: + model = ThesisLink + fields = '__all__' class ThesisLinkAdmin(admin.ModelAdmin): search_fields = ['requested_by__user__username', 'author', 'title'] + form = ThesisLinkAdminForm admin.site.register(ThesisLink, ThesisLinkAdmin) diff --git a/theses/urls.py b/theses/urls.py index 5f8baf0e35cb4f7e7564746256b3cb99b58589a8..392a03a506201c587c66fcd1443cacf865db753c 100644 --- a/theses/urls.py +++ b/theses/urls.py @@ -6,7 +6,7 @@ from . import views urlpatterns = [ # Thesis Links url(r'^$', views.ThesisListView.as_view(), name='theses'), - url(r'^browse/(?P<discipline>[a-z]+)/(?P<nrweeksback>[0-9]+)/$', views.ThesisListView.as_view(), name='browse'), + url(r'^browse/(?P<discipline>[a-z]+)/(?P<nrweeksback>[0-9]{1,3})/$', views.ThesisListView.as_view(), name='browse'), url(r'^(?P<thesislink_id>[0-9]+)/$', views.thesis_detail, name='thesis'), url(r'^request_thesislink$', views.RequestThesisLink.as_view(), name='request_thesislink'), url(r'^unvetted_thesislinks$', views.UnvettedThesisLinks.as_view(), name='unvetted_thesislinks'), diff --git a/virtualmeetings/admin.py b/virtualmeetings/admin.py index c831a447b58b6d95edd086fda5750bbfc5e315c6..6baf0995ccc27f01638926563db60ed849deebda 100644 --- a/virtualmeetings/admin.py +++ b/virtualmeetings/admin.py @@ -1,7 +1,11 @@ from django.contrib import admin +from django import forms + from .models import VGM, Feedback, Nomination, Motion +from scipost.models import Contributor + class VGMAdmin(admin.ModelAdmin): search_fields = ['start_date'] @@ -17,15 +21,57 @@ class FeedbackAdmin(admin.ModelAdmin): admin.site.register(Feedback, FeedbackAdmin) +class NominationAdminForm(forms.ModelForm): + in_agreement = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.filter( + user__groups__name__in=['Editorial College'], + ).order_by('user__last_name')) + in_notsure = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.filter( + user__groups__name__in=['Editorial College'], + ).order_by('user__last_name')) + in_disagreement = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.filter( + user__groups__name__in=['Editorial College'], + ).order_by('user__last_name')) + + class Meta: + model = Nomination + fields = '__all__' + class NominationAdmin(admin.ModelAdmin): search_fields = ['last_name', 'first_name', 'by'] - + form = NominationAdminForm admin.site.register(Nomination, NominationAdmin) +class MotionAdminForm(forms.ModelForm): + in_agreement = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.filter( + user__groups__name__in=['Editorial College'], + ).order_by('user__last_name')) + in_notsure = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.filter( + user__groups__name__in=['Editorial College'], + ).order_by('user__last_name')) + in_disagreement = forms.ModelMultipleChoiceField( + required=False, + queryset=Contributor.objects.filter( + user__groups__name__in=['Editorial College'], + ).order_by('user__last_name')) + + class Meta: + model = Motion + fields = '__all__' + class MotionAdmin(admin.ModelAdmin): search_fields = ['background', 'motion', 'put_forward_by'] - + form = MotionAdminForm admin.site.register(Motion, MotionAdmin)