From f5ad85bfedb90e0ea298ad7d532e3c30a3363c0a Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Tue, 24 Feb 2026 16:02:00 +0000 Subject: [PATCH] feat: adds VAC option along side waivers. --- admin.py | 7 + forms.py | 114 ++++++-- hooks.py | 65 +++-- install/settings.json | 60 +++++ logic.py | 67 +++++ migrations/0017_voluntarycontribution.py | 74 +++++ models.py | 41 +++ plugin_settings.py | 20 ++ templates/apc/index.html | 3 + templates/apc/settings.html | 46 ++-- templates/apc/vac_list.html | 104 ++++++++ templates/apc/vac_optin.html | 12 + tests.py | 138 ++++++++++ urls.py | 10 + views.py | 326 +++++++++++------------ 15 files changed, 856 insertions(+), 231 deletions(-) create mode 100644 migrations/0017_voluntarycontribution.py create mode 100644 templates/apc/vac_list.html create mode 100644 templates/apc/vac_optin.html create mode 100644 tests.py diff --git a/admin.py b/admin.py index af402ec..f7eff19 100644 --- a/admin.py +++ b/admin.py @@ -28,11 +28,18 @@ class BillingStafferAdmin(admin.ModelAdmin): raw_id_fields = ('journal', 'staffer') +class VoluntaryContributionAdmin(admin.ModelAdmin): + list_display = ('pk', 'article', 'value', 'currency', 'recorded', 'contacted') + list_filter = ('contacted', 'currency') + raw_id_fields = ('article', 'section_apc') + + admin_list = [ (SectionAPC, SectionAPCAdmin), (WaiverApplication, WaiverApplicationAdmin), (ArticleAPC, ArticleAPCAdmin), (BillingStaffer, BillingStafferAdmin), + (VoluntaryContribution, VoluntaryContributionAdmin), (Discount,), ] diff --git a/forms.py b/forms.py index 8f17d26..5d0596d 100644 --- a/forms.py +++ b/forms.py @@ -1,14 +1,15 @@ from django import forms from django.forms.forms import NON_FIELD_ERRORS -from plugins.apc import models +from plugins.apc import models, plugin_settings as apc_plugin_settings from core import models as core_models +from utils import setting_handler as core_setting_handler class APCForm(forms.ModelForm): class Meta: model = models.SectionAPC - exclude = ('section',) + exclude = ("section",) def save(self, section, commit=True): section_apc = super(APCForm, self).save(commit=False) @@ -21,46 +22,123 @@ def save(self, section, commit=True): class WaiverResponse(forms.ModelForm): - class Meta: model = models.WaiverApplication - fields = ('response',) + fields = ("response",) class WaiverApplication(forms.ModelForm): - class Meta: model = models.WaiverApplication - fields = ('rationale',) + fields = ("rationale",) + + +class APCSettingsForm(forms.Form): + enable_apcs = forms.BooleanField( + required=False, + widget=forms.CheckboxInput(attrs={"id": "enable_apcs"}), + ) + track_apcs = forms.BooleanField( + required=False, + widget=forms.CheckboxInput(attrs={"id": "track_apcs"}), + ) + author_contribution_mode = forms.ChoiceField( + choices=[ + ("", "None"), + ("waiver", "Waiver"), + ("vac", "Voluntary Author Contribution (VAC)"), + ], + required=False, + widget=forms.Select(attrs={"id": "author_contribution_mode"}), + ) + waiver_text = forms.CharField( + required=False, + widget=forms.Textarea(attrs={"id": "waiver_text"}), + ) + vac_text = forms.CharField( + required=False, + widget=forms.Textarea(attrs={"id": "vac_text"}), + ) + + def __init__(self, *args, plugin=None, journal=None, **kwargs): + super().__init__(*args, **kwargs) + self.plugin = plugin + self.journal = journal + for field_name, pretty in apc_plugin_settings.APC_SETTINGS.items(): + setting = core_setting_handler.get_plugin_setting( + plugin, + field_name, + journal, + create=True, + pretty=pretty, + ) + self.fields[field_name].label = setting.setting.pretty_name + self.fields[field_name].help_text = setting.setting.description + if not self.is_bound: + if field_name in apc_plugin_settings.APC_BOOLEAN_SETTINGS: + self.initial[field_name] = setting.value == "on" + else: + self.initial[field_name] = setting.value or "" + + def save(self): + data = self.cleaned_data + for field_name in apc_plugin_settings.APC_SETTINGS: + if field_name in apc_plugin_settings.APC_BOOLEAN_SETTINGS: + value = "on" if data[field_name] else "" + else: + value = data[field_name] + core_setting_handler.save_plugin_setting( + self.plugin, + field_name, + value, + self.journal, + ) -class BillingStafferForm(forms.ModelForm): +class VACFilterForm(forms.Form): + accepted = forms.ChoiceField( + choices=[("", "All"), ("yes", "Accepted"), ("no", "Not Accepted")], + required=False, + label="Filter by acceptance", + widget=forms.Select(attrs={"onchange": "this.form.submit()"}), + ) + contacted = forms.ChoiceField( + choices=[("", "All"), ("yes", "Contacted"), ("no", "Not Contacted")], + required=False, + label="Filter by contacted", + widget=forms.Select(attrs={"onchange": "this.form.submit()"}), + ) + +class BillingStafferForm(forms.ModelForm): class Meta: model = models.BillingStaffer - fields = ('staffer', 'type_of_notification', 'receives_notifications') + fields = ("staffer", "type_of_notification", "receives_notifications") def __init__(self, *args, **kwargs): - self.request = kwargs.pop('request') + self.request = kwargs.pop("request") super(BillingStafferForm, self).__init__(*args, **kwargs) user_pks = self.request.journal.journal_users(objects=False) - self.fields['staffer'].queryset = core_models.Account.objects.filter( + self.fields["staffer"].queryset = core_models.Account.objects.filter( pk__in=user_pks, ) def clean(self): - staffer = self.cleaned_data.get('staffer') - type_of_notification = self.cleaned_data.get('type_of_notification') + staffer = self.cleaned_data.get("staffer") + type_of_notification = self.cleaned_data.get("type_of_notification") journal = self.request.journal - if not self.instance.pk and models.BillingStaffer.objects.filter( - staffer=staffer, - journal=journal, - type_of_notification=type_of_notification, - ).exists(): + if ( + not self.instance.pk + and models.BillingStaffer.objects.filter( + staffer=staffer, + journal=journal, + type_of_notification=type_of_notification, + ).exists() + ): self._errors[NON_FIELD_ERRORS] = self.error_class( - ['A Billing Staffer with this user, journal and type already exists.'] + ["A Billing Staffer with this user, journal and type already exists."] ) return self.cleaned_data diff --git a/hooks.py b/hooks.py index ca3473a..45f7997 100644 --- a/hooks.py +++ b/hooks.py @@ -43,28 +43,41 @@ def waiver_info(context): plugin = plugin_settings.get_self() request = context['request'] - waiver_text = setting_handler.get_plugin_setting( + author_contribution_mode = setting_handler.get_plugin_setting( plugin, - 'waiver_text', + 'author_contribution_mode', request.journal, create=True, - pretty='Waiver Text', - ) - enable_waivers = setting_handler.get_plugin_setting( - plugin, - 'enable_waivers', - request.journal, - create=True, - pretty='Enable Waivers', + pretty='Author Contribution Mode', ) - if enable_waivers.value == 'on': + mode = author_contribution_mode.value if author_contribution_mode else '' + + if mode == 'vac': + vac_text = setting_handler.get_plugin_setting( + plugin, + 'vac_text', + request.journal, + create=True, + pretty='VAC Text', + ) + return render_to_string( + 'apc/vac_optin.html', + {'request': request, 'vac_text': vac_text.value if vac_text else ''}, + ) + elif mode == 'waiver': + waiver_text = setting_handler.get_plugin_setting( + plugin, + 'waiver_text', + request.journal, + create=True, + pretty='Waiver Text', + ) return render_to_string( 'apc/waiver_info.html', {'request': request, 'waiver_text': waiver_text.value}, ) - else: - return '' + return '' def waiver_application(context): @@ -72,21 +85,22 @@ def waiver_application(context): request = context['request'] article = context['article'] - waiver_text = setting_handler.get_plugin_setting( - plugin, - 'waiver_text', - request.journal, - create=True, - pretty='Waiver Text', - ) - enable_waivers = setting_handler.get_plugin_setting( + author_contribution_mode = setting_handler.get_plugin_setting( plugin, - 'enable_waivers', + 'author_contribution_mode', request.journal, create=True, - pretty='Enable Waivers', + pretty='Author Contribution Mode', ) - if enable_waivers.value == 'on': + + if author_contribution_mode and author_contribution_mode.value == 'waiver': + waiver_text = setting_handler.get_plugin_setting( + plugin, + 'waiver_text', + request.journal, + create=True, + pretty='Waiver Text', + ) return render_to_string( 'apc/article_waiver_app.html', { @@ -95,5 +109,4 @@ def waiver_application(context): 'article': article, }, ) - else: - return '' + return '' diff --git a/install/settings.json b/install/settings.json index f239f6b..63b4c72 100644 --- a/install/settings.json +++ b/install/settings.json @@ -178,5 +178,65 @@ "value": { "default": "Article Waiver Application" } + }, + { + "group": { + "name": "plugin:apc" + }, + "setting": { + "description": "Controls the author contribution mode: empty for none, 'waiver' for APC waivers, 'vac' for voluntary author contributions", + "is_translatable": false, + "name": "author_contribution_mode", + "pretty_name": "Author Contribution Mode", + "type": "char" + }, + "value": { + "default": "" + } + }, + { + "group": { + "name": "plugin:apc" + }, + "setting": { + "description": "Controls the text displayed to authors on the submission review page when voluntary contributions are enabled", + "is_translatable": true, + "name": "vac_text", + "pretty_name": "VAC Text", + "type": "rich-text" + }, + "value": { + "default": "" + } + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to Billing Staffer when a voluntary author contribution is ready.", + "is_translatable": true, + "name": "apc_vac_contribution_ready", + "pretty_name": "APC Plugin: Voluntary Contribution Ready", + "type": "rich-text" + }, + "value": { + "default": "

Dear {{ billing_staffer.staffer.full_name }},

This is a notification from {{ request.journal.name }}. Article #{{ article.pk }} '{{ article.title|safe }}' has been accepted and the author opted in to a voluntary contribution.

View the APC Dashboard {{ apc_index_value }}

" + } + }, + { + "group": { + "name": "email_subject" + }, + "setting": { + "description": "Email subject for email sent to Billing Staffer on voluntary contribution.", + "is_translatable": true, + "name": "subject_apc_vac_contribution_ready", + "pretty_name": "APC Plugin: Voluntary Contribution Ready Subject", + "type": "char" + }, + "value": { + "default": "Voluntary Author Contribution Ready" + } } ] diff --git a/logic.py b/logic.py index 659c70e..765dd5c 100644 --- a/logic.py +++ b/logic.py @@ -1,6 +1,8 @@ +from django.conf import settings as django_settings from django.shortcuts import get_object_or_404 from django.contrib import messages from django.core.exceptions import ObjectDoesNotExist +from django.utils import translation from submission import models as submission_models from plugins.apc import forms, models, plugin_settings @@ -75,6 +77,71 @@ def set_apc(**kwargs): pass +def record_vac_optin(**kwargs): + request = kwargs.get('request', None) + article = kwargs.get('article', None) + plugin = plugin_settings.get_self() + + if not request or not article: + return + + try: + author_contribution_mode = setting_handler.get_plugin_setting( + plugin, + 'author_contribution_mode', + request.journal, + ).processed_value + except (ObjectDoesNotExist, IndexError, AttributeError): + return + + if author_contribution_mode != 'vac': + return + + if not request.POST.get('vac_optin'): + return + + try: + section_apc = models.SectionAPC.objects.get(section=article.section) + vac_defaults = { + 'section_apc': section_apc, + 'value': section_apc.value, + 'currency': section_apc.currency, + } + except models.SectionAPC.DoesNotExist: + vac_defaults = { + 'section_apc': None, + 'value': 0, + 'currency': '', + } + + models.VoluntaryContribution.objects.get_or_create( + article=article, + defaults=vac_defaults, + ) + + +def notify_vac_handlers(**kwargs): + request = kwargs.get('request', None) + article = kwargs.get('article', None) + + if not request or not article: + return + + try: + article.voluntarycontribution + except models.VoluntaryContribution.DoesNotExist: + return + + billing_staffers = models.BillingStaffer.objects.filter( + journal=request.journal, + receives_notifications=True, + type_of_notification='vac', + ) + + for billing_staffer in billing_staffers: + billing_staffer.send_notification(request, article) + + def notify_billing_staffers(**kwargs): request = kwargs.get('request', None) article = kwargs.get('article', None) diff --git a/migrations/0017_voluntarycontribution.py b/migrations/0017_voluntarycontribution.py new file mode 100644 index 0000000..a46fb2d --- /dev/null +++ b/migrations/0017_voluntarycontribution.py @@ -0,0 +1,74 @@ +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +def migrate_enable_waivers(apps, schema_editor): + """ + For any journal that has enable_waivers = 'on', set author_contribution_mode + = 'waiver', provided author_contribution_mode is not already set. + """ + SettingValue = apps.get_model('core', 'SettingValue') + Setting = apps.get_model('core', 'Setting') + + try: + waivers_setting = Setting.objects.get( + name='enable_waivers', + group__name='plugin:apc', + ) + mode_setting = Setting.objects.get( + name='author_contribution_mode', + group__name='plugin:apc', + ) + except Setting.DoesNotExist: + return + + for sv in SettingValue.objects.filter(setting=waivers_setting, value='on'): + SettingValue.objects.update_or_create( + setting=mode_setting, + journal=sv.journal, + defaults={'value': 'waiver'}, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('submission', '0001_initial'), + ('apc', '0016_merge_20240926_1721'), + ] + + operations = [ + migrations.CreateModel( + name='VoluntaryContribution', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', models.DecimalField(decimal_places=2, default=0, help_text='Decimal with two places eg. 200.00', max_digits=6)), + ('currency', models.CharField(blank=True, default='', help_text='The currency of the APC value eg. GBP or USD.', max_length=25)), + ('recorded', models.DateTimeField(default=django.utils.timezone.now)), + ('contacted', models.BooleanField(default=False)), + ('contacted_date', models.DateTimeField(blank=True, null=True)), + ('article', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='submission.article')), + ('section_apc', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='apc.sectionapc')), + ], + options={ + 'ordering': ('-recorded',), + }, + ), + migrations.AlterField( + model_name='billingstaffer', + name='type_of_notification', + field=models.CharField( + choices=[ + ('ready', 'Ready for Invoicing'), + ('invoiced', 'Invoice Sent'), + ('paid', 'Invoice Paid'), + ('waiver', 'Waiver Application'), + ('vac', 'Voluntary Contribution'), + ], + default='ready', + max_length=15, + ), + ), + migrations.RunPython(migrate_enable_waivers, migrations.RunPython.noop), + ] diff --git a/models.py b/models.py index 79a20a7..8c3a36c 100644 --- a/models.py +++ b/models.py @@ -187,12 +187,51 @@ def reviewed_display(self): return 'Waiver has not been reviewed.' +class VoluntaryContribution(models.Model): + article = models.OneToOneField( + 'submission.Article', + on_delete=models.CASCADE, + ) + section_apc = models.ForeignKey( + SectionAPC, + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + value = models.DecimalField( + max_digits=6, + decimal_places=2, + default=0, + help_text='Decimal with two places eg. 200.00', + ) + currency = models.CharField( + max_length=25, + blank=True, + default='', + help_text='The currency of the APC value eg. GBP or USD.', + ) + recorded = models.DateTimeField(default=timezone.now) + contacted = models.BooleanField(default=False) + contacted_date = models.DateTimeField(blank=True, null=True) + + class Meta: + ordering = ('-recorded',) + + def __str__(self): + return 'VAC for Article {pk} - {value} {currency}'.format( + pk=self.article.pk, + value=self.value, + currency=self.currency, + ) + + def type_of_notification_choices(): return ( ('ready', 'Ready for Invoicing'), ('invoiced', 'Invoice Sent'), ('paid', 'Invoice Paid'), ('waiver', 'Waiver Application'), + ('vac', 'Voluntary Contribution'), ) @@ -232,6 +271,8 @@ def notification_setting_name(self): return 'apc_article_invoice_sent' elif self.type_of_notification == 'waiver': return 'apc_article_waiver' + elif self.type_of_notification == 'vac': + return 'apc_vac_contribution_ready' else: return 'apc_article_invoice_paid' diff --git a/plugin_settings.py b/plugin_settings.py index 02564ed..f9033d7 100644 --- a/plugin_settings.py +++ b/plugin_settings.py @@ -19,6 +19,16 @@ ON_INVOICE_SENT = 'on_invoice_sent' ON_INVOICE_PAID = 'on_invoice_paid' +# Plugin settings managed by APCSettingsForm: {name: pretty_name} +APC_SETTINGS = { + 'enable_apcs': 'Enable APCs', + 'track_apcs': 'Track APCs', + 'author_contribution_mode': 'Author Contribution Mode', + 'waiver_text': 'Waiver Text', + 'vac_text': 'VAC Text', +} +APC_BOOLEAN_SETTINGS = {'enable_apcs', 'track_apcs'} + def get_self(): defaults = { @@ -82,11 +92,21 @@ def register_for_events(): logic.set_apc, ) + event_logic.Events.register_for_event( + event_logic.Events.ON_ARTICLE_SUBMITTED, + logic.record_vac_optin, + ) + event_logic.Events.register_for_event( event_logic.Events.ON_ARTICLE_ACCEPTED, logic.notify_billing_staffers, ) + event_logic.Events.register_for_event( + event_logic.Events.ON_ARTICLE_ACCEPTED, + logic.notify_vac_handlers, + ) + event_logic.Events.register_for_event( ON_INVOICE_SENT, logic.notify_billing_staffers, diff --git a/templates/apc/index.html b/templates/apc/index.html index 1383316..bcc7cd0 100644 --- a/templates/apc/index.html +++ b/templates/apc/index.html @@ -19,6 +19,9 @@

Articles Ready for Invoicing

Add Article Manage Billing Staff Edit APC Settings + {% if request.user.is_staff %} + Voluntary Contributions + {% endif %}
diff --git a/templates/apc/settings.html b/templates/apc/settings.html index 7d776a6..8d7a12c 100644 --- a/templates/apc/settings.html +++ b/templates/apc/settings.html @@ -23,29 +23,29 @@

Enable APCs for {{ request.journal.name }}

{% csrf_token %}

APCs

-
@@ -59,4 +59,12 @@

Waivers

{% block js %} {% include "elements/jqte.html" %} -{% endblock %} \ No newline at end of file + +{% endblock %} diff --git a/templates/apc/vac_list.html b/templates/apc/vac_list.html new file mode 100644 index 0000000..c56bb03 --- /dev/null +++ b/templates/apc/vac_list.html @@ -0,0 +1,104 @@ +{% extends "admin/core/base.html" %} + +{% block title %}Voluntary Author Contributions{% endblock %} +{% block title-section %}Voluntary Author Contributions{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Voluntary Author Contributions
  • +{% endblock breadcrumbs %} + +{% block body %} +
    +
    +
    +
    +
    +

    Voluntary Author Contributions

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    + + + + + + + + + + + + + + + {% for vac in vac_records %} + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    ArticleJournalAuthorDate AcceptedValueCurrencyContacted
    {{ vac.article.title|safe }}{{ vac.article.journal.name }} + {{ vac.article.correspondence_author.full_name }}
    + {{ vac.article.correspondence_author.email }} + {% with affil=vac.article.correspondence_author.primary_affiliation %} + {% if affil %} +
    {{ affil.organization }} + {% if affil.organization.ror_id %} +
    ROR: {{ affil.organization.ror_id }} + {% endif %} + {% endif %} + {% endwith %} + {% if vac.article.correspondence_author.orcid %} +
    ORCiD: {{ vac.article.correspondence_author.orcid }} + {% endif %} +
    {{ vac.article.date_accepted|default:"Not Accepted" }}{{ vac.value }}{{ vac.currency }} + {% if vac.contacted %} + Yes{% if vac.contacted_date %} ({{ vac.contacted_date }}){% endif %} + {% else %} + No + {% endif %} + +
    + {% csrf_token %} + +
    +
    No voluntary contributions recorded.
    +
    +
    +
    +
    +
    +{% endblock %} + +{% block js %} + {% include "elements/datatables.html" with target=".vactable" sort=0 page_length=10 %} +{% endblock %} diff --git a/templates/apc/vac_optin.html b/templates/apc/vac_optin.html new file mode 100644 index 0000000..c685d42 --- /dev/null +++ b/templates/apc/vac_optin.html @@ -0,0 +1,12 @@ +
    +

    Voluntary Author Contribution

    +
    + +{{ vac_text|safe }} + +

    + +

    diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..a5ae31a --- /dev/null +++ b/tests.py @@ -0,0 +1,138 @@ +from django.template import Context +from django.test import TestCase + +from plugins.apc import forms, hooks, models, plugin_settings +from utils import setting_handler +from utils.testing import helpers + + +class APCSettingsFormTests(TestCase): + @classmethod + def setUpTestData(cls): + helpers.create_press() + cls.journal, _ = helpers.create_journals() + plugin_settings.install() + cls.plugin = plugin_settings.get_self() + + def _get_value(self, name): + return setting_handler.get_plugin_setting(self.plugin, name, self.journal).value + + def _submit(self, data): + form = forms.APCSettingsForm(data, plugin=self.plugin, journal=self.journal) + self.assertTrue(form.is_valid(), form.errors) + form.save() + + def _base_data(self, **overrides): + data = { + "enable_apcs": False, + "track_apcs": False, + "author_contribution_mode": "", + "waiver_text": "", + "vac_text": "", + } + data.update(overrides) + return data + + def test_form_populates_initial_values_from_settings(self): + setting_handler.save_plugin_setting( + self.plugin, "enable_apcs", "on", self.journal + ) + setting_handler.save_plugin_setting( + self.plugin, "author_contribution_mode", "vac", self.journal + ) + setting_handler.save_plugin_setting( + self.plugin, "vac_text", "Please contribute", self.journal + ) + form = forms.APCSettingsForm(plugin=self.plugin, journal=self.journal) + self.assertTrue(form.initial["enable_apcs"]) + self.assertFalse(form.initial["track_apcs"]) + self.assertEqual(form.initial["author_contribution_mode"], "vac") + self.assertEqual(form.initial["vac_text"], "Please contribute") + + def test_save_updates_settings(self): + self._submit( + self._base_data( + enable_apcs=True, + author_contribution_mode="vac", + vac_text="Help us publish", + ) + ) + self.assertEqual(self._get_value("enable_apcs"), "on") + self.assertEqual(self._get_value("track_apcs"), "") + self.assertEqual(self._get_value("author_contribution_mode"), "vac") + self.assertEqual(self._get_value("vac_text"), "Help us publish") + + + +class WaiverInfoHookTests(TestCase): + @classmethod + def setUpTestData(cls): + helpers.create_press() + cls.journal, _ = helpers.create_journals() + plugin_settings.install() + cls.plugin = plugin_settings.get_self() + + def _context(self): + return Context({"request": helpers.Request(journal=self.journal)}) + + def test_no_mode_returns_empty_string(self): + setting_handler.save_plugin_setting( + self.plugin, "author_contribution_mode", "", self.journal + ) + self.assertEqual(hooks.waiver_info(self._context()), "") + + def test_waiver_mode_renders_waiver_info(self): + setting_handler.save_plugin_setting( + self.plugin, "author_contribution_mode", "waiver", self.journal + ) + setting_handler.save_plugin_setting( + self.plugin, "waiver_text", "Apply for a waiver here", self.journal + ) + result = hooks.waiver_info(self._context()) + self.assertIn("Waiver Information", result) + self.assertIn("Apply for a waiver here", result) + + def test_vac_mode_renders_vac_optin(self): + setting_handler.save_plugin_setting( + self.plugin, "author_contribution_mode", "vac", self.journal + ) + setting_handler.save_plugin_setting( + self.plugin, "vac_text", "Support open access", self.journal + ) + result = hooks.waiver_info(self._context()) + self.assertIn('name="vac_optin"', result) + self.assertIn("Support open access", result) + + +class WaiverApplicationHookTests(TestCase): + @classmethod + def setUpTestData(cls): + helpers.create_press() + cls.journal, _ = helpers.create_journals() + plugin_settings.install() + cls.plugin = plugin_settings.get_self() + cls.article = helpers.create_article(cls.journal) + + def _context(self): + return Context( + { + "request": helpers.Request(journal=self.journal), + "article": self.article, + } + ) + + def test_waiver_mode_renders_application_template(self): + setting_handler.save_plugin_setting( + self.plugin, "author_contribution_mode", "waiver", self.journal + ) + models.WaiverApplication.objects.create(article=self.article) + result = hooks.waiver_application(self._context()) + self.assertIn("Waiver Application", result) + self.assertIn("active waiver request", result) + + def test_non_waiver_mode_returns_empty_string(self): + for mode in ("vac", ""): + setting_handler.save_plugin_setting( + self.plugin, "author_contribution_mode", mode, self.journal + ) + self.assertEqual(hooks.waiver_application(self._context()), "") diff --git a/urls.py b/urls.py index ce406ab..609d898 100644 --- a/urls.py +++ b/urls.py @@ -39,5 +39,15 @@ views.discount_apc, name='discount_apc', ), + re_path( + r'^vac/$', + views.vac_list, + name='apc_vac_list', + ), + re_path( + r'^vac/(?P\d+)/toggle/$', + views.vac_toggle_contacted, + name='apc_vac_toggle_contacted', + ), ] diff --git a/views.py b/views.py index e76639a..aa243d9 100644 --- a/views.py +++ b/views.py @@ -5,6 +5,10 @@ from plugins.apc import plugin_settings, logic, forms, models from submission import models as submission_models +from django.contrib.admin.views.decorators import staff_member_required +from django.views.decorators.http import require_POST +from django.http import Http404 + from security.decorators import ( has_journal, editor_user_required, @@ -19,7 +23,8 @@ @editor_user_required def index(request): sections = submission_models.Section.objects.filter( - journal=request.journal).prefetch_related('sectionapc') + journal=request.journal + ).prefetch_related("sectionapc") waiver_applications = models.WaiverApplication.objects.filter( article__journal=request.journal, reviewed__isnull=True, @@ -28,36 +33,35 @@ def index(request): form = forms.APCForm() - if request.POST and 'section' in request.POST: + if request.POST and "section" in request.POST: form = forms.APCForm(request.POST) if form.is_valid(): logic.handle_set_apc(request, form) else: - modal = request.POST.get('section') + modal = request.POST.get("section") article_apcs = models.ArticleAPC.objects.filter( - article__journal=request.journal, - article__date_accepted__isnull=False + article__journal=request.journal, article__date_accepted__isnull=False ) - template = 'apc/index.html' + template = "apc/index.html" context = { - 'sections': sections, - 'form': form, - 'waiver_applications': waiver_applications, - 'modal': modal, - 'articles_for_invoicing': article_apcs.filter( - status='new', + "sections": sections, + "form": form, + "waiver_applications": waiver_applications, + "modal": modal, + "articles_for_invoicing": article_apcs.filter( + status="new", ), - 'articles_paid': article_apcs.filter( - status='paid', + "articles_paid": article_apcs.filter( + status="paid", ), - 'articles_unpaid': article_apcs.filter( - status='nonpay', + "articles_unpaid": article_apcs.filter( + status="nonpay", + ), + "articles_invoiced": article_apcs.filter( + status="invoiced", ), - 'articles_invoiced': article_apcs.filter( - status='invoiced', - ) } return render(request, template, context) @@ -72,23 +76,23 @@ def apc_action(request, apc_id, action): article__journal=request.journal, ) - if request.POST and 'action' in request.POST: + if request.POST and "action" in request.POST: event_kwargs = { - 'request': request, - 'article': apc.article, - 'type_of_notification': action, + "request": request, + "article": apc.article, + "type_of_notification": action, } - if action == 'paid': + if action == "paid": apc.mark_as_paid() event_logic.Events.raise_event( plugin_settings.ON_INVOICE_PAID, **event_kwargs, ) - elif action == 'unpaid': + elif action == "unpaid": apc.mark_as_unpaid() - elif action == 'new': + elif action == "new": apc.mark_as_new() - elif action == 'invoiced': + elif action == "invoiced": apc.mark_as_invoiced() event_logic.Events.raise_event( plugin_settings.ON_INVOICE_SENT, @@ -98,28 +102,28 @@ def apc_action(request, apc_id, action): messages.add_message( request, messages.ERROR, - 'No suitable action found.', + "No suitable action found.", ) messages.add_message( request, messages.SUCCESS, - 'APC Updated', + "APC Updated", ) - return redirect(reverse('apc_index')) + return redirect(reverse("apc_index")) - elif action in ['paid', 'unpaid'] and apc.completed: + elif action in ["paid", "unpaid"] and apc.completed: messages.add_message( request, messages.ERROR, - 'APC has already been completed.', + "APC has already been completed.", ) - return redirect(reverse('apc_index')) + return redirect(reverse("apc_index")) - template = 'apc/apc_action.html' + template = "apc/apc_action.html" context = { - 'apc': apc, - 'action': action, + "apc": apc, + "action": action, } return render(request, template, context) @@ -129,90 +133,31 @@ def apc_action(request, apc_id, action): @editor_user_required def settings(request): plugin = plugin_settings.get_self() - enable_apcs = setting_handler.get_plugin_setting( - plugin, - 'enable_apcs', - request.journal, - create=True, - pretty='Enable APCs', - ) - track_apcs = setting_handler.get_plugin_setting( - plugin, - 'track_apcs', - request.journal, - create=True, - pretty='Track APCs', - ) - waiver_text = setting_handler.get_plugin_setting( - plugin, - 'waiver_text', - request.journal, - create=True, - pretty='Waiver Text', - ) - enable_waivers = setting_handler.get_plugin_setting( - plugin, - 'enable_waivers', - request.journal, - create=True, - pretty='Enable Waivers', - ) + form_kwargs = dict(plugin=plugin, journal=request.journal) + form = forms.APCSettingsForm(**form_kwargs) if request.POST: - apc_post = request.POST.get('enable_apcs') - track_post = request.POST.get('track_apcs') - text_post = request.POST.get('waiver_text') - waivers_post = request.POST.get('enable_waivers') - - setting_handler.save_plugin_setting( - plugin, - 'enable_apcs', - apc_post, - request.journal, - ) - setting_handler.save_plugin_setting( - plugin, - 'track_apcs', - track_post, - request.journal, - ) - setting_handler.save_plugin_setting( - plugin, - 'waiver_text', - text_post, - request.journal, - ) - setting_handler.save_plugin_setting( - plugin, - 'enable_waivers', - waivers_post, - request.journal, - ) - - messages.add_message(request, messages.SUCCESS, 'Setting updated.') - return redirect(reverse('apc_settings')) + form = forms.APCSettingsForm(request.POST, **form_kwargs) + if form.is_valid(): + form.save() + messages.add_message(request, messages.SUCCESS, "Setting updated.") + return redirect(reverse("apc_settings")) - template = 'apc/settings.html' - context = { - 'enable_apc': enable_apcs.value if enable_apcs else '', - 'track_apcs': track_apcs.value if track_apcs else '', - 'enable_waivers': enable_waivers.value if enable_waivers else '', - 'waiver_text': waiver_text.value if waiver_text else '', - } - - return render(request, template, context) + return render(request, "apc/settings.html", {"form": form}) @has_journal @editor_user_required def waiver_application(request, application_id): - application = get_object_or_404(models.WaiverApplication, - pk=application_id, - article__journal=request.journal, - reviewed__isnull=True) + application = get_object_or_404( + models.WaiverApplication, + pk=application_id, + article__journal=request.journal, + reviewed__isnull=True, + ) form = forms.WaiverResponse(instance=application) - if request.POST and 'action' in request.POST: + if request.POST and "action" in request.POST: form = forms.WaiverResponse(request.POST, instance=application) if form.is_valid(): @@ -223,12 +168,12 @@ def waiver_application(request, application_id): application.reviewed = timezone.now() application.reviewer = request.user application.save() - return redirect(reverse('apc_index')) + return redirect(reverse("apc_index")) - template = 'apc/waiver_application.html' + template = "apc/waiver_application.html" context = { - 'application': application, - 'form': form, + "application": application, + "form": form, } return render(request, template, context) @@ -239,7 +184,8 @@ def waiver_application(request, application_id): def make_waiver_application(request, article_id): article = get_object_or_404( submission_models.Article, - pk=article_id, journal=request.journal, + pk=article_id, + journal=request.journal, waiverapplication__isnull=True, ) form = forms.WaiverApplication() @@ -251,23 +197,23 @@ def make_waiver_application(request, article_id): waiver = form.save(commit=False) waiver.complete_application(article, request) kwargs = { - 'request': request, - 'article': article, - 'type_of_notification': 'waiver', + "request": request, + "article": article, + "type_of_notification": "waiver", } logic.notify_billing_staffers(**kwargs) return redirect( reverse( - 'core_dashboard_article', - kwargs={'article_id': article.pk}, + "core_dashboard_article", + kwargs={"article_id": article.pk}, ) ) - template = 'apc/make_waiver_application.html' + template = "apc/make_waiver_application.html" context = { - 'article': article, - 'form': form, + "article": article, + "form": form, } return render(request, template, context) @@ -283,9 +229,9 @@ def billing_staff(request): journal=request.journal, ) - template = 'apc/billing_staff.html' + template = "apc/billing_staff.html" context = { - 'billing_staffers': billing_staffers, + "billing_staffers": billing_staffers, } return render(request, template, context) @@ -299,11 +245,13 @@ def manage_billing_staff(request, billing_staffer_id=None): """ # Grab the staffer if we have an ID, otherwise set to None. - billing_staffer = get_object_or_404( - models.BillingStaffer, - journal=request.journal, - pk=billing_staffer_id - ) if billing_staffer_id else None + billing_staffer = ( + get_object_or_404( + models.BillingStaffer, journal=request.journal, pk=billing_staffer_id + ) + if billing_staffer_id + else None + ) form = forms.BillingStafferForm( instance=billing_staffer, @@ -311,20 +259,15 @@ def manage_billing_staff(request, billing_staffer_id=None): ) if request.POST: - if billing_staffer and 'delete' in request.POST: + if billing_staffer and "delete" in request.POST: billing_staffer.delete() - messages.add_message( - request, - messages.INFO, - 'Billing Staffer deleted.' - ) + messages.add_message(request, messages.INFO, "Billing Staffer deleted.") return redirect( reverse( - 'apc_billing_staff', + "apc_billing_staff", ) ) - form = forms.BillingStafferForm( request.POST, instance=billing_staffer, @@ -333,21 +276,13 @@ def manage_billing_staff(request, billing_staffer_id=None): if form.is_valid(): billing_staffer = form.save() - messages.add_message( - request, - messages.SUCCESS, - 'Billing Staffer saved.' - ) - return redirect( - reverse( - 'apc_billing_staff' - ) - ) + messages.add_message(request, messages.SUCCESS, "Billing Staffer saved.") + return redirect(reverse("apc_billing_staff")) - template = 'apc/manage_billing_staff.html' + template = "apc/manage_billing_staff.html" context = { - 'billing_staffer': billing_staffer, - 'form': form, + "billing_staffer": billing_staffer, + "form": form, } return render(request, template, context) @@ -369,8 +304,8 @@ def add_article(request): date_accepted__isnull=False, ) - if request.POST and 'article_to_add' in request.POST: - article_id = request.POST.get('article_to_add') + if request.POST and "article_to_add" in request.POST: + article_id = request.POST.get("article_to_add") try: article = submission_models.Article.objects.get( @@ -392,31 +327,30 @@ def add_article(request): messages.add_message( request, messages.SUCCESS, - 'APC set for article.', + "APC set for article.", ) except models.SectionAPC.DoesNotExist: messages.add_message( request, messages.WARNING, - 'APC Management is enabled but this' - ' section has no APC.', + "APC Management is enabled but this section has no APC.", ) except submission_models.Article.DoesNotExist: messages.add_message( request, messages.ERROR, - 'No article found matching supplied ID.', + "No article found matching supplied ID.", ) return redirect( reverse( - 'apc_add_article', + "apc_add_article", ) ) - template = 'apc/add_article.html' + template = "apc/add_article.html" context = { - 'journal_articles': journal_articles, + "journal_articles": journal_articles, } return render(request, template, context) @@ -436,19 +370,18 @@ def discount_apc(request, apc_id): if request.POST: original_value = apc.value - new_apc_amount = request.POST.get('new_value') + new_apc_amount = request.POST.get("new_value") apc.value = new_apc_amount apc.save() - description = 'APC Value changed from {} to {}'.format( - original_value, - apc.value + description = "APC Value changed from {} to {}".format( + original_value, apc.value ) utils_models.LogEntry.add_entry( types=models.APC_VALUE_CHANGE, description=description, - level='INFO', + level="INFO", actor=request.user, target=apc, ) @@ -459,9 +392,66 @@ def discount_apc(request, apc_id): description, ) - template = 'apc/discount_apc.html' + template = "apc/discount_apc.html" context = { - 'apc': apc, - 'discounts': models.Discount.objects.filter(journal=request.journal), + "apc": apc, + "discounts": models.Discount.objects.filter(journal=request.journal), } return render(request, template, context) + + +@staff_member_required +def vac_list(request): + vac_records = ( + models.VoluntaryContribution.objects.all() + .select_related( + "article", + "article__journal", + "article__correspondence_author", + "section_apc", + ) + .prefetch_related( + "article__correspondence_author__controlledaffiliation_set__organization", + ) + ) + + filter_form = forms.VACFilterForm(request.GET) + if filter_form.is_valid(): + acceptance_filter = filter_form.cleaned_data.get("accepted", "") + contacted_filter = filter_form.cleaned_data.get("contacted", "") + if acceptance_filter == "yes": + vac_records = vac_records.filter(article__date_accepted__isnull=False) + elif acceptance_filter == "no": + vac_records = vac_records.filter(article__date_accepted__isnull=True) + if contacted_filter == "yes": + vac_records = vac_records.filter(contacted=True) + elif contacted_filter == "no": + vac_records = vac_records.filter(contacted=False) + + template = "apc/vac_list.html" + context = { + "vac_records": vac_records, + "filter_form": filter_form, + } + + return render(request, template, context) + + +@require_POST +@staff_member_required +def vac_toggle_contacted(request, vac_id): + vac = get_object_or_404( + models.VoluntaryContribution, + pk=vac_id, + ) + + vac.contacted = not vac.contacted + vac.contacted_date = timezone.now() if vac.contacted else None + vac.save() + messages.add_message( + request, + messages.SUCCESS, + "VAC contacted status updated.", + ) + + return redirect(reverse("apc_vac_list"))