diff --git a/src/core/include_urls.py b/src/core/include_urls.py index 3b84253abb..cb9b37f82d 100644 --- a/src/core/include_urls.py +++ b/src/core/include_urls.py @@ -152,6 +152,11 @@ core_views.edit_settings_group, name="core_edit_settings_group", ), + re_path( + r"^manager/settings/journal/keywords/suggest/$", + core_views.journal_keyword_suggestions, + name="core_journal_keyword_suggestions", + ), re_path( r"^manager/settings/(?P[-\w.:]+)/(?P[-\w.]+)/(?P\d+)/$", core_views.edit_plugin_settings_groups, diff --git a/src/core/views.py b/src/core/views.py index 3ba50bf574..91807e4f6d 100755 --- a/src/core/views.py +++ b/src/core/views.py @@ -20,14 +20,14 @@ from django.shortcuts import render, get_object_or_404, redirect, Http404 from django.utils import timezone from django.utils.decorators import method_decorator -from django.http import HttpResponse, QueryDict +from django.http import HttpResponse, QueryDict, JsonResponse from django.contrib.messages.views import SuccessMessageMixin from django.contrib.sessions.models import Session from django.core.validators import validate_email from django.core.exceptions import ValidationError from django.db import IntegrityError from django.conf import settings as django_settings -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_GET, require_POST from django.views.decorators.csrf import ensure_csrf_cookie from django.views.generic import CreateView, UpdateView, DeleteView from django.contrib.contenttypes.models import ContentType @@ -1182,7 +1182,6 @@ def edit_setting(request, setting_group, setting_name): } return render(request, template, context) - @staff_member_required def edit_default_setting(request, setting_group, setting_name): """Proxy view for edit_setting allowing editing the default value @@ -1285,6 +1284,25 @@ def edit_settings_group(request, display_group): return render(request, template, context) +@editor_user_required +@require_GET +def journal_keyword_suggestions(request): + """ + Returns a small list of matching existing keywords for journal disciplines. + """ + search_term = request.GET.get("q", "").strip() + + current_keyword_ids = request.journal.keywords.values_list("pk", flat=True) + matches = submission_models.Keyword.objects.exclude(pk__in=current_keyword_ids) + + if search_term: + matches = matches.filter(word__istartswith=search_term) + + matches = matches.order_by("word").values_list("word", flat=True)[:10] + + return JsonResponse(list(matches), safe=False) + + @editor_user_required def edit_plugin_settings_groups( request, plugin, setting_group_name, journal=None, title=None diff --git a/src/security/test_security.py b/src/security/test_security.py index 8c269a9973..10363c0545 100644 --- a/src/security/test_security.py +++ b/src/security/test_security.py @@ -4461,6 +4461,78 @@ def test_setting_is_available_in_group(self): ) self.assertContains(response, "Journal Name") + def test_journal_settings_uses_remote_keyword_suggestions(self): + medicine = submission_models.Keyword.objects.create(word="Medicine") + arts = submission_models.Keyword.objects.create(word="Arts") + self.journal_one.keywords.add(medicine, arts) + + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "core_edit_settings_group", + kwargs={ + "display_group": "journal", + }, + ), + SERVER_NAME="journal1.localhost", + ) + + self.assertEqual(response.status_code, 200) + self.assertContains( + response, + reverse("core_journal_keyword_suggestions"), + ) + self.assertNotContains(response, '"Medicine"') + self.assertNotContains(response, '"Arts"') + + def test_journal_keyword_suggestions_returns_matching_keywords(self): + medicine = submission_models.Keyword.objects.create(word="Medicine") + mediation = submission_models.Keyword.objects.create(word="Mediation") + arts = submission_models.Keyword.objects.create(word="Arts") + self.journal_one.keywords.add(medicine) + self.journal_two.keywords.add(arts) + + self.client.force_login(self.editor) + response = self.client.get( + reverse("core_journal_keyword_suggestions"), + {"q": "Me"}, + SERVER_NAME="journal1.localhost", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), ["Mediation"]) + + def test_journal_keyword_suggestions_returns_initial_results_for_empty_query(self): + medicine = submission_models.Keyword.objects.create(word="Medicine") + arts = submission_models.Keyword.objects.create(word="Arts") + self.journal_one.keywords.add(medicine) + self.journal_two.keywords.add(arts) + + self.client.force_login(self.editor) + response = self.client.get( + reverse("core_journal_keyword_suggestions"), + SERVER_NAME="journal1.localhost", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), ["Arts"]) + + def test_journal_settings_page_requires_three_characters_before_searching(self): + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "core_edit_settings_group", + kwargs={ + "display_group": "journal", + }, + ), + SERVER_NAME="journal1.localhost", + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "minLength: 3") + self.assertNotContains(response, "showAutocompleteOnFocus: true") + def test_setting_is_removed_from_group(self): # set the journal_name setting to only be editable by journal managers. journal_manager_role = core_models.Role.objects.get( diff --git a/src/submission/tests/test_workflow.py b/src/submission/tests/test_workflow.py index a69630f01a..d8938f0787 100644 --- a/src/submission/tests/test_workflow.py +++ b/src/submission/tests/test_workflow.py @@ -447,6 +447,190 @@ def test_submit_info_view_form_selection_author(self): self.assertEqual(response.status_code, 200) self.assertNotContains(response, section.__str__()) + @override_settings(URL_CONFIG="domain") + def test_submit_info_view_shows_journal_disciplines_as_keyword_suggestions(self): + author_1, _ = self.create_authors() + clear_cache() + article = models.Article.objects.create( + journal=self.journal_one, + title="Test article: keyword suggestions", + owner=author_1, + ) + medicine = models.Keyword.objects.create(word="Medicine") + arts = models.Keyword.objects.create(word="Arts") + self.journal_one.keywords.add(medicine, arts) + + self.client.force_login(author_1) + clear_script_prefix() + response = self.client.get( + reverse("submit_info", kwargs={"article_id": article.pk}), + SERVER_NAME="testserver", + ) + + self.assertEqual(response.status_code, 200) + self.assertContains( + response, + reverse( + "submission_keyword_suggestions", + kwargs={"article_id": article.pk}, + ), + ) + self.assertNotContains(response, '"Medicine"') + self.assertNotContains(response, '"Arts"') + + @override_settings(URL_CONFIG="domain") + def test_edit_metadata_view_shows_journal_disciplines_as_keyword_suggestions(self): + article = helpers.create_article(self.journal_one) + medicine = models.Keyword.objects.create(word="Medicine") + arts = models.Keyword.objects.create(word="Arts") + self.journal_one.keywords.add(medicine, arts) + + self.client.force_login(self.editor) + clear_script_prefix() + response = self.client.get( + reverse("edit_metadata", kwargs={"article_id": article.pk}), + SERVER_NAME="testserver", + ) + + self.assertEqual(response.status_code, 200) + self.assertContains( + response, + reverse( + "submission_keyword_suggestions", + kwargs={"article_id": article.pk}, + ), + ) + self.assertNotContains(response, '"Medicine"') + self.assertNotContains(response, '"Arts"') + + @override_settings(URL_CONFIG="domain") + def test_keyword_suggestions_returns_filtered_journal_keywords(self): + author_1, _ = self.create_authors() + clear_cache() + article = models.Article.objects.create( + journal=self.journal_one, + title="Test article: keyword suggestions endpoint", + owner=author_1, + ) + medicine = models.Keyword.objects.create(word="Medicine") + mediation = models.Keyword.objects.create(word="Mediation") + arts = models.Keyword.objects.create(word="Arts") + medieval = models.Keyword.objects.create(word="Medieval") + self.journal_one.keywords.add(medicine, mediation, arts) + self.journal_two.keywords.add(medieval) + + self.client.force_login(author_1) + clear_script_prefix() + response = self.client.get( + reverse( + "submission_keyword_suggestions", + kwargs={"article_id": article.pk}, + ), + {"q": "Me"}, + SERVER_NAME="testserver", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), ["Mediation", "Medicine"]) + + @override_settings(URL_CONFIG="domain") + def test_keyword_suggestions_returns_initial_results_for_empty_query(self): + author_1, _ = self.create_authors() + clear_cache() + article = models.Article.objects.create( + journal=self.journal_one, + title="Test article: empty keyword query", + owner=author_1, + ) + medicine = models.Keyword.objects.create(word="Medicine") + arts = models.Keyword.objects.create(word="Arts") + self.journal_one.keywords.add(medicine) + self.journal_one.keywords.add(arts) + + self.client.force_login(author_1) + clear_script_prefix() + response = self.client.get( + reverse( + "submission_keyword_suggestions", + kwargs={"article_id": article.pk}, + ), + SERVER_NAME="testserver", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), ["Arts", "Medicine"]) + + @override_settings(URL_CONFIG="domain") + def test_keyword_suggestions_can_filter_single_character_query(self): + author_1, _ = self.create_authors() + clear_cache() + article = models.Article.objects.create( + journal=self.journal_one, + title="Test article: short keyword query", + owner=author_1, + ) + medicine = models.Keyword.objects.create(word="Medicine") + arts = models.Keyword.objects.create(word="Arts") + self.journal_one.keywords.add(medicine) + self.journal_one.keywords.add(arts) + + self.client.force_login(author_1) + clear_script_prefix() + response = self.client.get( + reverse( + "submission_keyword_suggestions", + kwargs={"article_id": article.pk}, + ), + {"q": "M"}, + SERVER_NAME="testserver", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), ["Medicine"]) + + @override_settings(URL_CONFIG="domain") + def test_submission_keyword_autocomplete_requires_three_characters_before_searching(self): + author_1, _ = self.create_authors() + clear_cache() + article = models.Article.objects.create( + journal=self.journal_one, + title="Test article: keyword autocomplete threshold", + owner=author_1, + ) + + self.client.force_login(author_1) + clear_script_prefix() + response = self.client.get( + reverse("submit_info", kwargs={"article_id": article.pk}), + SERVER_NAME="testserver", + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "minLength: 3") + self.assertNotContains(response, "showAutocompleteOnFocus: true") + + @override_settings(URL_CONFIG="domain") + def test_keyword_suggestions_available_for_editor_on_submitted_article(self): + article = helpers.create_article(self.journal_one) + article.stage = models.STAGE_UNDER_REVIEW + article.save() + medicine = models.Keyword.objects.create(word="Medicine") + self.journal_one.keywords.add(medicine) + + self.client.force_login(self.editor) + clear_script_prefix() + response = self.client.get( + reverse( + "submission_keyword_suggestions", + kwargs={"article_id": article.pk}, + ), + {"q": "Me"}, + SERVER_NAME="testserver", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), ["Medicine"]) + def test_article_issue_title(self): from utils.testing import helpers diff --git a/src/submission/urls.py b/src/submission/urls.py index f3cc9fa770..126d30b080 100755 --- a/src/submission/urls.py +++ b/src/submission/urls.py @@ -9,6 +9,11 @@ urlpatterns = [ re_path(r"^start/$", views.start, name="submission_start"), re_path(r"^(?P[-\w.]+)/start/$", views.start, name="submission_start"), + re_path( + r"^(?P\d+)/keywords/suggest/$", + views.keyword_suggestions, + name="submission_keyword_suggestions", + ), re_path(r"^(?P\d+)/info/$", views.submit_info, name="submit_info"), re_path( r"^(?P\d+)/authors/$", views.submit_authors, name="submit_authors" diff --git a/src/submission/views.py b/src/submission/views.py index 59325ba23b..1cef92f39a 100755 --- a/src/submission/views.py +++ b/src/submission/views.py @@ -10,14 +10,14 @@ from django.contrib.auth.decorators import login_required from django.urls import reverse from django.db.models import Q -from django.http import HttpResponse, Http404 +from django.http import HttpResponse, Http404, JsonResponse from django.shortcuts import render, redirect, get_object_or_404 from django.utils import timezone, translation from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from django.conf import settings from django.core.exceptions import PermissionDenied -from django.views.decorators.http import require_POST +from django.views.decorators.http import require_GET, require_POST from core import files, models as core_models from core.logic import create_organization_name, reverse_with_next @@ -175,6 +175,34 @@ def submit_funding(request, article_id): return render(request, template, context) +@login_required +@decorators.submission_is_enabled +@user_can_edit_article +@submission_authorised +@require_GET +def keyword_suggestions(request, article_id): + """ + Returns a small list of matching journal keywords for submission forms. + """ + get_object_or_404( + models.Article, + pk=article_id, + journal=request.journal, + ) + search_term = request.GET.get("q", "").strip() + + matches = request.journal.keywords.all() + + if search_term: + matches = matches.filter( + word__istartswith=search_term, + ) + + matches = matches.order_by("word").values_list("word", flat=True)[:10] + + return JsonResponse(list(matches), safe=False) + + @login_required @decorators.submission_is_enabled @article_is_not_submitted diff --git a/src/templates/admin/core/manager/settings/group.html b/src/templates/admin/core/manager/settings/group.html index a3702b953e..bb24aab524 100644 --- a/src/templates/admin/core/manager/settings/group.html +++ b/src/templates/admin/core/manager/settings/group.html @@ -58,14 +58,19 @@ {% endblock body %} {% block js %} - - - + {% if group == 'journal' %} + {% url "core_journal_keyword_suggestions" as keyword_suggestions_url %} + {% include "admin/elements/submission/keyword_autocomplete.html" with suggestions_url=keyword_suggestions_url %} + {% else %} + + + - + + {% endif %} {% endblock %} diff --git a/src/templates/admin/elements/submission/keyword_autocomplete.html b/src/templates/admin/elements/submission/keyword_autocomplete.html new file mode 100644 index 0000000000..eee29e5106 --- /dev/null +++ b/src/templates/admin/elements/submission/keyword_autocomplete.html @@ -0,0 +1,59 @@ +{% load static %} + + + + + + diff --git a/src/templates/admin/submission/edit/metadata.html b/src/templates/admin/submission/edit/metadata.html index 9c8e1e2105..fdc5219d50 100644 --- a/src/templates/admin/submission/edit/metadata.html +++ b/src/templates/admin/submission/edit/metadata.html @@ -234,16 +234,8 @@
Add funder
{% block js %} - - - - - + {% url "submission_keyword_suggestions" article.pk as keyword_suggestions_url %} + {% include "admin/elements/submission/keyword_autocomplete.html" with suggestions_url=keyword_suggestions_url %} - - - + {% url "submission_keyword_suggestions" article.pk as keyword_suggestions_url %} + {% include "admin/elements/submission/keyword_autocomplete.html" with suggestions_url=keyword_suggestions_url %} {% endblock %} {% block toastr %}