From 447ae648c3fc8b04e193b46c879ea277acc1acfb Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Fri, 15 May 2026 16:48:22 +0100 Subject: [PATCH 01/13] refactor(workflow): split Unassigned into Editor Assignment and Screening; extract editor_assignment app --- src/core/include_urls.py | 2 + src/core/janeway_global_settings.py | 2 + ...0111_editor_assignment_workflow_element.py | 90 ++++ .../0112_editor_assignment_primary_urls.py | 46 ++ src/core/models.py | 20 +- src/core/tests/test_workflow.py | 128 ++++++ src/core/workflow.py | 32 +- src/editor_assignment/__init__.py | 0 src/editor_assignment/apps.py | 6 + src/editor_assignment/logic.py | 93 ++++ src/editor_assignment/tests/__init__.py | 0 .../tests/test_extraction.py | 119 ++++++ src/editor_assignment/urls.py | 56 +++ src/editor_assignment/views.py | 401 ++++++++++++++++++ src/review/logic.py | 92 +--- src/review/views.py | 336 +-------------- .../migrations/0090_alter_article_stage.py | 44 ++ src/submission/models.py | 2 + src/templates/admin/core/manager/index.html | 11 + .../admin/elements/article_jump.html | 3 +- .../elements/breadcrumbs/unassigned_base.html | 2 +- src/templates/admin/review/unassigned.html | 6 +- .../admin/review/unassigned_article.html | 12 +- 23 files changed, 1084 insertions(+), 419 deletions(-) create mode 100644 src/core/migrations/0111_editor_assignment_workflow_element.py create mode 100644 src/core/migrations/0112_editor_assignment_primary_urls.py create mode 100644 src/core/tests/test_workflow.py create mode 100644 src/editor_assignment/__init__.py create mode 100644 src/editor_assignment/apps.py create mode 100644 src/editor_assignment/logic.py create mode 100644 src/editor_assignment/tests/__init__.py create mode 100644 src/editor_assignment/tests/test_extraction.py create mode 100644 src/editor_assignment/urls.py create mode 100644 src/editor_assignment/views.py create mode 100644 src/submission/migrations/0090_alter_article_stage.py diff --git a/src/core/include_urls.py b/src/core/include_urls.py index 3b84253abb..b71b3f8eb4 100644 --- a/src/core/include_urls.py +++ b/src/core/include_urls.py @@ -41,7 +41,9 @@ path("proofing/", include("proofing.urls")), path("reports/", include("reports.urls")), path("repository/", include("repository.urls")), + path("editor-assignment/", include("editor_assignment.urls")), path("review/", include("review.urls")), + path("screening/", include("screening.urls")), path("rss/", include("rss.urls")), path("submit/", include("submission.urls")), path("transform/", include("transform.urls")), diff --git a/src/core/janeway_global_settings.py b/src/core/janeway_global_settings.py index 27b3bd12cb..9e1d1a2ca2 100755 --- a/src/core/janeway_global_settings.py +++ b/src/core/janeway_global_settings.py @@ -69,6 +69,7 @@ "copyediting", "cron", "discussion", + "editor_assignment", "events", "identifiers", "journal", @@ -78,6 +79,7 @@ "production", "proofing", "review", + "screening", "repository", "reports", "security", diff --git a/src/core/migrations/0111_editor_assignment_workflow_element.py b/src/core/migrations/0111_editor_assignment_workflow_element.py new file mode 100644 index 0000000000..09788da34d --- /dev/null +++ b/src/core/migrations/0111_editor_assignment_workflow_element.py @@ -0,0 +1,90 @@ +from django.db import migrations + + +EDITOR_ASSIGNMENT_NAME = "editor_assignment" +EDITOR_ASSIGNMENT_HANDSHAKE_URL = "review_unassigned" +EDITOR_ASSIGNMENT_JUMP_URL = "review_unassigned_article" +REVIEW_NAME = "review" +STAGE_UNASSIGNED = "Unassigned" +STAGE_ASSIGNED = "Assigned" + + +def insert_editor_assignment(apps, schema_editor): + """For every Workflow, add an Editor Assignment element at the front and + realign the existing Review element to STAGE_ASSIGNED.""" + Workflow = apps.get_model("core", "Workflow") + WorkflowElement = apps.get_model("core", "WorkflowElement") + + for workflow in Workflow.objects.all(): + existing = list(workflow.elements.order_by("order", "element_name")) + # Bump existing element orders to make room at position 0. + for index, element in enumerate(existing, start=1): + if element.order != index: + element.order = index + element.save() + + # Narrow the legacy "review" element so it no longer owns STAGE_UNASSIGNED. + for element in existing: + if ( + element.element_name == REVIEW_NAME + and element.stage == STAGE_UNASSIGNED + ): + element.stage = STAGE_ASSIGNED + element.save() + + new_element, _ = WorkflowElement.objects.get_or_create( + journal=workflow.journal, + element_name=EDITOR_ASSIGNMENT_NAME, + defaults={ + "handshake_url": EDITOR_ASSIGNMENT_HANDSHAKE_URL, + "jump_url": EDITOR_ASSIGNMENT_JUMP_URL, + "stage": STAGE_UNASSIGNED, + "article_url": True, + "order": 0, + }, + ) + # Ensure the element's URLs and order are correct even if it pre-existed. + new_element.handshake_url = EDITOR_ASSIGNMENT_HANDSHAKE_URL + new_element.jump_url = EDITOR_ASSIGNMENT_JUMP_URL + new_element.stage = STAGE_UNASSIGNED + new_element.article_url = True + new_element.order = 0 + new_element.save() + + workflow.elements.add(new_element) + + +def remove_editor_assignment(apps, schema_editor): + """Reverse migration: detach editor_assignment from every Workflow, + restore the Review element's stage to STAGE_UNASSIGNED, and delete the + orphaned WorkflowElement rows. WorkflowLog history for the removed + elements is cascaded by the FK and is not recoverable.""" + Workflow = apps.get_model("core", "Workflow") + WorkflowElement = apps.get_model("core", "WorkflowElement") + + for workflow in Workflow.objects.all(): + elements = workflow.elements.filter(element_name=EDITOR_ASSIGNMENT_NAME) + for element in elements: + workflow.elements.remove(element) + + for element in workflow.elements.filter( + element_name=REVIEW_NAME, + stage=STAGE_ASSIGNED, + ): + element.stage = STAGE_UNASSIGNED + element.save() + + WorkflowElement.objects.filter(element_name=EDITOR_ASSIGNMENT_NAME).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0110_alttext"), + ] + + operations = [ + migrations.RunPython( + insert_editor_assignment, + reverse_code=remove_editor_assignment, + ), + ] diff --git a/src/core/migrations/0112_editor_assignment_primary_urls.py b/src/core/migrations/0112_editor_assignment_primary_urls.py new file mode 100644 index 0000000000..07ad3b9fb7 --- /dev/null +++ b/src/core/migrations/0112_editor_assignment_primary_urls.py @@ -0,0 +1,46 @@ +from django.db import migrations + + +EDITOR_ASSIGNMENT_NAME = "editor_assignment" +LEGACY_HANDSHAKE_URL = "review_unassigned" +LEGACY_JUMP_URL = "review_unassigned_article" +PRIMARY_HANDSHAKE_URL = "editor_assignment_list" +PRIMARY_JUMP_URL = "editor_assignment_article" + + +def use_primary_urls(apps, schema_editor): + """Update existing editor_assignment WorkflowElement rows to use the new + canonical URL names. Legacy URL names continue to resolve via the + backward-compat patterns in review.urls.""" + WorkflowElement = apps.get_model("core", "WorkflowElement") + WorkflowElement.objects.filter( + element_name=EDITOR_ASSIGNMENT_NAME, + ).update( + handshake_url=PRIMARY_HANDSHAKE_URL, + jump_url=PRIMARY_JUMP_URL, + ) + + +def use_legacy_urls(apps, schema_editor): + """Revert editor_assignment WorkflowElement rows to the pre-bau#271 + URL names.""" + WorkflowElement = apps.get_model("core", "WorkflowElement") + WorkflowElement.objects.filter( + element_name=EDITOR_ASSIGNMENT_NAME, + ).update( + handshake_url=LEGACY_HANDSHAKE_URL, + jump_url=LEGACY_JUMP_URL, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0111_editor_assignment_workflow_element"), + ] + + operations = [ + migrations.RunPython( + use_primary_urls, + reverse_code=use_legacy_urls, + ), + ] diff --git a/src/core/models.py b/src/core/models.py index 0e5d917d87..ac4c362b18 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -2043,11 +2043,18 @@ class Meta: BASE_ELEMENTS = [ + { + "name": "editor_assignment", + "handshake_url": "editor_assignment_list", + "jump_url": "editor_assignment_article", + "stage": submission_models.STAGE_UNASSIGNED, + "article_url": True, + }, { "name": "review", "handshake_url": "review_home", "jump_url": "review_in_review", - "stage": submission_models.STAGE_UNASSIGNED, + "stage": submission_models.STAGE_ASSIGNED, "article_url": True, }, { @@ -2085,6 +2092,13 @@ class Meta: "stage": submission_models.STAGE_PROOFING, "article_url": False, }, + { + "name": "screening", + "handshake_url": "screening_list", + "jump_url": "screening_article", + "stage": submission_models.STAGE_SCREENING, + "article_url": True, + }, ] BASE_ELEMENT_NAMES = [element.get("name") for element in BASE_ELEMENTS] @@ -2138,6 +2152,10 @@ def settings(self): return workflow.workflow_plugin_settings(self) + @property + def display_name(self): + return self.element_name.replace("_", " ").title() + def __str__(self): return self.element_name diff --git a/src/core/tests/test_workflow.py b/src/core/tests/test_workflow.py new file mode 100644 index 0000000000..3669f73c97 --- /dev/null +++ b/src/core/tests/test_workflow.py @@ -0,0 +1,128 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + +from django.contrib.messages.storage.fallback import FallbackStorage +from django.test import TestCase + +from core import models as core_models +from core import workflow +from submission import models as submission_models +from utils.testing import helpers + + +def attach_messages(request): + setattr(request, "session", {}) + setattr(request, "_messages", FallbackStorage(request)) + return request + + +class EditorAssignmentWorkflowTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.journal_one, cls.journal_two = helpers.create_journals() + cls.request = helpers.get_request( + press=cls.press, + journal=cls.journal_one, + ) + + def test_editor_assignment_is_first_base_element(self): + self.assertEqual( + core_models.BASE_ELEMENTS[0]["name"], + "editor_assignment", + ) + self.assertEqual( + core_models.BASE_ELEMENTS[0]["stage"], + submission_models.STAGE_UNASSIGNED, + ) + + def test_review_element_points_at_stage_assigned(self): + review_entry = next( + entry for entry in core_models.BASE_ELEMENTS if entry["name"] == "review" + ) + self.assertEqual( + review_entry["stage"], + submission_models.STAGE_ASSIGNED, + ) + + def test_element_stages_maps_editor_assignment_to_unassigned(self): + self.assertEqual( + workflow.ELEMENT_STAGES["editor_assignment"], + [submission_models.STAGE_UNASSIGNED], + ) + + def test_stages_elements_routes_unassigned_to_editor_assignment(self): + self.assertEqual( + workflow.STAGES_ELEMENTS[submission_models.STAGE_UNASSIGNED], + "editor_assignment", + ) + + def test_default_workflow_starts_with_editor_assignment(self): + first_element = self.journal_one.workflow().elements.first() + self.assertEqual(first_element.element_name, "editor_assignment") + self.assertEqual(first_element.stage, submission_models.STAGE_UNASSIGNED) + + def test_default_workflow_review_element_uses_stage_assigned(self): + review_element = self.journal_one.workflow().elements.get( + element_name="review", + ) + self.assertEqual(review_element.stage, submission_models.STAGE_ASSIGNED) + + def test_default_workflow_includes_five_elements(self): + elements = list(self.journal_one.workflow().elements.all()) + names = [element.element_name for element in elements] + self.assertEqual( + names, + [ + "editor_assignment", + "review", + "copyediting", + "typesetting", + "prepublication", + ], + ) + + def test_editor_assignment_cannot_be_removed(self): + journal_workflow = self.journal_one.workflow() + editor_assignment = journal_workflow.elements.get( + element_name="editor_assignment", + ) + request = attach_messages( + helpers.get_request(press=self.press, journal=self.journal_one), + ) + workflow.remove_element( + request, + journal_workflow, + editor_assignment, + ) + self.assertTrue( + journal_workflow.elements.filter( + element_name="editor_assignment", + ).exists(), + ) + + def test_set_stage_lands_new_article_in_editor_assignment(self): + article = submission_models.Article.objects.create( + journal=self.journal_one, + title="Workflow ordering test article", + ) + workflow.set_stage(article) + article.refresh_from_db() + self.assertEqual(article.stage, submission_models.STAGE_UNASSIGNED) + self.assertEqual( + article.current_workflow_element.element_name, + "editor_assignment", + ) + + def test_workflow_element_display_name_humanises_snake_case(self): + editor_assignment = self.journal_one.workflow().elements.get( + element_name="editor_assignment", + ) + self.assertEqual(editor_assignment.display_name, "Editor Assignment") + + review_element = self.journal_one.workflow().elements.get( + element_name="review", + ) + self.assertEqual(review_element.display_name, "Review") diff --git a/src/core/workflow.py b/src/core/workflow.py index e4973d0c81..dbf3d0ebad 100755 --- a/src/core/workflow.py +++ b/src/core/workflow.py @@ -18,6 +18,8 @@ ELEMENT_STAGES = { + "editor_assignment": [submission_models.STAGE_UNASSIGNED], + "screening": [submission_models.STAGE_SCREENING], "review": submission_models.REVIEW_STAGES, "copyediting": submission_models.COPYEDITING_STAGES, "production": [submission_models.STAGE_TYPESETTING], @@ -27,6 +29,8 @@ } STAGES_ELEMENTS = { + submission_models.STAGE_UNASSIGNED: "editor_assignment", + submission_models.STAGE_SCREENING: "screening", submission_models.STAGE_ASSIGNED: "review", submission_models.STAGE_UNDER_REVIEW: "review", submission_models.STAGE_UNDER_REVISION: "review", @@ -40,6 +44,21 @@ submission_models.STAGE_TYPESETTING_PLUGIN: "typesetting", } +EDITOR_ASSIGNMENT_ELEMENT_NAME = "editor_assignment" + + +def get_next_workflow_element(journal, current_element_name): + """Return the WorkflowElement immediately after ``current_element_name`` + in ``journal``'s workflow, or None if that element is last (or not + present). Used by stage-exit actions to route an article on to whichever + element the journal has placed next in its workflow. + """ + elements = list(journal.workflow().elements.order_by("order")) + for index, element in enumerate(elements): + if element.element_name == current_element_name: + return elements[index + 1] if index + 1 < len(elements) else None + return None + def workflow_element_complete(**kwargs): """ @@ -171,8 +190,9 @@ def create_default_workflow(journal): workflow, c = models.Workflow.objects.get_or_create(journal=journal) - # Add the first 4 workflow elements (review, copyediting, typesetting and prepub) - for index, element in enumerate(models.BASE_ELEMENTS[0:4]): + # Add the first 5 workflow elements (editor assignment, review, + # copyediting, typesetting and prepub). + for index, element in enumerate(models.BASE_ELEMENTS[0:5]): e, c = models.WorkflowElement.objects.get_or_create( journal=journal, element_name=element.get("name"), @@ -238,6 +258,14 @@ def remove_element(request, journal_workflow, element): :param element: :return: """ + if element.element_name == EDITOR_ASSIGNMENT_ELEMENT_NAME: + messages.add_message( + request, + messages.WARNING, + "Editor Assignment is required and cannot be removed from the workflow.", + ) + return + stages = ELEMENT_STAGES.get(element.element_name, None) articles = submission_models.Article.objects.filter( diff --git a/src/editor_assignment/__init__.py b/src/editor_assignment/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/editor_assignment/apps.py b/src/editor_assignment/apps.py new file mode 100644 index 0000000000..eeccbc236f --- /dev/null +++ b/src/editor_assignment/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EditorAssignmentConfig(AppConfig): + name = "editor_assignment" + verbose_name = "Editor Assignment" diff --git a/src/editor_assignment/logic.py b/src/editor_assignment/logic.py new file mode 100644 index 0000000000..6e9695f23e --- /dev/null +++ b/src/editor_assignment/logic.py @@ -0,0 +1,93 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + + +from django.urls import reverse + +from events import logic as event_logic +from review import models + + +def get_assignment_context(request, article, editor, assignment): + review_in_review_url = request.journal.site_url( + reverse("review_in_review", kwargs={"article_id": article.pk}) + ) + email_context = { + "article": article, + "editor": editor, + "assignment": assignment, + "review_in_review_url": review_in_review_url, + } + + return email_context + + +def get_unassignment_context(request, assignment): + email_context = { + "article": assignment.article, + "assignment": assignment, + "editor": request.user, + } + + return email_context + + +def assign_editor( + article, + editor, + assignment_type, + request=None, + skip=True, + automate_email=False, +): + from core.forms import SettingEmailForm + + assignment, created = models.EditorAssignment.objects.get_or_create( + article=article, + editor=editor, + editor_type=assignment_type, + ) + if request and created and automate_email: + email_context = get_assignment_context( + request, + article, + editor, + assignment, + ) + form = SettingEmailForm( + setting_name="editor_assignment", + email_context=email_context, + request=request, + ) + post_data = { + "subject": form.fields["subject"].initial, + "body": form.fields["body"].initial, + } + form = SettingEmailForm( + post_data, + setting_name="editor_assignment", + email_context=email_context, + request=request, + ) + + if form.is_valid(): + kwargs = { + "email_data": form.as_dataclass(), + "editor_assignment": assignment, + "request": request, + "skip": skip, + "acknowledgement": False, + } + event_logic.Events.raise_event( + event_logic.Events.ON_ARTICLE_ASSIGNED, + task_object=article, + **kwargs, + ) + if not skip: + event_logic.Events.raise_event( + event_logic.Events.ON_ARTICLE_ASSIGNED_ACKNOWLEDGE, + **kwargs, + ) + return assignment, created diff --git a/src/editor_assignment/tests/__init__.py b/src/editor_assignment/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/editor_assignment/tests/test_extraction.py b/src/editor_assignment/tests/test_extraction.py new file mode 100644 index 0000000000..8dee5013f8 --- /dev/null +++ b/src/editor_assignment/tests/test_extraction.py @@ -0,0 +1,119 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + +from django.test import TestCase +from django.urls import reverse + + +class EditorAssignmentExtractionTests(TestCase): + """Confirms that moving editor-assignment views and logic into the + editor_assignment app does not break the public surface. + + See bau#271. + """ + + def test_review_views_shim_resolves_moved_view_callables(self): + from review import views + + # The shim re-exports the decorated callables; assert each name resolves. + for name in ( + "unassigned", + "unassigned_article", + "assign_editor", + "assign_editor_move_to_review", + "unassign_editor", + "assignment_notification", + "move_to_review", + ): + self.assertTrue( + hasattr(views, name), + msg="review.views must re-export {}".format(name), + ) + + def test_review_logic_shim_resolves_moved_helpers(self): + from review import logic + + for name in ( + "assign_editor", + "get_assignment_context", + "get_unassignment_context", + ): + self.assertTrue( + hasattr(logic, name), + msg="review.logic must re-export {}".format(name), + ) + + def test_editor_assignment_views_are_importable_directly(self): + from editor_assignment import views + + for name in ( + "unassigned", + "unassigned_article", + "assign_editor", + "assign_editor_move_to_review", + "unassign_editor", + "assignment_notification", + "move_to_review", + ): + self.assertTrue(hasattr(views, name)) + + def test_editor_assignment_logic_helpers_are_importable_directly(self): + from editor_assignment import logic + + for name in ( + "assign_editor", + "get_assignment_context", + "get_unassignment_context", + ): + self.assertTrue(hasattr(logic, name)) + + def test_review_unassigned_url_name_still_resolves(self): + # Legacy URL name and path remain available for backward compatibility. + path = reverse("review_unassigned") + self.assertIn("/review/unassigned/", path) + + def test_review_unassigned_article_url_name_still_resolves(self): + path = reverse("review_unassigned_article", kwargs={"article_id": 1}) + self.assertIn("/review/unassigned/article/1/", path) + + def test_editor_assignment_list_is_primary_url(self): + path = reverse("editor_assignment_list") + self.assertIn("/editor-assignment/", path) + + def test_editor_assignment_article_is_primary_url(self): + path = reverse("editor_assignment_article", kwargs={"article_id": 1}) + self.assertIn("/editor-assignment/article/1/", path) + + def test_all_primary_url_names_resolve(self): + for name, kwargs in ( + ("editor_assignment_list", {}), + ("editor_assignment_article", {"article_id": 1}), + ( + "editor_assignment_assign", + {"article_id": 1, "editor_id": 2, "assignment_type": "editor"}, + ), + ( + "editor_assignment_assign_and_move", + {"article_id": 1, "editor_id": 2, "assignment_type": "editor"}, + ), + ("editor_assignment_unassign", {"article_id": 1, "editor_id": 2}), + ("editor_assignment_notification", {"article_id": 1, "editor_id": 2}), + ("editor_assignment_move_to_review", {"article_id": 1}), + ): + self.assertIn( + "/editor-assignment/", + reverse(name, kwargs=kwargs), + msg="Primary URL {} must mount under /editor-assignment/".format( + name, + ), + ) + + def test_core_workflow_can_import_assign_editor_from_review_logic(self): + # core.workflow uses `from review.logic import assign_editor` — + # this must continue to resolve to the moved callable. + from review.logic import assign_editor as review_assign_editor + from editor_assignment.logic import assign_editor as new_assign_editor + + self.assertIs(review_assign_editor, new_assign_editor) diff --git a/src/editor_assignment/urls.py b/src/editor_assignment/urls.py new file mode 100644 index 0000000000..004b15c53e --- /dev/null +++ b/src/editor_assignment/urls.py @@ -0,0 +1,56 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + + +from django.urls import re_path + +from editor_assignment import views + + +urlpatterns = [ + re_path( + r"^$", + views.unassigned, + name="editor_assignment_list", + ), + re_path( + r"^article/(?P\d+)/$", + views.unassigned_article, + name="editor_assignment_article", + ), + re_path( + r"^article/(?P\d+)/assign/(?P\d+)/type/(?P[-\w.]+)/$", + views.assign_editor, + name="editor_assignment_assign", + ), + re_path( + r"^article/(?P\d+)/assign/(?P\d+)/type/(?P[-\w.]+)/" + r"move/review/$", + views.assign_editor_move_to_review, + name="editor_assignment_assign_and_move", + ), + re_path( + r"^article/(?P\d+)/unassign/(?P\d+)/$", + views.unassign_editor, + name="editor_assignment_unassign", + ), + re_path( + r"^article/(?P\d+)/notify/(?P\d+)/$", + views.assignment_notification, + name="editor_assignment_notification", + ), + re_path( + r"^article/(?P\d+)/move/next/$", + views.move_to_next_stage, + name="editor_assignment_move_to_next_stage", + ), + # Backward-compat alias — the action is now generic. New code should + # use editor_assignment_move_to_next_stage. + re_path( + r"^article/(?P\d+)/move/review/$", + views.move_to_next_stage, + name="editor_assignment_move_to_review", + ), +] diff --git a/src/editor_assignment/views.py b/src/editor_assignment/views.py new file mode 100644 index 0000000000..b5556f0895 --- /dev/null +++ b/src/editor_assignment/views.py @@ -0,0 +1,401 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + + +from django.contrib import messages +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse + +from core import ( + forms as core_forms, + logic as core_logic, + models as core_models, +) +from editor_assignment import logic +from events import logic as event_logic +from review import models +from security.decorators import ( + any_editor_user_required, + editor_user_required, + senior_editor_user_required, +) +from submission import models as submission_models +from utils import ithenticate, models as util_models + + +@any_editor_user_required +def unassigned(request): + """ + Displays a list of unassigned articles. + :param request: HttpRequest object + :return: HttpResponse + """ + articles = submission_models.Article.objects.filter( + stage=submission_models.STAGE_UNASSIGNED, journal=request.journal + ) + + if not request.user.is_editor(request) and request.user.is_section_editor(request): + articles = core_logic.filter_articles_to_editor_assigned(request, articles) + + template = "review/unassigned.html" + context = { + "articles": articles, + } + + return render(request, template, context) + + +@editor_user_required +def unassigned_article(request, article_id): + """ + Displays metadata of an individual article, can send details to Crosscheck for reporting. + :param request: HttpRequest object + :param article_id: Article PK + :return: HttpResponse or Redirect if POST + """ + article = get_object_or_404( + submission_models.Article, + pk=article_id, + journal=request.journal, + ) + + if article.ithenticate_id and not article.ithenticate_score: + ithenticate.fetch_percentage(request.journal, [article]) + + if "crosscheck" in request.POST: + file_id = request.POST.get("crosscheck") + file = get_object_or_404(core_models.File, pk=file_id) + try: + id = ithenticate.send_to_ithenticate(article, file) + article.ithenticate_id = id + article.save() + except AssertionError: + messages.add_message( + request, + messages.ERROR, + "Error returned by iThenticate. Check login details and API status.", + ) + + return redirect( + reverse( + "review_unassigned_article", + kwargs={"article_id": article.pk}, + ) + ) + + current_editors = [ + assignment.editor.pk + for assignment in models.EditorAssignment.objects.filter(article=article) + ] + editors = core_models.AccountRole.objects.filter( + role__slug="editor", journal=request.journal + ).exclude(user__id__in=current_editors) + section_editors = core_models.AccountRole.objects.filter( + role__slug="section-editor", journal=request.journal + ).exclude(user__id__in=current_editors) + + template = "review/unassigned_article.html" + context = { + "article": article, + "editors": editors, + "section_editors": section_editors, + "next_workflow_element": _next_workflow_element(request.journal), + } + + return render(request, template, context) + + +@senior_editor_user_required +def assign_editor_move_to_review(request, article_id, editor_id, assignment_type): + """Allows an editor to assign another editor to an article and moves to review.""" + assign_editor( + request, article_id, editor_id, assignment_type, should_redirect=False + ) + return move_to_review(request, article_id) + + +@senior_editor_user_required +def assign_editor( + request, article_id, editor_id, assignment_type, should_redirect=True +): + """ + Allows a Senior Editor to assign another editor to an article. + :param request: HttpRequest object + :param article_id: Article PK + :param editor_id: Account PK + :param assignment_type: string, 'section-editor' or 'editor' + :param should_redirect: if true, we redirect the user to the notification page + :return: HttpResponse or HttpRedirect + """ + article = get_object_or_404( + submission_models.Article, + pk=article_id, + journal=request.journal, + ) + editor = get_object_or_404(core_models.Account, pk=editor_id) + + if not editor.has_an_editor_role(request): + messages.add_message( + request, messages.WARNING, "User is not an Editor or Section Editor" + ) + return redirect( + reverse("review_unassigned_article", kwargs={"article_id": article.pk}) + ) + + _, created = logic.assign_editor(article, editor, assignment_type, request) + messages.add_message( + request, messages.SUCCESS, "{0} added as an Editor".format(editor.full_name()) + ) + if created and should_redirect: + return redirect( + "{0}?return={1}".format( + reverse( + "review_assignment_notification", + kwargs={"article_id": article_id, "editor_id": editor.pk}, + ), + request.GET.get("return"), + ) + ) + elif not created: + messages.add_message( + request, + messages.WARNING, + "{0} is already an Editor on this article.".format(editor.full_name()), + ) + if should_redirect: + return redirect( + reverse("review_unassigned_article", kwargs={"article_id": article_id}) + ) + + +@senior_editor_user_required +def unassign_editor(request, article_id, editor_id): + """Unassigns an editor from an article""" + article = get_object_or_404( + submission_models.Article, + pk=article_id, + journal=request.journal, + ) + editor = get_object_or_404(core_models.Account, pk=editor_id) + assignment = get_object_or_404( + models.EditorAssignment, article=article, editor=editor + ) + skip = request.POST.get("skip") + email_context = logic.get_unassignment_context(request, assignment) + form = core_forms.SettingEmailForm( + setting_name="unassign_editor", + email_context=email_context, + request=request, + ) + + if request.method == "POST": + form = core_forms.SettingEmailForm( + request.POST, + request.FILES, + setting_name="unassign_editor", + email_context=email_context, + request=request, + ) + + if form.is_valid() or skip: + kwargs = { + "email_data": form.as_dataclass(), + "assignment": assignment, + "request": request, + "skip": skip, + } + + event_logic.Events.raise_event( + event_logic.Events.ON_ARTICLE_UNASSIGNED, **kwargs + ) + + assignment.delete() + + util_models.LogEntry.add_entry( + types="EditorialAction", + description="Editor {0} unassigned from article {1}".format( + editor.full_name(), article.id + ), + level="Info", + request=request, + target=article, + ) + + return redirect( + reverse("review_unassigned_article", kwargs={"article_id": article_id}) + ) + + template = "review/unassign_editor.html" + context = { + "article": article, + "assignment": assignment, + "form": form, + } + + return render(request, template, context) + + +@senior_editor_user_required +def assignment_notification(request, article_id, editor_id): + """ + A senior editor can sent a notification to an assigned editor. + :param request: HttpRequest object + :param article_id: Article PK + :param editor_id: Account PK + :return: HttpResponse or HttpRedirect + """ + article = get_object_or_404( + submission_models.Article, + pk=article_id, + journal=request.journal, + ) + editor = get_object_or_404(core_models.Account, pk=editor_id) + assignment = get_object_or_404( + models.EditorAssignment, article=article, editor=editor, notified=False + ) + + email_context = logic.get_assignment_context(request, article, editor, assignment) + + form = core_forms.SettingEmailForm( + setting_name="editor_assignment", + email_context=email_context, + request=request, + ) + + if request.POST: + form = core_forms.SettingEmailForm( + request.POST, + request.FILES, + setting_name="editor_assignment", + email_context=email_context, + request=request, + ) + skip = request.POST.get("skip") + form_valid = form.is_valid() + if skip or form_valid: + kwargs = { + "editor_assignment": assignment, + "request": request, + "skip": skip, + "email_data": form.as_dataclass(), + } + + event_logic.Events.raise_event( + event_logic.Events.ON_EDITOR_MANUALLY_ASSIGNED, **kwargs + ) + + assignment.notified = True + assignment.save() + + if request.GET.get("return", None): + return redirect(request.GET.get("return")) + else: + return redirect( + reverse( + "review_unassigned_article", kwargs={"article_id": article_id} + ) + ) + + template = "review/assignment_notification.html" + context = { + "article": article, + "editor": editor, + "assignment": assignment, + "form": form, + } + + return render(request, template, context) + + +def _next_workflow_element(journal): + """Compatibility shim. Prefer core.workflow.get_next_workflow_element.""" + from core import workflow as core_workflow + + return core_workflow.get_next_workflow_element(journal, "editor_assignment") + + +def _setup_next_stage(article, next_element): + """Idempotent per-stage setup when an article enters the next workflow + element. New downstream stages should add their initialisation here + (or, when there are enough of them, this should become a registry). + """ + if next_element.element_name == "review": + models.ReviewRound.objects.get_or_create(article=article, round_number=1) + elif next_element.element_name == "screening": + from screening import logic as screening_logic + + if not screening_logic.screening_models.ScreeningRound.objects.filter( + article=article, + ).exists(): + screening_logic.open_screening_round(article) + + +@editor_user_required +def move_to_next_stage(request, article_id, should_redirect=True): + """Move an article out of editor assignment into whichever workflow + element follows it for this journal. + + Replaces the old hardcoded move_to_review action so that journals with + a Screening element get routed to Screening, and any future workflow + element inserted after Editor Assignment is honoured automatically. + """ + article = get_object_or_404( + submission_models.Article, + pk=article_id, + journal=request.journal, + ) + + if article.editorassignment_set.all().count() == 0: + messages.add_message( + request, + messages.INFO, + "You must assign an editor before moving the article on.", + ) + if should_redirect: + return redirect( + reverse("review_unassigned_article", kwargs={"article_id": article_id}) + ) + return + + next_element = _next_workflow_element(request.journal) + if next_element is None: + messages.add_message( + request, + messages.WARNING, + "There is no next workflow element configured for this journal.", + ) + if should_redirect: + return redirect( + reverse("review_unassigned_article", kwargs={"article_id": article_id}) + ) + return + + # Pre-create the next stage's artefacts (Round 1, etc.) so they exist + # when the user lands on the next page. + _setup_next_stage(article, next_element) + + # Delegate the stage transition, log entry, and redirect to the + # canonical core.workflow machinery. + from core import workflow as core_workflow + + workflow = request.journal.workflow() + current_element = workflow.elements.get(element_name="editor_assignment") + response = core_workflow.workflow_next( + handshake_url=current_element.handshake_url, + request=request, + article=article, + switch_stage=True, + ) + if response and should_redirect: + if request.GET.get("return", None): + return redirect(request.GET.get("return")) + return response + if should_redirect: + return redirect(reverse("core_dashboard")) + + +# Backward-compat alias for code/URLs that still reference the original +# move_to_review name. +move_to_review = move_to_next_stage diff --git a/src/review/logic.py b/src/review/logic.py index d2e162a6d5..484ad6ac4f 100755 --- a/src/review/logic.py +++ b/src/review/logic.py @@ -41,6 +41,15 @@ from events import logic as event_logic from submission import models as submission_models +# Editor-assignment logic helpers live in the editor_assignment app. They are +# re-exported here so existing imports (e.g. `from review.logic import +# assign_editor` in core.workflow) continue to resolve. See bau#271. +from editor_assignment.logic import ( # noqa: E402, F401 + assign_editor, + get_assignment_context, + get_unassignment_context, +) + def get_reviewers(article, candidate_queryset, exclude_pks): prefetch_review_assignment = Prefetch( @@ -194,20 +203,6 @@ def get_previous_round_reviewers(article): ) -def get_assignment_context(request, article, editor, assignment): - review_in_review_url = request.journal.site_url( - reverse("review_in_review", kwargs={"article_id": article.pk}) - ) - email_context = { - "article": article, - "editor": editor, - "assignment": assignment, - "review_in_review_url": review_in_review_url, - } - - return email_context - - def get_review_url(request, review_assignment): review_url = request.journal.site_url( path=reverse("do_review", kwargs={"assignment_id": review_assignment.id}) @@ -300,16 +295,6 @@ def get_withdrawal_notification_context(request, review_assignment): return email_context -def get_unassignment_context(request, assignment): - email_context = { - "article": assignment.article, - "assignment": assignment, - "editor": request.user, - } - - return email_context - - def get_decision_context(request, article, decision, author_review_url): email_context = { "article": article, @@ -838,65 +823,6 @@ def send_review_reminder(request, form, review_assignment, reminder_type): ) -def assign_editor( - article, - editor, - assignment_type, - request=None, - skip=True, - automate_email=False, -): - from core.forms import SettingEmailForm - - assignment, created = models.EditorAssignment.objects.get_or_create( - article=article, - editor=editor, - editor_type=assignment_type, - ) - if request and created and automate_email: - email_context = get_assignment_context( - request, - article, - editor, - assignment, - ) - form = SettingEmailForm( - setting_name="editor_assignment", - email_context=email_context, - request=request, - ) - post_data = { - "subject": form.fields["subject"].initial, - "body": form.fields["body"].initial, - } - form = SettingEmailForm( - post_data, - setting_name="editor_assignment", - email_context=email_context, - request=request, - ) - - if form.is_valid(): - kwargs = { - "email_data": form.as_dataclass(), - "editor_assignment": assignment, - "request": request, - "skip": skip, - "acknowledgement": False, - } - event_logic.Events.raise_event( - event_logic.Events.ON_ARTICLE_ASSIGNED, - task_object=article, - **kwargs, - ) - if not skip: - event_logic.Events.raise_event( - event_logic.Events.ON_ARTICLE_ASSIGNED_ACKNOWLEDGE, - **kwargs, - ) - return assignment, created - - def process_reviewer_csv(path, request, article, form): """ Iterates through a CSV c diff --git a/src/review/views.py b/src/review/views.py index f2999b2c1f..d99cc5e820 100755 --- a/src/review/views.py +++ b/src/review/views.py @@ -49,6 +49,20 @@ from utils import models as util_models, ithenticate, shared, setting_handler from utils.logger import get_logger +# Editor-assignment view functions live in the editor_assignment app. They are +# re-exported here so existing imports (e.g. `from review.views import +# unassigned`) and URL patterns referencing `views.unassigned` continue to +# resolve. See bau#271. +from editor_assignment.views import ( # noqa: F401 + assign_editor, + assign_editor_move_to_review, + assignment_notification, + move_to_review, + unassign_editor, + unassigned, + unassigned_article, +) + logger = get_logger(__name__) @@ -83,87 +97,6 @@ def home(request): return render(request, template, context) -@any_editor_user_required -def unassigned(request): - """ - Displays a list of unassigned articles. - :param request: HttpRequest object - :return: HttpResponse - """ - articles = submission_models.Article.objects.filter( - stage=submission_models.STAGE_UNASSIGNED, journal=request.journal - ) - - if not request.user.is_editor(request) and request.user.is_section_editor(request): - articles = core_logic.filter_articles_to_editor_assigned(request, articles) - - template = "review/unassigned.html" - context = { - "articles": articles, - } - - return render(request, template, context) - - -@editor_user_required -def unassigned_article(request, article_id): - """ - Displays metadata of an individual article, can send details to Crosscheck for reporting. - :param request: HttpRequest object - :param article_id: Article PK - :return: HttpResponse or Redirect if POST - """ - article = get_object_or_404( - submission_models.Article, - pk=article_id, - journal=request.journal, - ) - - if article.ithenticate_id and not article.ithenticate_score: - ithenticate.fetch_percentage(request.journal, [article]) - - if "crosscheck" in request.POST: - file_id = request.POST.get("crosscheck") - file = get_object_or_404(core_models.File, pk=file_id) - try: - id = ithenticate.send_to_ithenticate(article, file) - article.ithenticate_id = id - article.save() - except AssertionError: - messages.add_message( - request, - messages.ERROR, - "Error returned by iThenticate. Check login details and API status.", - ) - - return redirect( - reverse( - "review_unassigned_article", - kwargs={"article_id": article.pk}, - ) - ) - - current_editors = [ - assignment.editor.pk - for assignment in models.EditorAssignment.objects.filter(article=article) - ] - editors = core_models.AccountRole.objects.filter( - role__slug="editor", journal=request.journal - ).exclude(user__id__in=current_editors) - section_editors = core_models.AccountRole.objects.filter( - role__slug="section-editor", journal=request.journal - ).exclude(user__id__in=current_editors) - - template = "review/unassigned_article.html" - context = { - "article": article, - "editors": editors, - "section_editors": section_editors, - } - - return render(request, template, context) - - @editor_user_required def add_projected_issue(request, article_id): """ @@ -235,247 +168,6 @@ def view_ithenticate_report(request, article_id): return render(request, template, context) -@senior_editor_user_required -def assign_editor_move_to_review(request, article_id, editor_id, assignment_type): - """Allows an editor to assign another editor to an article and moves to review.""" - assign_editor( - request, article_id, editor_id, assignment_type, should_redirect=False - ) - return move_to_review(request, article_id) - - -@senior_editor_user_required -def assign_editor( - request, article_id, editor_id, assignment_type, should_redirect=True -): - """ - Allows a Senior Editor to assign another editor to an article. - :param request: HttpRequest object - :param article_id: Article PK - :param editor_id: Account PK - :param assignment_type: string, 'section-editor' or 'editor' - :param should_redirect: if true, we redirect the user to the notification page - :return: HttpResponse or HttpRedirect - """ - article = get_object_or_404( - submission_models.Article, - pk=article_id, - journal=request.journal, - ) - editor = get_object_or_404(core_models.Account, pk=editor_id) - - if not editor.has_an_editor_role(request): - messages.add_message( - request, messages.WARNING, "User is not an Editor or Section Editor" - ) - return redirect( - reverse("review_unassigned_article", kwargs={"article_id": article.pk}) - ) - - _, created = logic.assign_editor(article, editor, assignment_type, request) - messages.add_message( - request, messages.SUCCESS, "{0} added as an Editor".format(editor.full_name()) - ) - if created and should_redirect: - return redirect( - "{0}?return={1}".format( - reverse( - "review_assignment_notification", - kwargs={"article_id": article_id, "editor_id": editor.pk}, - ), - request.GET.get("return"), - ) - ) - elif not created: - messages.add_message( - request, - messages.WARNING, - "{0} is already an Editor on this article.".format(editor.full_name()), - ) - if should_redirect: - return redirect( - reverse("review_unassigned_article", kwargs={"article_id": article_id}) - ) - - -@senior_editor_user_required -def unassign_editor(request, article_id, editor_id): - """Unassigns an editor from an article""" - article = get_object_or_404( - submission_models.Article, - pk=article_id, - journal=request.journal, - ) - editor = get_object_or_404(core_models.Account, pk=editor_id) - assignment = get_object_or_404( - models.EditorAssignment, article=article, editor=editor - ) - skip = request.POST.get("skip") - email_context = logic.get_unassignment_context(request, assignment) - form = core_forms.SettingEmailForm( - setting_name="unassign_editor", - email_context=email_context, - request=request, - ) - - if request.method == "POST": - form = core_forms.SettingEmailForm( - request.POST, - request.FILES, - setting_name="unassign_editor", - email_context=email_context, - request=request, - ) - - if form.is_valid() or skip: - kwargs = { - "email_data": form.as_dataclass(), - "assignment": assignment, - "request": request, - "skip": skip, - } - - event_logic.Events.raise_event( - event_logic.Events.ON_ARTICLE_UNASSIGNED, **kwargs - ) - - assignment.delete() - - util_models.LogEntry.add_entry( - types="EditorialAction", - description="Editor {0} unassigned from article {1}".format( - editor.full_name(), article.id - ), - level="Info", - request=request, - target=article, - ) - - return redirect( - reverse("review_unassigned_article", kwargs={"article_id": article_id}) - ) - - template = "review/unassign_editor.html" - context = { - "article": article, - "assignment": assignment, - "form": form, - } - - return render(request, template, context) - - -@senior_editor_user_required -def assignment_notification(request, article_id, editor_id): - """ - A senior editor can sent a notification to an assigned editor. - :param request: HttpRequest object - :param article_id: Article PK - :param editor_id: Account PK - :return: HttpResponse or HttpRedirect - """ - article = get_object_or_404( - submission_models.Article, - pk=article_id, - journal=request.journal, - ) - editor = get_object_or_404(core_models.Account, pk=editor_id) - assignment = get_object_or_404( - models.EditorAssignment, article=article, editor=editor, notified=False - ) - - email_context = logic.get_assignment_context(request, article, editor, assignment) - - form = core_forms.SettingEmailForm( - setting_name="editor_assignment", - email_context=email_context, - request=request, - ) - - if request.POST: - form = core_forms.SettingEmailForm( - request.POST, - request.FILES, - setting_name="editor_assignment", - email_context=email_context, - request=request, - ) - skip = request.POST.get("skip") - form_valid = form.is_valid() - if skip or form_valid: - kwargs = { - "editor_assignment": assignment, - "request": request, - "skip": skip, - "email_data": form.as_dataclass(), - } - - event_logic.Events.raise_event( - event_logic.Events.ON_EDITOR_MANUALLY_ASSIGNED, **kwargs - ) - - assignment.notified = True - assignment.save() - - if request.GET.get("return", None): - return redirect(request.GET.get("return")) - else: - return redirect( - reverse( - "review_unassigned_article", kwargs={"article_id": article_id} - ) - ) - - template = "review/assignment_notification.html" - context = { - "article": article_id, - "editor": editor, - "assignment": assignment, - "form": form, - } - - return render(request, template, context) - - -@editor_user_required -def move_to_review(request, article_id, should_redirect=True): - """Moves an article into the review stage""" - article = get_object_or_404( - submission_models.Article, - pk=article_id, - journal=request.journal, - ) - - if article.editorassignment_set.all().count() > 0: - article.stage = submission_models.STAGE_ASSIGNED - article.save() - review_round, created = models.ReviewRound.objects.get_or_create( - article=article, round_number=1 - ) - - if not created: - messages.add_message( - request, - messages.WARNING, - "A default review round already exists for this article.", - ) - - else: - messages.add_message( - request, - messages.INFO, - "You must assign an editor before moving into reivew.", - ) - - if should_redirect: - if request.GET.get("return", None): - return redirect(request.GET.get("return")) - else: - return redirect( - "{0}?modal_id={1}".format(reverse("kanban_home"), article_id) - ) - - @editor_is_not_author @editor_user_required def in_review(request, article_id): diff --git a/src/submission/migrations/0090_alter_article_stage.py b/src/submission/migrations/0090_alter_article_stage.py new file mode 100644 index 0000000000..50fa90246b --- /dev/null +++ b/src/submission/migrations/0090_alter_article_stage.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.29 on 2026-05-14 07:11 + +import core.model_utils +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("submission", "0089_merge_20260226_1524"), + ] + + operations = [ + migrations.AlterField( + model_name="article", + name="stage", + field=core.model_utils.DynamicChoiceField( + blank=True, + choices=[ + ("Unsubmitted", "Unsubmitted"), + ("Unassigned", "Unassigned"), + ("Screening", "Screening"), + ("Assigned", "Assigned to Editor"), + ("Under Review", "Peer Review"), + ("Under Revision", "Revision"), + ("Rejected", "Rejected"), + ("Accepted", "Accepted"), + ("Editor Copyediting", "Editor Copyediting"), + ("Author Copyediting", "Author Copyediting"), + ("Final Copyediting", "Final Copyediting"), + ("Typesetting", "Typesetting"), + ("typesetting_plugin", "typesetting_plugin"), + ("Proofing", "Proofing"), + ("pre_publication", "Pre Publication"), + ("Published", "Published"), + ("preprint_review", "Preprint Review"), + ("preprint_published", "Preprint Published"), + ("Archived", "Archived"), + ], + default="Unsubmitted", + help_text="WARNING: Manually changing the stage of a submission overrides Janeway's workflow. It should only be changed to a value which is know to be safe such as a stage an article has already been a part of before.", + max_length=200, + ), + ), + ] diff --git a/src/submission/models.py b/src/submission/models.py index 38b98c96d2..d9fb7c4530 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -603,6 +603,7 @@ def get_jats_article_types(): STAGE_UNSUBMITTED = "Unsubmitted" STAGE_UNASSIGNED = "Unassigned" +STAGE_SCREENING = "Screening" STAGE_ASSIGNED = "Assigned" STAGE_UNDER_REVIEW = "Under Review" STAGE_UNDER_REVISION = "Under Revision" @@ -658,6 +659,7 @@ def get_jats_article_types(): STAGE_CHOICES = [ (STAGE_UNSUBMITTED, "Unsubmitted"), (STAGE_UNASSIGNED, "Unassigned"), + (STAGE_SCREENING, "Screening"), (STAGE_ASSIGNED, "Assigned to Editor"), (STAGE_UNDER_REVIEW, "Peer Review"), (STAGE_UNDER_REVISION, "Revision"), diff --git a/src/templates/admin/core/manager/index.html b/src/templates/admin/core/manager/index.html index f3e94a187c..6c6ca52668 100644 --- a/src/templates/admin/core/manager/index.html +++ b/src/templates/admin/core/manager/index.html @@ -39,6 +39,17 @@

Review

+
+

Screening

+
+ +

Submission

diff --git a/src/templates/admin/elements/article_jump.html b/src/templates/admin/elements/article_jump.html index cd314e8c24..b463eb7c2f 100644 --- a/src/templates/admin/elements/article_jump.html +++ b/src/templates/admin/elements/article_jump.html @@ -7,10 +7,9 @@ {% if editor or section_editor %}
- Editor Assignment {% for element in article.distinct_workflow_elements %} {{ element.element_name|capfirst }} + href="{% url element.jump_url article.pk %}">{{ element.display_name }} {% endfor %} diff --git a/src/templates/admin/elements/breadcrumbs/unassigned_base.html b/src/templates/admin/elements/breadcrumbs/unassigned_base.html index 59419958dc..da0a7700f3 100644 --- a/src/templates/admin/elements/breadcrumbs/unassigned_base.html +++ b/src/templates/admin/elements/breadcrumbs/unassigned_base.html @@ -1 +1 @@ -
  • Unassigned articles
  • \ No newline at end of file +
  • Editor Assignment
  • \ No newline at end of file diff --git a/src/templates/admin/review/unassigned.html b/src/templates/admin/review/unassigned.html index 4eeb3fbe2d..63c87b3ef5 100644 --- a/src/templates/admin/review/unassigned.html +++ b/src/templates/admin/review/unassigned.html @@ -1,10 +1,10 @@ {% extends "admin/core/base.html" %} -{% block title %}Unassigned Articles{% endblock %} +{% block title %}Editor Assignment{% endblock %} {% block breadcrumbs %} {{ block.super }} -
  • Unassigned Articles
  • +
  • Editor Assignment
  • {% endblock breadcrumbs %} {% block body %} @@ -12,7 +12,7 @@
    -

    Unassigned Articles

    +

    Editor Assignment

    diff --git a/src/templates/admin/review/unassigned_article.html b/src/templates/admin/review/unassigned_article.html index 1c470c6751..11bed8740b 100644 --- a/src/templates/admin/review/unassigned_article.html +++ b/src/templates/admin/review/unassigned_article.html @@ -2,8 +2,8 @@ {% load static roles i18n securitytags hooks %} -{% block title %}Unassigned {{ article.title }}{% endblock %} -{% block title-section %}Unassigned{% endblock %} +{% block title %}Editor Assignment {{ article.title }}{% endblock %} +{% block title-section %}Editor Assignment{% endblock %} {% block title-sub %}{% include "admin/elements/article_title_sub_pii.html" %}{% endblock %} {% block breadcrumbs %} @@ -278,9 +278,11 @@

    Actions

    {% endif %} {% if article.editors %} + + + + + diff --git a/src/templates/admin/elements/screening/checklist_item_form.html b/src/templates/admin/elements/screening/checklist_item_form.html new file mode 100644 index 0000000000..e9172e2339 --- /dev/null +++ b/src/templates/admin/elements/screening/checklist_item_form.html @@ -0,0 +1,21 @@ +{% load foundation %} + +
    +
    +
    +

    {% if modal %} Edit Item{% else %} Add New Item{% endif %}

    +
    +
    + {% include "elements/forms/errors.html" with form=item_form %} +
    + {% csrf_token %} + {{ item_form|foundation }} + + +
    +
    + + + +
    diff --git a/src/templates/admin/elements/screening/element_form.html b/src/templates/admin/elements/screening/element_form.html new file mode 100644 index 0000000000..5ff52c3d5c --- /dev/null +++ b/src/templates/admin/elements/screening/element_form.html @@ -0,0 +1,21 @@ +{% load foundation %} + +
    +
    +
    +

    {% if modal %} Edit Element{% else %} Add New Element{% endif %}

    +
    +
    + {% include "elements/forms/errors.html" with form=element_form %} +
    + {% csrf_token %} + {{ element_form|foundation }} + + +
    +
    + + + +
    diff --git a/src/templates/admin/screening/_checklist_item_row.html b/src/templates/admin/screening/_checklist_item_row.html new file mode 100644 index 0000000000..b4cf9550b8 --- /dev/null +++ b/src/templates/admin/screening/_checklist_item_row.html @@ -0,0 +1,31 @@ +
    + + + + + diff --git a/src/templates/admin/screening/_checklist_panel.html b/src/templates/admin/screening/_checklist_panel.html new file mode 100644 index 0000000000..8cb2a9d342 --- /dev/null +++ b/src/templates/admin/screening/_checklist_panel.html @@ -0,0 +1,101 @@ +
    +{% if checklist %} +
    +
    +

    Technical Checklist

    + {% if checklist_templates %} + +  Change Template + + {% endif %} +
    +
    +

    + + Editorial-only — not visible to screeners or authors. + {% if checklist.template %}Template: {{ checklist.template.name }}.{% endif %} + +

    +
    + + + +{{ screener.email }} + {% for role in screener.role_labels %} + {{ role }} + {% endfor %} + {% for group in screener.group_labels %} + {{ group }} + {% endfor %} +{{ screener.active_screenings_count|default_if_none:0 }}{{ screener.last_screening_completed|date:"Y-m-d"|default:"—" }}
    +
    + {% csrf_token %} + +
    +
    {{ item.label }} + {% if item.comment %} + {{ item.comment|truncatewords:12 }} + {% else %} + No comment + {% endif %} + + + + + {% if item.completed_by %} + {{ item.completed_by.full_name }}{% if item.completed_at %}, {{ item.completed_at|date:"Y-m-d" }}{% endif %} + {% endif %} +
    + + + + + + + + + + {% for item in checklist.items.all %} + {% include "admin/screening/_checklist_item_row.html" %} + {% empty %} + + {% endfor %} + +
    ItemCommentCompleted by
    This checklist has no items.
    +
    +
    + + {% for item in checklist.items.all %} +
    +
    +
    +

     Comment on "{{ item.label }}"

    +
    +
    +
    + {% csrf_token %} + + + + +
    +
    +
    + +
    + {% endfor %} + + {% if checklist_templates %} +
    +
    +
    +

     Change Checklist Template

    +
    +
    +

    + + Switching the template re-seeds the checklist from the chosen template's items. + Existing tick states and comments will be discarded. + +

    +
    + {% csrf_token %} + {% for tpl in checklist_templates %} + + {% endfor %} + + +
    +
    +
    + +
    + {% endif %} +{% endif %} +
    diff --git a/src/templates/admin/screening/add_assignment.html b/src/templates/admin/screening/add_assignment.html new file mode 100644 index 0000000000..b33c72d576 --- /dev/null +++ b/src/templates/admin/screening/add_assignment.html @@ -0,0 +1,121 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} +{% load i18n %} + +{% block title %}{% trans "Invite Screener" %}{% endblock %} +{% block title-section %}{% trans "Invite Screener" %}{% endblock %} +{% block title-sub %}{% include "admin/elements/article_title_sub_pii.html" %}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • {% trans "Screening" %}
  • +
  • {{ article.safe_title }}
  • +
  • {% blocktrans with round_number=screening_round.round_number %}Invite Screener (Round {{ round_number }}){% endblocktrans %}
  • +{% endblock breadcrumbs %} + +{% block body %} +
    +
    + {% include "elements/forms/errors.html" with form=form %} + {% csrf_token %} +
    +
    +

    {% blocktrans with round_number=screening_round.round_number %}1. Select Screener for Round {{ round_number }}{% endblocktrans %}

    +
    +
    +
    +

    + + {% if pool_groups %} + {% blocktrans %} + The screening pool is drawn from members of the + editorial groups selected in the + {% endblocktrans %} + {% trans "Screener Pool manager" %}: + {% for group in pool_groups %}{{ group.name }}{% if not forloop.last %}, {% endif %}{% endfor %}. + {% trans "Anyone already invited to this round is excluded from the list below." %} + {% else %} + {% blocktrans %} + No Screener Pool is configured for this journal, so the list below falls + back to all users holding the Editor + or Section Editor role. Anyone already + invited to this round is excluded. + {% endblocktrans %} + {% endif %} +

    +

    + {% blocktrans %} + Select a candidate using the radio buttons in + the first column, then complete the options in + step 2 before sending the invitation. + {% endblocktrans %} +

    +
    + + + + + + + + + + + + + + {% for screener in candidates %} + + {% include "admin/elements/screening/add_screener_table_row.html" with screener=screener form=form %} + + {% empty %} + + + + {% endfor %} + +
    {% trans "Select" %}{% trans "Name" %}{% trans "Email" %}{% trans "Roles & Editorial Groups" %}{% trans "Active Screenings" %}{% trans "Last Completed" %}
    + {% trans "No editorial team members are available to screen this round. Anyone with the Editor or Section Editor role on this journal who is not already invited to this round will appear here." %} +
    +
    + +
    +

    {% trans "2. Set Options" %}

    +
    +
    +
    +
    + {{ form.form|foundation }} +
    +
    + {{ form.date_due|foundation }} +
    +
    +
    + {% trans "Anonymity" %} + {{ form.anonymous_to_author|foundation }} + {{ form.anonymous_to_coscreeners|foundation }} +
    +
    +
    +
    +
    + + + {% trans "Cancel" %} + +
    +
    +
    +
    +
    +
    +{% endblock body %} + +{% block js %} + {{ block.super }} + {% include "elements/datatables.html" with target="#screeners" %} +{% endblock js %} diff --git a/src/templates/admin/screening/article.html b/src/templates/admin/screening/article.html new file mode 100644 index 0000000000..947a29353a --- /dev/null +++ b/src/templates/admin/screening/article.html @@ -0,0 +1,336 @@ +{% extends "admin/core/base.html" %} +{% load static roles i18n securitytags hooks %} + +{% block title %}Screening {{ article.title }}{% endblock title %} +{% block title-section %}Screening{% endblock %} +{% block title-sub %}{% include "admin/elements/article_title_sub_pii.html" %}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/screening_base.html" %} +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    + {% if rounds %} + +
    + {% for round in rounds %} +
    +
    +
    +

    Screeners

    + {% if round == latest_round and is_screening_stage %} + +  Invite Screener + + {% endif %} +
    + + + + + + + + + + + + + + {% for assignment in round.screeningassignment_set.all %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    ScreenerAnonymity to AuthorAnonymity to Co-screenersDate DueStatusRecommendationActions
    + {{ assignment.screener.full_name|se_can_see_pii:article }} + {% if assignment.screener_id == assignment.editor_id %} + Self-assigned + {% endif %} + {{ assignment.anonymous_to_author|yesno:"Anonymous,Open" }}{{ assignment.anonymous_to_coscreeners|yesno:"Anonymous,Open" }} + {{ assignment.date_due }} + {% if assignment.is_late and not assignment.is_complete %} + + Overdue + + {% endif %} + + {{ assignment.status.display }} + {{ assignment.get_recommendation_display|default:"—" }} + {% if not is_screening_stage %} + {% if assignment.is_complete %} + + View Report + + {% endif %} + {% else %} + + + +
    +
    +
    +

     Withdraw screening assignment

    +
    +
    +

    + Withdrawing this assignment closes + {{ assignment.screener.full_name|se_can_see_pii:article }} + out of this round. They will no longer be able to + accept, decline, or submit a report. Any work they + have already submitted is retained. +

    +

    Proceed?

    +
    + {% csrf_token %} + + +
    +
    +
    + +
    + + {% if assignment.is_complete or assignment.is_withdrawn %} +
    +
    +
    +

     Reset screening assignment

    +
    +
    +

    + {% if assignment.is_withdrawn %} + Resetting re-opens the assignment for + {{ assignment.screener.full_name|se_can_see_pii:article }}. + They will be able to accept, decline, or + submit a report again. + {% else %} + Resetting clears + {{ assignment.screener.full_name|se_can_see_pii:article }}'s + recommendation and completion stamp so they can + revise their report. Saved form answers are kept. + {% endif %} +

    +

    Proceed?

    +
    + {% csrf_token %} + + +
    +
    +
    + +
    + {% endif %} + {% endif %} +
    No screeners invited to this round yet.
    +
    +
    + {% endfor %} +
    + {% else %} +
    +

    Screening Rounds

    +
    +
    +
    +

    + No screening rounds have been opened for this article yet. Use the Actions panel to open the first round. +

    +
    +
    + {% endif %} +
    + + {% if revision_requests %} +
    +
    +

    Revision Requests

    +
    +
    + + + + + + + + + + + + {% for rev in revision_requests %} + + + + + + + + {% endfor %} + +
    RequestedTypeDueStatus
    {{ rev.date_requested|date:"Y-m-d" }}{{ rev.get_type_display }}{{ rev.date_due }} + {% if rev.is_complete %} + Submitted + {% else %} + Awaiting Author + {% endif %} + + View +
    +
    +
    + {% endif %} + + {% include "admin/screening/_checklist_panel.html" %} +
    + +
    +
    +
    +

    Status

    +
    +
    +
    + This article is currently in the {{ article.stage }} stage. +
    +
    +
    + +
    +
    +

    Actions

    +
    +
    + {% if is_screening_stage %} + + {% else %} +
    +

    + This article has moved past the screening stage. Reports are shown for reference only. +

    +
    + {% endif %} +
    +
    + + {% include "admin/elements/contacts_box.html" %} +
    +
    + +{% endblock body %} + +{% block js %} + {{ block.super }} + {% include "admin/core/partials/htmx.html" %} + +{% endblock js %} diff --git a/src/templates/admin/screening/assignment_notification.html b/src/templates/admin/screening/assignment_notification.html new file mode 100644 index 0000000000..7593594da6 --- /dev/null +++ b/src/templates/admin/screening/assignment_notification.html @@ -0,0 +1,42 @@ +{% extends "admin/core/base.html" %} +{% load static %} +{% load settings %} + +{% block css %}{% endblock %} + +{% block title %}Notify Screener{% endblock title %} +{% block title-section %}Notify Screener{% endblock %} +{% block title-sub %}{% include "admin/elements/article_title_sub_pii.html" %}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/screening_base.html" %} +
  • {{ article.safe_title }}
  • +
  • Notify Screener
  • +{% endblock breadcrumbs %} + +{% block body %} +
    +
    +
    +

    Notify the Screener

    +
    +
    +

    Review and edit the invitation email before sending it. Use Skip to leave the assignment in place without sending an email.

    +
    +
    +

    To {{ assignment.screener.full_name }}

    +
    From {{ request.user.full_name }}
    +
    + {% include "admin/elements/email_form.html" with form=form skip=1 %} +
    +
    +
    +
    +{% endblock body %} + +{% block js %} + {{ block.super }} + + {{ form.media.js }} +{% endblock js %} diff --git a/src/templates/admin/screening/do_revisions.html b/src/templates/admin/screening/do_revisions.html new file mode 100644 index 0000000000..59e764162c --- /dev/null +++ b/src/templates/admin/screening/do_revisions.html @@ -0,0 +1,49 @@ +{% extends "core/base.html" %} +{% load foundation %} + +{% block title %}Submit Revisions{% endblock title %} +{% block title-section %}Revisions for "{{ article.safe_title }}"{% endblock %} + +{% block body %} +
    +
    +
    +
    +

    Editor's note

    +
    +
    +

    + + Requested by {{ revision.editor.full_name }} + on {{ revision.date_requested|date:"Y-m-d" }}. + Due by {{ revision.date_due }}. + Revision type: {{ revision.get_type_display }}. + +

    + {{ revision.editor_note|safe }} +
    +
    + +
    +
    +

    Submit your revisions

    +
    +
    +

    + Upload your revised manuscript and add a covering letter describing + the changes you made. Submitting these revisions reopens a new + screening round so the editorial team can review them. +

    +
    + {% csrf_token %} + {{ form|foundation }} + +
    +
    +
    +
    +
    +{% endblock body %} diff --git a/src/templates/admin/screening/do_screening.html b/src/templates/admin/screening/do_screening.html new file mode 100644 index 0000000000..efb4bc0726 --- /dev/null +++ b/src/templates/admin/screening/do_screening.html @@ -0,0 +1,131 @@ +{% extends "admin/core/base.html" %} +{% load foundation hooks static %} + +{% block title %}Screening {{ article.title }}{% endblock title %} +{% block title-section %}Screening{% endblock %} +{% block title-sub %}{% include "admin/elements/article_title_sub_pii.html" %}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/screener_base.html" %} +
  • Screening Form
  • +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +
    +

    Anonymity

    +
    +
    +
    + + {% if assignment.anonymous_to_author %} + Anonymity: your identity will not be shown to the author when this report is shared. + {% else %} + Anonymity: your identity will be visible to the author when this report is shared. + {% endif %} + {% if assignment.anonymous_to_coscreeners %} + Other screeners on this round will not see your identity either. + {% endif %} + +
    +
    + +
    +

    Metadata

    +
    +
    + {% include "admin/elements/review/review_meta_block.html" with review_request=assignment %} +
    +
    + +
    + {% include "elements/author_summary_table.html" with article=article %} +
    + +
    +
    +

    Files

    +
    +
    + + + + + + + + + + + + {% for file in article.manuscript_files.all %} + + + + + + + + {% endfor %} + {% for file in article.data_figure_files.all %} + + + + + + + + {% empty %} + {% endfor %} + +
    LabelFilenameTypeUploadedDownload
    {{ file.label }}{{ file.original_filename }}Manuscript{{ file.last_modified|date:"Y-m-d G:i" }}
    {{ file.label }}{{ file.original_filename }}Data/Figure{{ file.last_modified|date:"Y-m-d G:i" }}
    +
    +
    + +
    + {% if assignment.form.intro %} +
    +

    Information for this Form

    +
    +
    + {{ assignment.form.intro|safe }} +
    + {% endif %} + + {% hook 'screening_form_guidelines' %} + +
    + {% csrf_token %} + + {% if screening_form %} +
    +

    Screening Form

    +
    +
    + {{ screening_form|foundation }} +
    + {% endif %} + +
    +

    Recommendation

    +
    +
    + {{ recommendation_form|foundation }} + + Cancel +
    +
    +
    +
    +
    + +{% endblock body %} + +{% block js %} + {{ block.super }} + +{% endblock js %} diff --git a/src/templates/admin/screening/edit_assignment.html b/src/templates/admin/screening/edit_assignment.html new file mode 100644 index 0000000000..723f8513f9 --- /dev/null +++ b/src/templates/admin/screening/edit_assignment.html @@ -0,0 +1,43 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} + +{% block title %}Edit Screening Assignment{% endblock title %} +{% block title-section %}Edit Screening Assignment{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/screening_base.html" %} +
  • {{ article.safe_title }}
  • +
  • Edit assignment (Round {{ screening_round.round_number }})
  • +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +

    Edit Screening Assignment

    +
    +
    +

    + + Adjust the screener, due date, anonymity flags or the + screening form for this open assignment. Changes do not + affect anything the screener has already submitted. + +

    +
    + {% csrf_token %} + {{ form|foundation }} +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    + +{% endblock body %} diff --git a/src/templates/admin/screening/list.html b/src/templates/admin/screening/list.html new file mode 100644 index 0000000000..588222301a --- /dev/null +++ b/src/templates/admin/screening/list.html @@ -0,0 +1,57 @@ +{% extends "admin/core/base.html" %} +{% load securitytags %} + +{% block title %}Screening{% endblock %} +{% block title-section %}Screening{% endblock %} +{% block title-sub %}Articles awaiting screening{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/screening_base.html" %} +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +

    Screening

    +
    +
    + + + + + + + + + + + + + {% for article in articles %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    IDTitleSubmittedMain AuthorSection
    {{ article.pk }}{{ article.safe_title }}{{ article.date_submitted }}{{ article.correspondence_author.full_name|se_can_see_pii:article }}{{ article.section.name }}View
    No articles in this stage
    +
    +
    +
    +{% endblock body %} + +{% block js %} + {{ block.super }} + {% include "admin/elements/datatables.html" with target="#screening" sort=2 order='asc' %} +{% endblock js %} diff --git a/src/templates/admin/screening/manager/checklist_templates.html b/src/templates/admin/screening/manager/checklist_templates.html new file mode 100644 index 0000000000..ef2241f18f --- /dev/null +++ b/src/templates/admin/screening/manager/checklist_templates.html @@ -0,0 +1,81 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} + +{% block title %}Technical Checklist Templates{% endblock title %} +{% block title-section %}Technical Checklist Templates{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Manager
  • +
  • Technical Checklist Templates
  • +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +

    Journal's Checklist Templates

    +
    +
    +
      + {% for template in template_list %} +
    • + {{ template.name }} + {% if template.is_default %} (Default){% endif %} +
      + + +
      +
    • + {% empty %} +
    • No checklist templates on this journal yet.
    • + {% endfor %} +
    +
    +
    +
    + +
    +
    +
    +

    Add New Template

    +
    +
    +
    + {% csrf_token %} + {{ form|foundation }} + +
    +
    +
    +
    + + {% for template in template_list %} +
    +
    +
    +

     Delete checklist template

    +
    +
    +

    + Existing per-article checklists derived from + "{{ template.name }}" will keep their items. + The template will no longer be available for future articles + entering screening. +

    +

    Proceed?

    +
    + {% csrf_token %} + + +
    +
    +
    + +
    + {% endfor %} + +{% endblock body %} diff --git a/src/templates/admin/screening/manager/edit_checklist_template.html b/src/templates/admin/screening/manager/edit_checklist_template.html new file mode 100644 index 0000000000..539cf3c527 --- /dev/null +++ b/src/templates/admin/screening/manager/edit_checklist_template.html @@ -0,0 +1,71 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} + +{% block title %}Edit Checklist Template{% endblock title %} +{% block title-section %}Edit Checklist Template{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Manager
  • +
  • Checklist Templates
  • +
  • {{ edit_template.name }}
  • +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +

    Template's Items

    + Add Item +
    +
    + +
    + {% csrf_token %} +
      + {% for tpl_item in edit_template.items.all %} +
    • + {{ tpl_item.label }} +
      + + +
      +
    • + {% empty %} +
    • No items on this template yet.
    • + {% endfor %} +
    +
    + +
    +
    +
    + +
    +
    +
    +

    Edit Template Detail

    +
    +
    +
    + {% csrf_token %} + {{ form|foundation }} + +
    +
    +
    +
    + + {% include "elements/screening/checklist_item_form.html" %} + +{% endblock body %} + +{% block js %} + {% if modal %} + {% include "admin/elements/open_modal.html" with target=modal %} + {% endif %} +{% endblock %} diff --git a/src/templates/admin/screening/manager/edit_screening_form.html b/src/templates/admin/screening/manager/edit_screening_form.html new file mode 100644 index 0000000000..015fbfbf5f --- /dev/null +++ b/src/templates/admin/screening/manager/edit_screening_form.html @@ -0,0 +1,72 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} + +{% block title %}Edit Screening Form{% endblock title %} +{% block title-section %}Edit Screening Form{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Manager
  • +
  • Screening Forms
  • +
  • {{ edit_form.name }}
  • +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +

    Form's Elements

    + Add Element +
    +
    + +
    + {% csrf_token %} +
      + {% for element in edit_form.elements.all %} +
    • + {{ element.name }} + ({{ element.kind }}) +
      + + +
      +
    • + {% empty %} +
    • No elements on this form yet.
    • + {% endfor %} +
    +
    + +
    +
    +
    + +
    +
    +
    +

    Edit Form Detail

    +
    +
    +
    + {% csrf_token %} + {{ form|foundation }} + +
    +
    +
    +
    + + {% include "elements/screening/element_form.html" %} + +{% endblock body %} + +{% block js %} + {% if modal %} + {% include "admin/elements/open_modal.html" with target=modal %} + {% endif %} +{% endblock %} diff --git a/src/templates/admin/screening/manager/screening_forms.html b/src/templates/admin/screening/manager/screening_forms.html new file mode 100644 index 0000000000..7257ce4cd4 --- /dev/null +++ b/src/templates/admin/screening/manager/screening_forms.html @@ -0,0 +1,79 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} + +{% block title %}Screening Forms{% endblock title %} +{% block title-section %}Screening Forms{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Manager
  • +
  • Screening Forms
  • +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +

    Journal's Forms

    +
    +
    +
      + {% for screening_form in form_list %} +
    • + {{ screening_form.name }} +
      + + +
      +
    • + {% empty %} +
    • No screening forms on this journal yet.
    • + {% endfor %} +
    +
    +
    +
    + +
    +
    +
    +

    Add New Form

    +
    +
    +
    + {% csrf_token %} + {{ form|foundation }} + +
    +
    +
    +
    + + {% for screening_form in form_list %} +
    +
    +
    +

     Delete screening form

    +
    +
    +

    + Deleting "{{ screening_form.name }}" won't delete any + active or past screening reports using it. The form will no + longer be available for selection on future screenings. +

    +

    Proceed?

    +
    + {% csrf_token %} + + +
    +
    +
    + +
    + {% endfor %} + +{% endblock body %} diff --git a/src/templates/admin/screening/manager/screening_pool.html b/src/templates/admin/screening/manager/screening_pool.html new file mode 100644 index 0000000000..cf0c9b03d0 --- /dev/null +++ b/src/templates/admin/screening/manager/screening_pool.html @@ -0,0 +1,42 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} + +{% block title %}Screener Pool{% endblock title %} +{% block title-section %}Screener Pool{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Manager
  • +
  • Screener Pool
  • +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +

    Screener Pool

    +
    +
    +
    +

    + Select the editorial groups whose members appear in + the screener selection list. If no groups are selected, + the screener pool falls back to all users holding the + Editor or Section Editor role on this journal. +

    +

    + To control who belongs to each group, use the + Editorial Team manager. +

    +
    +
    + {% csrf_token %} + {{ form|foundation }} + +
    +
    +
    +
    + +{% endblock body %} diff --git a/src/templates/admin/screening/request_revisions.html b/src/templates/admin/screening/request_revisions.html new file mode 100644 index 0000000000..1ecf4455bd --- /dev/null +++ b/src/templates/admin/screening/request_revisions.html @@ -0,0 +1,41 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} + +{% block title %}Request Revisions{% endblock title %} +{% block title-section %}Request Revisions{% endblock %} +{% block title-sub %}{% include "admin/elements/article_title_sub_pii.html" %}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/screening_base.html" %} +
  • {{ article.safe_title }}
  • +
  • Request Revisions
  • +{% endblock breadcrumbs %} + +{% block body %} +
    +
    +
    +

    Request Revisions from the Author

    +
    +
    +

    + Use this action to ask the corresponding author to revise their submission + in response to screening. The author will be notified by email and lands on + a dedicated revision page where they can upload revised files and add a + covering letter. Submitting the revisions opens a fresh screening round + automatically. +

    +
    + {% csrf_token %} + {{ form|foundation }} + + Cancel +
    +
    +
    +
    +{% endblock body %} diff --git a/src/templates/admin/screening/requests.html b/src/templates/admin/screening/requests.html new file mode 100644 index 0000000000..d2655bb3e6 --- /dev/null +++ b/src/templates/admin/screening/requests.html @@ -0,0 +1,124 @@ +{% extends "admin/core/base.html" %} + +{% block title %}Screening Requests{% endblock title %} +{% block title-section %}Screening Requests{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/screener_base.html" %} +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +

    Your Screening Requests

    +
    +
    + + + + + + + + + + + + + {% for assignment in pending %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    ArticleRoundDate RequestedDate DueStatusActions
    {{ assignment.article.safe_title }}{{ assignment.screening_round.round_number }}{{ assignment.date_requested|date:"Y-m-d" }} + {{ assignment.date_due }} + {% with days=assignment.days_until_due %} + {% if days is not None %} + {% if days < 0 %} + + Overdue by {{ days|stringformat:"d"|cut:"-" }} day{{ days|stringformat:"d"|cut:"-"|pluralize }} + + {% elif days == 0 %} + Due today + {% else %} + Due in {{ days }} day{{ days|pluralize }} + {% endif %} + {% endif %} + {% endwith %} + + {{ assignment.status.display }} + + {% if not assignment.date_accepted and not assignment.date_declined %} + Accept + Decline + {% elif assignment.date_accepted and not assignment.is_complete %} + Open Report + {% endif %} +
    You have no pending screening requests.
    +
    +
    + + {% if completed %} +
    +
    +

    Completed & Declined Screening Requests

    +
    +
    + + + + + + + + + + + {% for assignment in completed %} + + + + + + + {% endfor %} + +
    ArticleRoundDateOutcome
    {{ assignment.article.safe_title }}{{ assignment.screening_round.round_number }} + {% if assignment.is_complete %} + {{ assignment.date_complete|date:"Y-m-d" }} + {% else %} + {{ assignment.date_declined|date:"Y-m-d" }} + {% endif %} + + {% if assignment.is_complete %} + {{ assignment.get_recommendation_display|default:"—" }} + {% else %} + Declined + {% endif %} +
    +
    +
    + {% endif %} +
    +{% endblock body %} + +{% block js %} + {{ block.super }} + {% include "admin/elements/datatables.html" with target="#screening-requests" sort=2 order='desc' %} + {% if completed %} + {% include "admin/elements/datatables.html" with target="#completed-screening-requests" sort=2 order='desc' %} + {% endif %} +{% endblock js %} diff --git a/src/templates/admin/screening/thanks.html b/src/templates/admin/screening/thanks.html new file mode 100644 index 0000000000..bb8f098535 --- /dev/null +++ b/src/templates/admin/screening/thanks.html @@ -0,0 +1,31 @@ +{% extends "admin/core/base.html" %} + +{% block title %}Screening Report Submitted{% endblock title %} +{% block title-section %}Screening Complete{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/screener_base.html" %} +
  • Thanks
  • +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +
    +

    Thank you

    +
    +
    +

    + Your screening report for {{ article.safe_title }} + has been submitted. The managing editor will be notified. +

    +

    + Continue +

    +
    +
    +
    + +{% endblock body %} diff --git a/src/templates/admin/screening/view_report.html b/src/templates/admin/screening/view_report.html new file mode 100644 index 0000000000..0d666ecb6a --- /dev/null +++ b/src/templates/admin/screening/view_report.html @@ -0,0 +1,96 @@ +{% extends "admin/core/base.html" %} +{% load securitytags %} + +{% block title %}Screening Report{% endblock title %} +{% block title-section %}Screening Report{% endblock %} +{% block title-sub %}{% include "admin/elements/article_title_sub_pii.html" %}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/screening_base.html" %} +
  • {{ article.safe_title }}
  • +
  • Report #{{ assignment.pk }}
  • +{% endblock breadcrumbs %} + +{% block body %} + +
    +
    +

    + + Back + +

    +
    +
    +

    Screener

    +
    +
    +
    + {% include "admin/elements/layout/key_value_above.html" with key="Screener" value=assignment.screener.full_name|se_can_see_pii:article %} + {% include "admin/elements/layout/key_value_above.html" with key="Date due" value=assignment.date_due %} + {% include "admin/elements/layout/key_value_above.html" with key="Date completed" value=assignment.date_complete|date:"Y-m-d H:i" %} + {% include "admin/elements/layout/key_value_above.html" with key="Anonymity to author" value=assignment.anonymous_to_author|yesno:"Anonymous,Open" %} + {% include "admin/elements/layout/key_value_above.html" with key="Anonymity to co-screeners" value=assignment.anonymous_to_coscreeners|yesno:"Anonymous,Open" %} +
    +
    + +
    +

    Recommendation

    +
    +
    +

    + {{ assignment.get_recommendation_display|default:"—" }} +

    +
    + + {% if assignment.suggested_reviewers %} +
    +

    Suggested Reviewers

    +
    +
    +

    Hidden from the author.

    +

    {{ assignment.suggested_reviewers|linebreaksbr }}

    +
    + {% endif %} + + {% if assignment.comments_for_editor %} +
    +

    Comments for the Editor

    +
    +
    +

    Hidden from the author.

    +

    {{ assignment.comments_for_editor|safe }}

    +
    + {% endif %} + +
    +

    Screening Form Answers

    +
    +
    + {% if answers %} + + + + + + + + + {% for answer in answers %} + + + + + {% endfor %} + +
    QuestionAnswer
    {{ answer.best_label }}{{ answer.answer|safe|default:"—" }}
    + {% else %} +

    The screener completed this report without a form, or no answers were recorded.

    + {% endif %} +
    +
    +
    +
    + +{% endblock body %} diff --git a/src/templates/admin/screening/view_revision.html b/src/templates/admin/screening/view_revision.html new file mode 100644 index 0000000000..22b26e804f --- /dev/null +++ b/src/templates/admin/screening/view_revision.html @@ -0,0 +1,53 @@ +{% extends "admin/core/base.html" %} + +{% block title %}Screening Revision{% endblock title %} +{% block title-section %}Screening Revision{% endblock %} +{% block title-sub %}{% include "admin/elements/article_title_sub_pii.html" %}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/screening_base.html" %} +
  • {{ article.safe_title }}
  • +
  • Revision #{{ revision.pk }}
  • +{% endblock breadcrumbs %} + +{% block body %} +
    +

    + + Back + +

    + +
    +
    +

    Request

    +
    +
    +
    + {% include "admin/elements/layout/key_value_above.html" with key="Requested by" value=revision.editor.full_name %} + {% include "admin/elements/layout/key_value_above.html" with key="Date requested" value=revision.date_requested|date:"Y-m-d" %} + {% include "admin/elements/layout/key_value_above.html" with key="Date due" value=revision.date_due %} + {% include "admin/elements/layout/key_value_above.html" with key="Type" value=revision.get_type_display %} + {% include "admin/elements/layout/key_value_above.html" with key="Date completed" value=revision.date_completed|date:"Y-m-d H:i" %} +
    +
    + +
    +

    Note to the author

    +
    +
    + {{ revision.editor_note|safe|default:"No note provided." }} +
    + + {% if revision.is_complete %} +
    +

    Author's covering letter

    +
    +
    + {{ revision.author_note|safe|default:"No covering letter provided." }} +
    + {% endif %} +
    +
    +{% endblock body %} diff --git a/src/utils/install/journal_defaults.json b/src/utils/install/journal_defaults.json index ee1653bb10..10b13da0d6 100644 --- a/src/utils/install/journal_defaults.json +++ b/src/utils/install/journal_defaults.json @@ -5672,5 +5672,195 @@ "editor", "journal-manager" ] + }, + { + "group": { + "name": "email_subject" + }, + "setting": { + "description": "Subject for email sent to a screener when they are invited to screen an article.", + "is_translatable": true, + "name": "subject_screening_invitation", + "pretty_name": "Screening Invitation", + "type": "char" + }, + "value": { + "default": "Screening Invitation" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to a screener when they are invited to screen an article.", + "is_translatable": true, + "name": "screening_invitation", + "pretty_name": "Screening Invitation", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ screening_assignment.screener.salutation_name }},

    We are inviting you to provide a screening report on \"{{ article.safe_title }}\" in {{ article.journal.name }} ahead of any peer review.

    You can view the invitation and respond at your screening dashboard: {{ screening_requests_url }}

    Regards,
    {{ request.user.signature|safe }}" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email_subject" + }, + "setting": { + "description": "Subject for email sent to the managing editor when a screener submits their report.", + "is_translatable": true, + "name": "subject_screening_complete", + "pretty_name": "Screening Report Complete", + "type": "char" + }, + "value": { + "default": "Screening Report Complete" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to the managing editor when a screener submits their report.", + "is_translatable": true, + "name": "screening_complete", + "pretty_name": "Screening Report Complete", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ screening_assignment.editor.salutation_name }},

    {{ screening_assignment.screener.full_name }} has submitted a screening report for \"{{ article.safe_title }}\" in {{ article.journal.name }} with this recommendation: {{ screening_assignment.get_recommendation_display }}.

    You can view the report and the rest of the screening round at: {{ screening_article_url }}

    Regards" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email_subject" + }, + "setting": { + "description": "Subject for email sent to the corresponding author when their submission passes screening.", + "is_translatable": true, + "name": "subject_screening_passed", + "pretty_name": "Screening Passed", + "type": "char" + }, + "value": { + "default": "Your submission has passed screening" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to the corresponding author when their submission passes screening into the next workflow stage.", + "is_translatable": true, + "name": "screening_passed", + "pretty_name": "Screening Passed", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ article.correspondence_author.salutation_name }},

    Your submission \"{{ article.safe_title }}\" to {{ article.journal.name }} has passed our screening process and is now moving into the next stage of our editorial workflow{% if next_workflow_element %} ({{ next_workflow_element.display_name }}){% endif %}.

    You will hear from us again as the article progresses.

    Regards" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email_subject" + }, + "setting": { + "description": "Subject for email sent to the author when an editor requests screening revisions.", + "is_translatable": true, + "name": "subject_screening_revisions_requested", + "pretty_name": "Screening Revisions Requested", + "type": "char" + }, + "value": { + "default": "Revisions requested following screening" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to the corresponding author when an editor requests revisions following screening.", + "is_translatable": true, + "name": "screening_revisions_requested", + "pretty_name": "Screening Revisions Requested", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ article.correspondence_author.salutation_name }},

    Following screening of your submission \"{{ article.safe_title }}\" to {{ article.journal.name }}, the editorial team has asked for revisions before peer review can proceed.

    Please follow the link below to read the editor's notes and upload your revised manuscript:

    {{ do_revisions_url }}

    Revisions are due by {{ screening_revision.date_due }}.

    Regards" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email_subject" + }, + "setting": { + "description": "Subject for email sent to the editor when an author submits screening revisions.", + "is_translatable": true, + "name": "subject_screening_revisions_completed", + "pretty_name": "Screening Revisions Completed", + "type": "char" + }, + "value": { + "default": "Author has submitted screening revisions" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to the editor when the author submits their screening revisions.", + "is_translatable": true, + "name": "screening_revisions_completed", + "pretty_name": "Screening Revisions Completed", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ screening_revision.editor.salutation_name }},

    {{ article.correspondence_author.full_name }} has submitted revisions to \"{{ article.safe_title }}\" following your screening request.

    A new screening round has been opened automatically. Open the article to review the revisions and invite screeners:

    {{ article_url }}

    Regards" + }, + "editable_by": [ + "editor", + "journal-manager" + ] } ] From 12688bce0cab7272fd4438ca524651775d67db80 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Fri, 15 May 2026 16:48:54 +0100 Subject: [PATCH 04/13] feat(screening): integrate screener surfaces in dashboard, kanban, and navigation --- src/core/views.py | 29 +++++++++++++++++-- src/templates/admin/core/dashboard.html | 24 +++++++++++++++ src/templates/admin/core/kanban.html | 11 +++++-- src/templates/admin/core/nav.html | 10 ++----- .../admin/elements/core/editor_dashboard.html | 9 ++---- .../admin/elements/core/kanban/card.html | 4 +++ 6 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/core/views.py b/src/core/views.py index 24706c8c8d..01730d3ec5 100755 --- a/src/core/views.py +++ b/src/core/views.py @@ -52,6 +52,7 @@ from review import models as review_models from copyediting import models as copyedit_models from production import models as production_models +from screening import models as screening_models from journal import models as journal_models from proofing import logic as proofing_logic from proofing import models as proofing_models @@ -827,9 +828,6 @@ def dashboard(request): "new_proofing_typesetting": new_proofing_typesetting.count(), "completed_proofing_typesetting": completed_proofing_typesetting.count(), "active_proofing_typesetting": active_proofing_typesetting.count(), - "unassigned_articles_count": submission_models.Article.objects.filter( - stage=submission_models.STAGE_UNASSIGNED, journal=request.journal - ).count(), "assigned_articles_count": submission_models.Article.objects.filter( Q(stage=submission_models.STAGE_ASSIGNED) | Q(stage=submission_models.STAGE_UNDER_REVIEW) @@ -855,6 +853,7 @@ def dashboard(request): "is_editor": request.user.is_editor(request), "is_author": request.user.is_author(request), "is_reviewer": request.user.is_reviewer(request), + "journal_has_screening": request.journal.element_in_workflow("screening"), "section_editor_articles": section_editor_articles, "active_submission_count": submission_models.Article.objects.filter( owner=request.user, journal=request.journal @@ -886,6 +885,24 @@ def dashboard(request): & Q(date_declined__isnull=True), article__journal=request.journal, ).count(), + "assigned_screenings_for_user_count": screening_models.ScreeningAssignment.objects.filter( + screener=request.user, + article__journal=request.journal, + date_accepted__isnull=True, + date_declined__isnull=True, + is_complete=False, + ).count(), + "assigned_screenings_for_user_accepted_count": screening_models.ScreeningAssignment.objects.filter( + screener=request.user, + article__journal=request.journal, + date_accepted__isnull=False, + is_complete=False, + ).count(), + "assigned_screenings_for_user_completed_count": screening_models.ScreeningAssignment.objects.filter( + screener=request.user, + article__journal=request.journal, + is_complete=True, + ).count(), "copyeditor_requests": copyedit_models.CopyeditAssignment.objects.filter( Q(copyeditor=request.user) & Q(decision__isnull=True) @@ -2283,6 +2300,11 @@ def kanban(request): journal=request.journal, ).order_by("-date_submitted") + screening_articles = submission_models.Article.objects.filter( + stage=submission_models.STAGE_SCREENING, + journal=request.journal, + ).order_by("-date_submitted") + articles_in_workflow_plugins = workflow.articles_in_workflow_plugins(request) context = { @@ -2295,6 +2317,7 @@ def kanban(request): "proofing_assigned": proof_assigned_articles, "typesetting": typesetting_articles, "prepubs": prepub, + "screening_articles": screening_articles, "articles_in_workflow_plugins": articles_in_workflow_plugins, "workflow": request.journal.workflow(), } diff --git a/src/templates/admin/core/dashboard.html b/src/templates/admin/core/dashboard.html index 0460d5524a..6b78467a63 100644 --- a/src/templates/admin/core/dashboard.html +++ b/src/templates/admin/core/dashboard.html @@ -41,6 +41,30 @@

    Reviewer

    {% endif %} + {% if journal_has_screening %}{% if assigned_screenings_for_user_count or assigned_screenings_for_user_accepted_count or assigned_screenings_for_user_completed_count %} +
    +
    +
    +

    Screener

    + View Requests +
    +
    +
    + {{ assigned_screenings_for_user_count }} + Screening Requests +
    +
    + {{ assigned_screenings_for_user_accepted_count }} + Accepted Screenings +
    +
    + {{ assigned_screenings_for_user_completed_count }} + Completed Screenings +
    +
    +
    +
    + {% endif %}{% endif %} {% user_has_role request 'copyeditor' as copyeditor %} {% if copyeditor and 'copyediting' in workflow_elements %}
    diff --git a/src/templates/admin/core/kanban.html b/src/templates/admin/core/kanban.html index e07c8d82b6..9bab9ff3ca 100644 --- a/src/templates/admin/core/kanban.html +++ b/src/templates/admin/core/kanban.html @@ -17,13 +17,20 @@ {% for element in workflow.elements.all %}
    -

    {{ element.element_name }}

    +

    {{ element.display_name }}

    - {% if element.element_name == 'review' %} + {% if element.element_name == 'editor_assignment' %} {% for article in unassigned_articles %} {% include "admin/elements/core/kanban/card.html" with article=article type='unassigned' %} {% endfor %} + + {% elif element.element_name == 'screening' %} + {% for article in screening_articles %} + {% include "admin/elements/core/kanban/card.html" with article=article type='screening' %} + {% endfor %} + + {% elif element.element_name == 'review' %} {% for article in in_review %} {% include "admin/elements/core/kanban/card.html" with article=article type='assigned' %} {% endfor %} diff --git a/src/templates/admin/core/nav.html b/src/templates/admin/core/nav.html index b0f4018198..11d13d7fe7 100644 --- a/src/templates/admin/core/nav.html +++ b/src/templates/admin/core/nav.html @@ -64,16 +64,10 @@
  • Workflow
  • -
  • - -   Unassigned - -
  • - {% for element in request.journal.workflow.elements.all %}
  • -   {{ element|capfirst }} +   {{ element.display_name }}
  • {% endfor %} @@ -85,7 +79,7 @@ {% if element.element_name == 'review' or element.element_name == 'copyediting' %}
  • -   {{ element|capfirst }} +   {{ element.display_name }}
  • {% endif %} diff --git a/src/templates/admin/elements/core/editor_dashboard.html b/src/templates/admin/elements/core/editor_dashboard.html index b9e9274e3f..23f85ac2f8 100644 --- a/src/templates/admin/elements/core/editor_dashboard.html +++ b/src/templates/admin/elements/core/editor_dashboard.html @@ -8,18 +8,13 @@

    Editor

    -
    - {{ unassigned_articles_count }} - Unassigned - -
    {% for element in request.journal.workflow.elements.all %}
    {{ element.articles.count }} - {{ element|capfirst }} + {{ element.display_name }}
    - {% if forloop.counter == 1 or forloop.counter|divisibleby:3 and not forloop.last %} + {% if forloop.counter|divisibleby:3 and not forloop.last %}
    diff --git a/src/templates/admin/elements/core/kanban/card.html b/src/templates/admin/elements/core/kanban/card.html index a6847f8662..62a7c937ab 100644 --- a/src/templates/admin/elements/core/kanban/card.html +++ b/src/templates/admin/elements/core/kanban/card.html @@ -5,6 +5,8 @@
    {{ article.pk }} - {{ article.safe_title }}
    {% if type == "unassigned" %}

    New article, awaiting Editor assignment.

    + {% elif type == "screening" %} +

    Article in screening.

    {% elif type == "assigned" %}

    Review in progress.

    {% elif type == "copyedit" %} @@ -24,6 +26,8 @@
    {{ article.pk }} - {{ article.safe_title }}
    {% if type == "unassigned" %} Assign Editor + {% elif type == "screening" %} + View Screening Detail {% elif type == 'assigned' %} View Review Detail {% elif type == "copyedit" %} From 88d735558d8dc2e9f62637cd73e88cba86fbc178 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Fri, 15 May 2026 16:49:03 +0100 Subject: [PATCH 05/13] fix(review): drop stray QuerySet output above the Keywords list --- src/templates/admin/elements/review/review_meta_block.html | 1 - 1 file changed, 1 deletion(-) diff --git a/src/templates/admin/elements/review/review_meta_block.html b/src/templates/admin/elements/review/review_meta_block.html index 523ef7cfed..65374fe16b 100644 --- a/src/templates/admin/elements/review/review_meta_block.html +++ b/src/templates/admin/elements/review/review_meta_block.html @@ -18,7 +18,6 @@ {{ review_request.article.abstract|safe }}

    Keywords

    -{{ article.keywords.all }}

    {% for keyword in review_request.article.keywords.all %} {{ keyword.word }}{% if not forloop.last %}, {% endif %} From ea973a68ea72bec7d3e3db9670a150803e4cbe3b Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Mon, 18 May 2026 10:21:22 +0100 Subject: [PATCH 06/13] feat(screening): screening revisions workflow with email previews, withdrawal notifications, and POST-only state changes --- src/events/logic.py | 8 + src/events/registration.py | 10 + src/screening/forms.py | 12 +- src/screening/logic.py | 19 +- ...screeningrevisionrequest_date_cancelled.py | 17 + src/screening/models.py | 9 + src/screening/notifications.py | 128 ++++++- src/screening/tests/test_editor_management.py | 37 ++ src/screening/tests/test_flows.py | 2 +- src/screening/tests/test_managing_editor.py | 15 +- src/screening/tests/test_revisions.py | 242 +++++++++++- src/screening/tests/test_screener_flows.py | 6 +- src/screening/urls.py | 25 ++ src/screening/views.py | 353 +++++++++++++++--- src/submission/models.py | 6 + .../admin/elements/core/author_dashboard.html | 5 + src/templates/admin/screening/article.html | 77 +++- .../admin/screening/do_revisions.html | 141 +++++-- .../admin/screening/edit_revisions.html | 37 ++ .../admin/screening/replace_file.html | 43 +++ .../admin/screening/request_revisions.html | 6 +- src/templates/admin/screening/requests.html | 10 +- .../screening/revision_notification.html | 46 +++ .../admin/screening/upload_new_file.html | 45 +++ .../admin/screening/view_revision.html | 62 +++ src/utils/install/journal_defaults.json | 76 ++++ 26 files changed, 1312 insertions(+), 125 deletions(-) create mode 100644 src/screening/migrations/0002_screeningrevisionrequest_date_cancelled.py create mode 100644 src/templates/admin/screening/edit_revisions.html create mode 100644 src/templates/admin/screening/replace_file.html create mode 100644 src/templates/admin/screening/revision_notification.html create mode 100644 src/templates/admin/screening/upload_new_file.html diff --git a/src/events/logic.py b/src/events/logic.py index e2d6b8c200..8b63893fcb 100755 --- a/src/events/logic.py +++ b/src/events/logic.py @@ -319,6 +319,14 @@ class Events: # raised when the author submits their revisions; handler emails the # editor so they can reopen a screening round ON_SCREENING_REVISIONS_COMPLETED = "on_screening_revisions_completed" + # kwargs: request, screening_assignment + # raised when an editor withdraws an open screening assignment; + # handler emails the screener so they know the request is closed + ON_SCREENING_WITHDRAWN = "on_screening_withdrawn" + # kwargs: request, screening_revision + # raised when an editor withdraws (cancels) an open revision + # request; handler emails the corresponding author + ON_SCREENING_REVISION_WITHDRAWN = "on_screening_revision_withdrawn" ON_TYPESETTING_ASSIGN_CANCELLED = "on_typesetting_assign_cancelled" ON_TYPESETTING_ASSIGN_DELETED = "on_typesetting_assign_deleted" ON_TYPESETTING_ASSIGN_COMPLETE = "on_typesetting_assign_complete" diff --git a/src/events/registration.py b/src/events/registration.py index 3829d0a0f3..6e0d592bcf 100755 --- a/src/events/registration.py +++ b/src/events/registration.py @@ -336,6 +336,16 @@ screening_notifications.send_screening_revisions_completed, ) +event_logic.Events.register_for_event( + event_logic.Events.ON_SCREENING_WITHDRAWN, + screening_notifications.send_screening_withdrawn, +) + +event_logic.Events.register_for_event( + event_logic.Events.ON_SCREENING_REVISION_WITHDRAWN, + screening_notifications.send_screening_revision_withdrawn, +) + event_logic.Events.register_for_event( event_logic.Events.ON_TYPESETTING_ASSIGN_CANCELLED, emails.send_typesetting_assign_cancelled, diff --git a/src/screening/forms.py b/src/screening/forms.py index 93e2d404a7..9c649b5e67 100644 --- a/src/screening/forms.py +++ b/src/screening/forms.py @@ -193,15 +193,9 @@ def save(self, commit=True): class AuthorRevisionResponseForm(forms.ModelForm): - """Author-side form to submit a revised manuscript file plus an - optional covering letter. The uploaded file is saved as a new - manuscript file on the article via core.files.save_file_to_article.""" - - manuscript = forms.FileField( - required=True, - label="Revised manuscript", - help_text="Upload the revised manuscript as a single file.", - ) + """Author-side form for the covering letter. Files are replaced or + added through dedicated per-file views (mirroring Review's revision + flow), so this form only captures the author_note.""" class Meta: model = models.ScreeningRevisionRequest diff --git a/src/screening/logic.py b/src/screening/logic.py index 49c60baf59..ffb59a0bf8 100644 --- a/src/screening/logic.py +++ b/src/screening/logic.py @@ -5,7 +5,8 @@ from django.db.models import Count, Max, Q -from django.shortcuts import redirect, render +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from core import models as core_models @@ -180,6 +181,22 @@ def setup_after_screening(article, next_element): ) +def get_open_revision_for_author(request, revision_id): + """Return the open ScreeningRevisionRequest belonging to the + correspondence author on the current journal, raising Http404 if + the user is not the corresponding author or the revision is not + open.""" + revision = get_object_or_404( + screening_models.ScreeningRevisionRequest, + pk=revision_id, + article__journal=request.journal, + date_completed__isnull=True, + ) + if request.user != revision.article.correspondence_author: + raise Http404 + return revision + + def render_checklist_item_response(request, item): """Return the appropriate response for a checklist-item mutation: a single-row HTML partial when the request is from HTMX (so the diff --git a/src/screening/migrations/0002_screeningrevisionrequest_date_cancelled.py b/src/screening/migrations/0002_screeningrevisionrequest_date_cancelled.py new file mode 100644 index 0000000000..f000c45dc0 --- /dev/null +++ b/src/screening/migrations/0002_screeningrevisionrequest_date_cancelled.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.29 on 2026-05-15 16:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("screening", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="screeningrevisionrequest", + name="date_cancelled", + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/src/screening/models.py b/src/screening/models.py index 112cbae2c0..c482037d46 100644 --- a/src/screening/models.py +++ b/src/screening/models.py @@ -452,6 +452,7 @@ class ScreeningRevisionRequest(models.Model): date_requested = models.DateTimeField(default=timezone.now) date_due = models.DateField() date_completed = models.DateTimeField(blank=True, null=True) + date_cancelled = models.DateTimeField(blank=True, null=True) def __str__(self): editor_name = self.editor.full_name() if self.editor else "Unknown editor" @@ -464,6 +465,14 @@ def __str__(self): def is_complete(self): return self.date_completed is not None + @property + def is_cancelled(self): + return self.date_cancelled is not None + + @property + def is_open(self): + return not self.is_complete and not self.is_cancelled + class ScreeningPool(models.Model): """Per-journal selection of editorial groups whose members are diff --git a/src/screening/notifications.py b/src/screening/notifications.py index 0fa7508da9..6c06851076 100644 --- a/src/screening/notifications.py +++ b/src/screening/notifications.py @@ -190,30 +190,46 @@ def send_screening_revisions_requested(**kwargs): Notifies the corresponding author that an editor has asked them to revise their submission. The email contains a link to the revision page where the author uploads revised files and a covering letter. + When ``email_data`` is supplied (editor previewed and edited the + email), it is used verbatim. When ``skip`` is truthy, the assignment + is created but no email is sent. """ request = kwargs["request"] revision = kwargs["screening_revision"] + skip = kwargs.get("skip", False) + custom_email_data = kwargs.get("email_data") article = revision.article if not article.correspondence_author: return - do_revisions_url = request.journal.site_url( - reverse("do_screening_revisions", kwargs={"revision_id": revision.pk}), - ) - context = { - "article": article, - "screening_revision": revision, - "do_revisions_url": do_revisions_url, - } - email_data = _build_email_data( - request, - "subject_screening_revisions_requested", - "screening_revisions_requested", - context, - ) - description = 'Screening revisions requested for "{0}"'.format(article.title) + + if skip: + notify_helpers.send_slack(request, description, ["slack_editors"]) + return + + if custom_email_data is not None: + email_data = core_email.EmailData( + subject=custom_email_data.subject, + body=custom_email_data.body, + ) + else: + do_revisions_url = request.journal.site_url( + reverse("do_screening_revisions", kwargs={"revision_id": revision.pk}), + ) + context = { + "article": article, + "screening_revision": revision, + "do_revisions_url": do_revisions_url, + } + email_data = _build_email_data( + request, + "subject_screening_revisions_requested", + "screening_revisions_requested", + context, + ) + log_dict = { "level": "Info", "action_text": description, @@ -273,3 +289,85 @@ def send_screening_revisions_completed(**kwargs): log_dict=log_dict, ) notify_helpers.send_slack(request, description, ["slack_editors"]) + + +def send_screening_withdrawn(**kwargs): + """Handler for ON_SCREENING_WITHDRAWN. + + Notifies the screener that the editor has withdrawn their screening + assignment. Fires even for assignments the screener had not yet + accepted — the editor's intent to cancel is always communicated. + """ + request = kwargs["request"] + assignment = kwargs["screening_assignment"] + article = assignment.article + + if not assignment.screener: + return + + context = {"article": article, "screening_assignment": assignment} + email_data = _build_email_data( + request, + "subject_screening_withdrawn", + "screening_withdrawn", + context, + ) + + description = 'Screening assignment withdrawn for "{0}" ({1})'.format( + article.title, + assignment.screener.full_name(), + ) + log_dict = { + "level": "Info", + "action_text": description, + "types": "Screening Withdrawn", + "target": article, + } + core_email.send_email( + assignment.screener, + email_data, + request, + article=article, + log_dict=log_dict, + ) + notify_helpers.send_slack(request, description, ["slack_editors"]) + + +def send_screening_revision_withdrawn(**kwargs): + """Handler for ON_SCREENING_REVISION_WITHDRAWN. + + Notifies the corresponding author that the editor has cancelled the + open revision request. + """ + request = kwargs["request"] + revision = kwargs["screening_revision"] + article = revision.article + + if not article.correspondence_author: + return + + context = {"article": article, "screening_revision": revision} + email_data = _build_email_data( + request, + "subject_screening_revision_withdrawn", + "screening_revision_withdrawn", + context, + ) + + description = 'Screening revision request withdrawn for "{0}"'.format( + article.title, + ) + log_dict = { + "level": "Info", + "action_text": description, + "types": "Screening Revision Withdrawn", + "target": article, + } + core_email.send_email( + article.correspondence_author, + email_data, + request, + article=article, + log_dict=log_dict, + ) + notify_helpers.send_slack(request, description, ["slack_editors"]) diff --git a/src/screening/tests/test_editor_management.py b/src/screening/tests/test_editor_management.py index 0412da52e7..cf52aacbb2 100644 --- a/src/screening/tests/test_editor_management.py +++ b/src/screening/tests/test_editor_management.py @@ -125,6 +125,43 @@ def test_withdraw_sets_assignment_withdrawn(self): self.assertEqual(self.assignment.recommendation, SR.WITHDRAWN.value) self.assertIsNotNone(self.assignment.date_declined) + def test_withdraw_emails_screener(self): + from django.core import mail as django_mail + + django_mail.outbox = [] + self.client.force_login(self.editor) + self.client.post( + reverse( + "withdraw_screening_assignment", + kwargs={ + "article_id": self.article.pk, + "assignment_id": self.assignment.pk, + }, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(len(django_mail.outbox), 1) + self.assertIn(self.screener.email, django_mail.outbox[0].to) + + def test_repeat_withdraw_does_not_double_email(self): + from django.core import mail as django_mail + + self.assignment.recommendation = SR.WITHDRAWN.value + self.assignment.save() + django_mail.outbox = [] + self.client.force_login(self.editor) + self.client.post( + reverse( + "withdraw_screening_assignment", + kwargs={ + "article_id": self.article.pk, + "assignment_id": self.assignment.pk, + }, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(len(django_mail.outbox), 0) + def test_withdraw_rejects_get(self): self.client.force_login(self.editor) response = self.client.get( diff --git a/src/screening/tests/test_flows.py b/src/screening/tests/test_flows.py index ddc50c1a95..4935c2f5c3 100644 --- a/src/screening/tests/test_flows.py +++ b/src/screening/tests/test_flows.py @@ -441,7 +441,7 @@ def test_screening_move_to_next_stage_creates_workflow_log(self): self.article.save() screening_logic.open_screening_round(self.article) self.client.force_login(self.editor) - self.client.get( + self.client.post( reverse( "screening_move_to_next_stage", kwargs={"article_id": self.article.pk}, diff --git a/src/screening/tests/test_managing_editor.py b/src/screening/tests/test_managing_editor.py index b5ec30aeae..77dfb7e80e 100644 --- a/src/screening/tests/test_managing_editor.py +++ b/src/screening/tests/test_managing_editor.py @@ -77,7 +77,7 @@ def test_get_next_workflow_element_returns_none_when_no_successor(self): def test_screening_move_to_next_stage_routes_to_review(self): self.client.force_login(self.editor) - self.client.get( + self.client.post( reverse( "screening_move_to_next_stage", kwargs={"article_id": self.article.pk}, @@ -94,7 +94,7 @@ def test_screening_move_requires_screening_stage(self): self.article.stage = submission_models.STAGE_UNASSIGNED self.article.save() self.client.force_login(self.editor) - response = self.client.get( + response = self.client.post( reverse( "screening_move_to_next_stage", kwargs={"article_id": self.article.pk}, @@ -103,6 +103,17 @@ def test_screening_move_requires_screening_stage(self): ) self.assertEqual(response.status_code, 404) + def test_screening_move_rejects_get(self): + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "screening_move_to_next_stage", + kwargs={"article_id": self.article.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 405) + def test_screening_article_shows_move_and_reject_buttons(self): self.client.force_login(self.editor) response = self.client.get( diff --git a/src/screening/tests/test_revisions.py b/src/screening/tests/test_revisions.py index f6d91bc98e..525107bbed 100644 --- a/src/screening/tests/test_revisions.py +++ b/src/screening/tests/test_revisions.py @@ -9,6 +9,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.urls import reverse +from django.utils import timezone from core import logic as core_logic from screening import models as screening_models @@ -67,6 +68,45 @@ def test_editor_can_open_request_revisions_page(self): ) self.assertEqual(response.status_code, 200) self.assertContains(response, "Request Revisions") + self.assertNotContains(response, "open revision request already exists") + + def test_open_revision_blocks_opening_another(self): + existing = screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + ) + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "request_screening_revisions", + kwargs={"article_id": self.article.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 302) + self.assertIn( + reverse("view_screening_revision", kwargs={"revision_id": existing.pk}), + response.url, + ) + + def test_completed_revision_does_not_block_new_request(self): + screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + date_completed=datetime.datetime(2026, 1, 1, 9, 0, 0), + ) + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "request_screening_revisions", + kwargs={"article_id": self.article.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Request Revisions") def test_editor_post_creates_revision_request(self): self.client.force_login(self.editor) @@ -90,6 +130,27 @@ def test_editor_post_creates_revision_request(self): self.assertEqual(revision.type, "minor_revisions") self.assertEqual(revision.date_due, due) + def test_request_redirects_to_notification_preview(self): + self.client.force_login(self.editor) + response = self.client.post( + reverse( + "request_screening_revisions", + kwargs={"article_id": self.article.pk}, + ), + { + "type": "minor_revisions", + "editor_note": "Please revise.", + "date_due": ( + datetime.date.today() + datetime.timedelta(days=14) + ).isoformat(), + }, + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("/notify/", response.url) + # No email sent yet — the editor must confirm on the preview. + self.assertEqual(len(mail.outbox), 0) + def test_creating_revision_request_emails_author(self): self.client.force_login(self.editor) self.client.post( @@ -106,9 +167,46 @@ def test_creating_revision_request_emails_author(self): }, SERVER_NAME=self.journal_one.domain, ) + revision = screening_models.ScreeningRevisionRequest.objects.get( + article=self.article, + ) + self.client.post( + reverse( + "screening_revision_notification", + kwargs={ + "article_id": self.article.pk, + "revision_id": revision.pk, + }, + ), + { + "subject": "Revisions requested", + "body": "Please revise as discussed.", + }, + SERVER_NAME=self.journal_one.domain, + ) self.assertEqual(len(mail.outbox), 1) self.assertIn(self.author.email, mail.outbox[0].to) + def test_notification_skip_suppresses_email(self): + revision = screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + ) + self.client.force_login(self.editor) + self.client.post( + reverse( + "screening_revision_notification", + kwargs={ + "article_id": self.article.pk, + "revision_id": revision.pk, + }, + ), + {"skip": "1"}, + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(len(mail.outbox), 0) + def test_other_user_cannot_open_do_revisions(self): revision = screening_models.ScreeningRevisionRequest.objects.create( article=self.article, @@ -139,11 +237,6 @@ def test_author_submits_revisions_opens_new_round(self): date_due=datetime.date.today() + datetime.timedelta(days=14), ) self.client.force_login(self.author) - uploaded = SimpleUploadedFile( - "revised.txt", - b"revised manuscript contents", - content_type="text/plain", - ) self.client.post( reverse( "do_screening_revisions", @@ -151,7 +244,7 @@ def test_author_submits_revisions_opens_new_round(self): ), { "author_note": "Section 3 has been clarified.", - "manuscript": uploaded, + "submit": "1", }, SERVER_NAME=self.journal_one.domain, ) @@ -162,7 +255,6 @@ def test_author_submits_revisions_opens_new_round(self): article=self.article, ).exists() ) - self.assertTrue(self.article.manuscript_files.exists()) def test_author_completion_emails_editor(self): revision = screening_models.ScreeningRevisionRequest.objects.create( @@ -171,22 +263,36 @@ def test_author_completion_emails_editor(self): date_due=datetime.date.today() + datetime.timedelta(days=14), ) self.client.force_login(self.author) - uploaded = SimpleUploadedFile( - "revised2.txt", - b"contents", - content_type="text/plain", - ) self.client.post( reverse( "do_screening_revisions", kwargs={"revision_id": revision.pk}, ), - {"author_note": "Done.", "manuscript": uploaded}, + {"author_note": "Done.", "submit": "1"}, SERVER_NAME=self.journal_one.domain, ) self.assertEqual(len(mail.outbox), 1) self.assertIn(self.editor.email, mail.outbox[0].to) + def test_author_can_save_covering_letter_without_submitting(self): + revision = screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + ) + self.client.force_login(self.author) + self.client.post( + reverse( + "do_screening_revisions", + kwargs={"revision_id": revision.pk}, + ), + {"author_note": "Saving for later.", "save": "1"}, + SERVER_NAME=self.journal_one.domain, + ) + revision.refresh_from_db() + self.assertIsNone(revision.date_completed) + self.assertIn("Saving for later", revision.author_note or "") + def test_completed_revision_redirects_author_away(self): revision = screening_models.ScreeningRevisionRequest.objects.create( article=self.article, @@ -225,3 +331,113 @@ def test_editor_can_view_revision(self): ) self.assertEqual(response.status_code, 200) self.assertContains(response, "Please tidy section 3") + + def test_editor_can_edit_open_revision(self): + revision = screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + editor_note="Original note.", + ) + self.client.force_login(self.editor) + new_due = datetime.date.today() + datetime.timedelta(days=21) + self.client.post( + reverse( + "edit_screening_revisions", + kwargs={"article_id": self.article.pk, "revision_id": revision.pk}, + ), + { + "type": "major_revisions", + "editor_note": "Updated note.", + "date_due": new_due.isoformat(), + }, + SERVER_NAME=self.journal_one.domain, + ) + revision.refresh_from_db() + self.assertEqual(revision.type, "major_revisions") + self.assertEqual(revision.date_due, new_due) + self.assertIn("Updated note", revision.editor_note) + + def test_editor_can_withdraw_open_revision(self): + revision = screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + ) + self.client.force_login(self.editor) + self.client.post( + reverse( + "withdraw_screening_revisions", + kwargs={"article_id": self.article.pk, "revision_id": revision.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + revision.refresh_from_db() + self.assertIsNotNone(revision.date_cancelled) + + def test_withdraw_revision_emails_author(self): + revision = screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + ) + self.client.force_login(self.editor) + self.client.post( + reverse( + "withdraw_screening_revisions", + kwargs={"article_id": self.article.pk, "revision_id": revision.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(len(mail.outbox), 1) + self.assertIn(self.author.email, mail.outbox[0].to) + + def test_withdraw_rejects_get(self): + revision = screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + ) + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "withdraw_screening_revisions", + kwargs={"article_id": self.article.pk, "revision_id": revision.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 405) + + def test_withdrawn_revision_does_not_block_new_request(self): + screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + date_cancelled=timezone.now(), + ) + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "request_screening_revisions", + kwargs={"article_id": self.article.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + + def test_cancelled_revision_redirects_author_away(self): + revision = screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + date_cancelled=timezone.now(), + ) + self.client.force_login(self.author) + response = self.client.get( + reverse( + "do_screening_revisions", + kwargs={"revision_id": revision.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 302) diff --git a/src/screening/tests/test_screener_flows.py b/src/screening/tests/test_screener_flows.py index 3f25eda882..7aff379476 100644 --- a/src/screening/tests/test_screener_flows.py +++ b/src/screening/tests/test_screener_flows.py @@ -87,7 +87,7 @@ def test_pending_list_visible_to_screener(self): def test_accept_records_date_accepted(self): self.client.force_login(self.screener) - self.client.get( + self.client.post( reverse( "accept_screening_request", kwargs={"assignment_id": self.assignment.pk}, @@ -99,7 +99,7 @@ def test_accept_records_date_accepted(self): def test_decline_records_date_declined(self): self.client.force_login(self.screener) - self.client.get( + self.client.post( reverse( "decline_screening_request", kwargs={"assignment_id": self.assignment.pk}, @@ -113,7 +113,7 @@ def test_decline_blocked_after_complete(self): self.assignment.is_complete = True self.assignment.save() self.client.force_login(self.screener) - self.client.get( + self.client.post( reverse( "decline_screening_request", kwargs={"assignment_id": self.assignment.pk}, diff --git a/src/screening/urls.py b/src/screening/urls.py index 9c3e67ce76..5a0f7796fa 100644 --- a/src/screening/urls.py +++ b/src/screening/urls.py @@ -70,11 +70,36 @@ views.request_screening_revisions, name="request_screening_revisions", ), + re_path( + r"^article/(?P\d+)/revisions/(?P\d+)/notify/$", + views.screening_revision_notification, + name="screening_revision_notification", + ), + re_path( + r"^article/(?P\d+)/revisions/(?P\d+)/edit/$", + views.edit_screening_revisions, + name="edit_screening_revisions", + ), + re_path( + r"^article/(?P\d+)/revisions/(?P\d+)/withdraw/$", + views.withdraw_screening_revisions, + name="withdraw_screening_revisions", + ), re_path( r"^revisions/(?P\d+)/do/$", views.do_screening_revisions, name="do_screening_revisions", ), + re_path( + r"^revisions/(?P\d+)/file/(?P\d+)/replace/$", + views.screening_revisions_replace_file, + name="screening_revisions_replace_file", + ), + re_path( + r"^revisions/(?P\d+)/file/new/$", + views.screening_revisions_upload_new_file, + name="screening_revisions_upload_new_file", + ), re_path( r"^revisions/(?P\d+)/$", views.view_screening_revision, diff --git a/src/screening/views.py b/src/screening/views.py index aed515b09f..8c5079a4e3 100644 --- a/src/screening/views.py +++ b/src/screening/views.py @@ -350,10 +350,20 @@ def withdraw_screening_assignment(request, article_id, assignment_id): pk=assignment_id, article=article, ) + already_withdrawn = ( + assignment.recommendation == ScreeningRecommendations.WITHDRAWN.value + ) assignment.recommendation = ScreeningRecommendations.WITHDRAWN.value if not assignment.date_declined: assignment.date_declined = timezone.now() assignment.save() + if not already_withdrawn: + event_logic.Events.raise_event( + event_logic.Events.ON_SCREENING_WITHDRAWN, + task_object=article, + request=request, + screening_assignment=assignment, + ) messages.add_message( request, messages.SUCCESS, @@ -454,6 +464,7 @@ def screening_requests(request): return render(request, template, context) +@require_POST @screener_for_assignment_required def accept_screening_request(request, assignment_id, assignment=None): """Record the screener's acceptance of an invitation.""" @@ -476,6 +487,7 @@ def accept_screening_request(request, assignment_id, assignment=None): return redirect(reverse("do_screening", kwargs={"assignment_id": assignment.pk})) +@require_POST @screener_for_assignment_required def decline_screening_request(request, assignment_id, assignment=None): """Record the screener's decline of an invitation.""" @@ -579,6 +591,7 @@ def screening_thanks(request, assignment_id, assignment=None): @editor_user_required +@require_POST def move_to_next_stage(request, article_id): """Move an article out of screening into whichever workflow element follows it for this journal. Mirrors the editor_assignment exit @@ -653,6 +666,29 @@ def request_screening_revisions(request, article_id): journal=request.journal, stage=submission_models.STAGE_SCREENING, ) + open_revision = ( + screening_models.ScreeningRevisionRequest.objects.filter( + article=article, + date_completed__isnull=True, + date_cancelled__isnull=True, + ) + .order_by("-date_requested") + .first() + ) + if open_revision: + messages.add_message( + request, + messages.WARNING, + "A revision request is already open on this article. " + "Only one revision task may be open at a time.", + ) + return redirect( + reverse( + "view_screening_revision", + kwargs={"revision_id": open_revision.pk}, + ) + ) + form = forms.ScreeningRevisionRequestForm( request.POST or None, article=article, @@ -660,23 +696,176 @@ def request_screening_revisions(request, article_id): ) if request.method == "POST" and form.is_valid(): revision = form.save() - event_logic.Events.raise_event( - event_logic.Events.ON_SCREENING_REVISIONS_REQUESTED, - task_object=article, + return redirect( + reverse( + "screening_revision_notification", + kwargs={ + "article_id": article.pk, + "revision_id": revision.pk, + }, + ) + ) + + template = "admin/screening/request_revisions.html" + context = {"article": article, "form": form} + return render(request, template, context) + + +@editor_user_required +def screening_revision_notification(request, article_id, revision_id): + """Preview and edit the revision-request email before sending it to + the corresponding author. POST sends the email (or skips it) and + raises ON_SCREENING_REVISIONS_REQUESTED with the edited email body.""" + if not journal_has_screening_element(request.journal): + raise Http404("Screening is not enabled for this journal.") + + article = get_object_or_404( + submission_models.Article, + pk=article_id, + journal=request.journal, + stage=submission_models.STAGE_SCREENING, + ) + revision = get_object_or_404( + screening_models.ScreeningRevisionRequest, + pk=revision_id, + article=article, + ) + + do_revisions_url = request.journal.site_url( + reverse("do_screening_revisions", kwargs={"revision_id": revision.pk}), + ) + email_context = { + "article": article, + "screening_revision": revision, + "do_revisions_url": do_revisions_url, + } + form = core_forms.SettingEmailForm( + setting_name="screening_revisions_requested", + email_context=email_context, + request=request, + ) + + if request.method == "POST": + form = core_forms.SettingEmailForm( + request.POST, + request.FILES, + setting_name="screening_revisions_requested", + email_context=email_context, request=request, - screening_revision=revision, ) + skip = request.POST.get("skip") + if skip or form.is_valid(): + event_logic.Events.raise_event( + event_logic.Events.ON_SCREENING_REVISIONS_REQUESTED, + task_object=article, + request=request, + screening_revision=revision, + email_data=form.as_dataclass() if not skip else None, + skip=bool(skip), + ) + messages.add_message( + request, + messages.SUCCESS, + ( + "Revision request created (email skipped)." + if skip + else "Revision request sent to the corresponding author." + ), + ) + return redirect( + reverse("screening_article", kwargs={"article_id": article.pk}), + ) + + template = "admin/screening/revision_notification.html" + context = { + "article": article, + "revision": revision, + "form": form, + } + return render(request, template, context) + + +@editor_user_required +@require_POST +def withdraw_screening_revisions(request, article_id, revision_id): + """Editor cancels an open revision request. Sets date_cancelled and + fires ON_SCREENING_REVISION_WITHDRAWN so the author is notified.""" + if not journal_has_screening_element(request.journal): + raise Http404("Screening is not enabled for this journal.") + + article = get_object_or_404( + submission_models.Article, + pk=article_id, + journal=request.journal, + stage=submission_models.STAGE_SCREENING, + ) + revision = get_object_or_404( + screening_models.ScreeningRevisionRequest, + pk=revision_id, + article=article, + date_completed__isnull=True, + date_cancelled__isnull=True, + ) + revision.date_cancelled = timezone.now() + revision.save() + event_logic.Events.raise_event( + event_logic.Events.ON_SCREENING_REVISION_WITHDRAWN, + task_object=article, + request=request, + screening_revision=revision, + ) + messages.add_message( + request, + messages.SUCCESS, + "Revision request withdrawn.", + ) + return redirect( + reverse("screening_article", kwargs={"article_id": article.pk}), + ) + + +@editor_user_required +def edit_screening_revisions(request, article_id, revision_id): + """Editor amends an open revision request (due date, type, note) + before the author submits.""" + if not journal_has_screening_element(request.journal): + raise Http404("Screening is not enabled for this journal.") + + article = get_object_or_404( + submission_models.Article, + pk=article_id, + journal=request.journal, + stage=submission_models.STAGE_SCREENING, + ) + revision = get_object_or_404( + screening_models.ScreeningRevisionRequest, + pk=revision_id, + article=article, + date_completed__isnull=True, + date_cancelled__isnull=True, + ) + form = forms.ScreeningRevisionRequestForm( + request.POST or None, + instance=revision, + article=article, + editor=request.user, + ) + if request.method == "POST" and form.is_valid(): + form.save() messages.add_message( request, messages.SUCCESS, - "Revision request sent to the corresponding author.", + "Revision request updated.", ) return redirect( - reverse("screening_article", kwargs={"article_id": article.pk}), + reverse( + "view_screening_revision", + kwargs={"revision_id": revision.pk}, + ) ) - template = "admin/screening/request_revisions.html" - context = {"article": article, "form": form} + template = "admin/screening/edit_revisions.html" + context = {"article": article, "revision": revision, "form": form} return render(request, template, context) @@ -693,7 +882,7 @@ def do_screening_revisions(request, revision_id): ) if request.user != revision.article.correspondence_author: raise Http404 - if revision.date_completed: + if revision.date_completed or revision.date_cancelled: return redirect( reverse( "view_screening_revision", @@ -701,39 +890,38 @@ def do_screening_revisions(request, revision_id): ) ) - form = forms.AuthorRevisionResponseForm( - request.POST or None, - request.FILES or None, - instance=revision, - ) - if request.method == "POST" and form.is_valid(): - from core import files as core_files - - manuscript = form.cleaned_data["manuscript"] - new_file = core_files.save_file_to_article( - manuscript, - revision.article, - request.user, - label="Revised Manuscript", - ) - revision.article.manuscript_files.add(new_file) - - revision = form.save(commit=False) - revision.date_completed = timezone.now() - revision.save() - logic.open_screening_round(revision.article) - event_logic.Events.raise_event( - event_logic.Events.ON_SCREENING_REVISIONS_COMPLETED, - task_object=revision.article, - request=request, - screening_revision=revision, - ) - messages.add_message( - request, - messages.SUCCESS, - "Revisions submitted. The editorial team will be in touch.", - ) - return redirect(reverse("core_dashboard")) + form = forms.AuthorRevisionResponseForm(request.POST or None, instance=revision) + if request.method == "POST": + if "save" in request.POST and form.is_valid(): + form.save() + messages.add_message( + request, + messages.SUCCESS, + "Covering letter saved. Come back any time to finish.", + ) + return redirect( + reverse( + "do_screening_revisions", + kwargs={"revision_id": revision.pk}, + ) + ) + if "submit" in request.POST and form.is_valid(): + form.save() + revision.date_completed = timezone.now() + revision.save() + logic.open_screening_round(revision.article) + event_logic.Events.raise_event( + event_logic.Events.ON_SCREENING_REVISIONS_COMPLETED, + task_object=revision.article, + request=request, + screening_revision=revision, + ) + messages.add_message( + request, + messages.SUCCESS, + "Revisions submitted. The editorial team will be in touch.", + ) + return redirect(reverse("core_dashboard")) template = "admin/screening/do_revisions.html" context = { @@ -744,6 +932,87 @@ def do_screening_revisions(request, revision_id): return render(request, template, context) +@login_required +def screening_revisions_replace_file(request, revision_id, file_id): + """Replace one of the article's files with a new upload, as part of + the author's revision response. Mirrors review's `replace_file`.""" + from core import files as core_files + from core import models as core_models + + revision = logic.get_open_revision_for_author(request, revision_id) + file = get_object_or_404(core_models.File, pk=file_id) + + if request.method == "POST" and request.FILES: + uploaded_file = request.FILES.get("replacement-file") + if uploaded_file: + label = request.POST.get("label") or file.label + new_file = core_files.save_file_to_article( + uploaded_file, + revision.article, + request.user, + replace=file, + is_galley=False, + label=label, + ) + core_files.replace_file( + revision.article, + file, + new_file, + retain_old_label=False, + ) + messages.add_message( + request, + messages.SUCCESS, + "File replaced.", + ) + return redirect( + reverse("do_screening_revisions", kwargs={"revision_id": revision.pk}), + ) + + template = "admin/screening/replace_file.html" + context = {"revision": revision, "article": revision.article, "file": file} + return render(request, template, context) + + +@login_required +def screening_revisions_upload_new_file(request, revision_id): + """Upload a new file (manuscript or data/figure) to the article as + part of the author's revision response. Mirrors review's + `upload_new_file`.""" + from core import files as core_files + + revision = logic.get_open_revision_for_author(request, revision_id) + article = revision.article + + if request.method == "POST" and request.FILES: + file_type = request.POST.get("file_type") + uploaded_file = request.FILES.get("file") + label = request.POST.get("label") or "Author Upload" + if uploaded_file: + new_file = core_files.save_file_to_article( + uploaded_file, + article, + request.user, + label=label, + ) + if file_type == "manuscript": + article.manuscript_files.add(new_file) + else: + article.data_figure_files.add(new_file) + messages.add_message( + request, + messages.SUCCESS, + "File uploaded.", + ) + return redirect( + reverse("do_screening_revisions", kwargs={"revision_id": revision.pk}), + ) + + template = "admin/screening/upload_new_file.html" + context = {"revision": revision, "article": article} + return render(request, template, context) + + @editor_user_required def view_screening_revision(request, revision_id): """Editor read-only view of a completed (or pending) revision.""" diff --git a/src/submission/models.py b/src/submission/models.py index d9fb7c4530..34d595c1d0 100755 --- a/src/submission/models.py +++ b/src/submission/models.py @@ -2248,6 +2248,12 @@ def active_revision_requests(self): def completed_revision_requests(self): return self.revisionrequest_set.filter(date_completed__isnull=False) + def active_screening_revision_requests(self): + return self.screeningrevisionrequest_set.filter( + date_completed__isnull=True, + date_cancelled__isnull=True, + ) + def active_author_copyedits(self): author_copyedits = [] diff --git a/src/templates/admin/elements/core/author_dashboard.html b/src/templates/admin/elements/core/author_dashboard.html index 9ded0c476c..5d03e8e13c 100644 --- a/src/templates/admin/elements/core/author_dashboard.html +++ b/src/templates/admin/elements/core/author_dashboard.html @@ -63,6 +63,11 @@

    Active Articles

    {% endif %} {% endfor %} + {% for screening_revision in article.active_screening_revision_requests %} + Screening + Revision + {% endfor %} + {% for review in article.active_author_copyedits %} Copyediting Review diff --git a/src/templates/admin/screening/article.html b/src/templates/admin/screening/article.html index 947a29353a..8bd834d79f 100644 --- a/src/templates/admin/screening/article.html +++ b/src/templates/admin/screening/article.html @@ -83,7 +83,7 @@

    Screeners

    {% endif %} {% else %}
    - Actions @@ -229,7 +229,7 @@

    Revision Requests

    Type Due Status - + Actions @@ -241,12 +241,74 @@

    Revision Requests

    {% if rev.is_complete %} Submitted + {% elif rev.is_cancelled %} + Withdrawn {% else %} Awaiting Author {% endif %} - View + {% if not is_screening_stage %} + + View + + {% else %} + + + + {% if rev.is_open %} +
    +
    +
    +

     Withdraw revision request

    +
    +
    +

    + Withdrawing this revision request will close it out + and notify {{ rev.article.correspondence_author.full_name }} + that no response is needed. You may open a new + revision request afterwards if circumstances change. +

    +

    Proceed?

    +
    + {% csrf_token %} + + +
    +
    +
    + +
    + {% endif %} + {% endif %} {% endfor %} @@ -286,9 +348,12 @@

    Actions

    {% if next_workflow_element %}
  • - -  Move to {{ next_workflow_element.display_name }} - +
    + {% csrf_token %} + +
  • {% endif %}
  • diff --git a/src/templates/admin/screening/do_revisions.html b/src/templates/admin/screening/do_revisions.html index 59e764162c..edf2a2d960 100644 --- a/src/templates/admin/screening/do_revisions.html +++ b/src/templates/admin/screening/do_revisions.html @@ -1,49 +1,134 @@ -{% extends "core/base.html" %} +{% extends "admin/core/base.html" %} {% load foundation %} +{% load files %} -{% block title %}Submit Revisions{% endblock title %} +{% block title %}Screening Revisions{% endblock title %} {% block title-section %}Revisions for "{{ article.safe_title }}"{% endblock %} +{% block breadcrumbs %} + {{ block.super }} +
  • Screening Revisions
  • +{% endblock breadcrumbs %} + {% block body %} -
    -
    -
    +
    +
    +
    +

    Editor's note

    +
    +
    +

    + + Requested by {{ revision.editor.full_name }} + on {{ revision.date_requested|date:"Y-m-d" }}. + Due by {{ revision.date_due }}. + Revision type: {{ revision.get_type_display }}. + +

    + {{ revision.editor_note|safe }} +
    + +
    +

    Files

    +
    +
    +

    + Your article files are listed below. Use Replace + next to each row to upload a new version of an existing file, or + Upload New File to add a file that wasn't part of + the original submission. +

    +

    + +  Upload New File + +

    + + + + + + + + + + + + + + {% for file in article.manuscript_files.all %} + + + + + + + + + + {% endfor %} + {% for file in article.data_figure_files.all %} + + + + + + + + + + {% empty %} + {% endfor %} + +
    LabelFilenameTypeUploadedSizeDownloadReplace
    {{ file.label }}{{ file.original_filename }}Manuscript{{ file.date_uploaded|date:"Y-m-d G:i" }}{% file_size file article %} + + + + + + Replace + +
    {{ file.label }}{{ file.original_filename }}Data/Figure{{ file.date_uploaded|date:"Y-m-d G:i" }}{% file_size file article %} + + + + + + Replace + +
    +
    + +
    + {% csrf_token %}
    -

    Editor's note

    +

    Covering letter

    - - Requested by {{ revision.editor.full_name }} - on {{ revision.date_requested|date:"Y-m-d" }}. - Due by {{ revision.date_due }}. - Revision type: {{ revision.get_type_display }}. - + Optionally describe the changes you made for the editor.

    - {{ revision.editor_note|safe }} + {{ form|foundation }} +
    -
    -
    -

    Submit your revisions

    +

    Finishing up

    - Upload your revised manuscript and add a covering letter describing - the changes you made. Submitting these revisions reopens a new - screening round so the editorial team can review them. + When you have replaced all the files you need to and written your + covering letter, click Submit Revisions below. + You will not be able to make further changes after submission.

    - - {% csrf_token %} - {{ form|foundation }} - - +
    -
    +
    {% endblock body %} diff --git a/src/templates/admin/screening/edit_revisions.html b/src/templates/admin/screening/edit_revisions.html new file mode 100644 index 0000000000..ff130d18ca --- /dev/null +++ b/src/templates/admin/screening/edit_revisions.html @@ -0,0 +1,37 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} + +{% block title %}Edit Revision Request{% endblock title %} +{% block title-section %}Edit Revision Request{% endblock %} +{% block title-sub %}{% include "admin/elements/article_title_sub_pii.html" %}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/screening_base.html" %} +
  • {{ article.safe_title }}
  • +
  • Revision #{{ revision.pk }}
  • +
  • Edit
  • +{% endblock breadcrumbs %} + +{% block body %} +
    +
    +
    +

    Amend Revision Request

    +
    +
    +

    + Adjust the due date, type, or note. The author is not re-emailed + automatically — if the change is material, contact the author + separately. +

    +
    + {% csrf_token %} + {{ form|foundation }} + + Cancel +
    +
    +
    +
    +{% endblock body %} diff --git a/src/templates/admin/screening/replace_file.html b/src/templates/admin/screening/replace_file.html new file mode 100644 index 0000000000..ccdea7185b --- /dev/null +++ b/src/templates/admin/screening/replace_file.html @@ -0,0 +1,43 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} + +{% block title %}Replace File{% endblock title %} +{% block title-section %}Replace "{{ file.label }}"{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Screening Revisions
  • +
  • Replace File
  • +{% endblock breadcrumbs %} + +{% block body %} +
    +
    +
    +

    Replace "{{ file.label }}"

    +
    +
    +

    + The current file is {{ file.original_filename }}. + Upload a replacement below; the old file is retained in the article + history but the new file becomes the active version. +

    +
    + {% csrf_token %} + + + + Cancel +
    +
    +
    +
    +{% endblock body %} diff --git a/src/templates/admin/screening/request_revisions.html b/src/templates/admin/screening/request_revisions.html index 1ecf4455bd..33bbeb2713 100644 --- a/src/templates/admin/screening/request_revisions.html +++ b/src/templates/admin/screening/request_revisions.html @@ -22,9 +22,9 @@

    Request Revisions from the Author

    Use this action to ask the corresponding author to revise their submission in response to screening. The author will be notified by email and lands on - a dedicated revision page where they can upload revised files and add a - covering letter. Submitting the revisions opens a fresh screening round - automatically. + a dedicated revision page where they can replace each of the files they + uploaded at submission and add a covering letter. Submitting the revisions + opens a fresh screening round automatically.

    {% csrf_token %} diff --git a/src/templates/admin/screening/requests.html b/src/templates/admin/screening/requests.html index d2655bb3e6..3c0b6a5da3 100644 --- a/src/templates/admin/screening/requests.html +++ b/src/templates/admin/screening/requests.html @@ -54,8 +54,14 @@

    Your Screening Requests

    {% if not assignment.date_accepted and not assignment.date_declined %} - Accept - Decline + + {% csrf_token %} + +
    +
    + {% csrf_token %} + +
    {% elif assignment.date_accepted and not assignment.is_complete %} Open Report {% endif %} diff --git a/src/templates/admin/screening/revision_notification.html b/src/templates/admin/screening/revision_notification.html new file mode 100644 index 0000000000..76e087a085 --- /dev/null +++ b/src/templates/admin/screening/revision_notification.html @@ -0,0 +1,46 @@ +{% extends "admin/core/base.html" %} +{% load static %} +{% load settings %} + +{% block css %}{% endblock %} + +{% block title %}Notify Author of Revisions{% endblock title %} +{% block title-section %}Notify Author{% endblock %} +{% block title-sub %}{% include "admin/elements/article_title_sub_pii.html" %}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include "elements/breadcrumbs/screening_base.html" %} +
  • {{ article.safe_title }}
  • +
  • Notify Author
  • +{% endblock breadcrumbs %} + +{% block body %} +
    +
    +
    +

    Notify the Author of the Revision Request

    +
    +
    +

    + Review and edit the email before sending it. Use Skip to leave the + revision request in place without sending an email — useful if you + want to contact the author another way. +

    +
    +
    +

    To {{ article.correspondence_author.full_name }}

    +
    From {{ request.user.full_name }}
    +
    + {% include "admin/elements/email_form.html" with form=form skip=1 %} +
    +
    +
    +
    +{% endblock body %} + +{% block js %} + {{ block.super }} + + {{ form.media.js }} +{% endblock js %} diff --git a/src/templates/admin/screening/upload_new_file.html b/src/templates/admin/screening/upload_new_file.html new file mode 100644 index 0000000000..7cfdc31fe5 --- /dev/null +++ b/src/templates/admin/screening/upload_new_file.html @@ -0,0 +1,45 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} + +{% block title %}Upload New File{% endblock title %} +{% block title-section %}Upload a new file{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} +
  • Screening Revisions
  • +
  • Upload New File
  • +{% endblock breadcrumbs %} + +{% block body %} +
    +
    +
    +

    Add a file to the revision

    +
    +
    +
    + {% csrf_token %} + + + + + Cancel +
    +
    +
    +
    +{% endblock body %} diff --git a/src/templates/admin/screening/view_revision.html b/src/templates/admin/screening/view_revision.html index 22b26e804f..f3828db964 100644 --- a/src/templates/admin/screening/view_revision.html +++ b/src/templates/admin/screening/view_revision.html @@ -1,4 +1,5 @@ {% extends "admin/core/base.html" %} +{% load files %} {% block title %}Screening Revision{% endblock title %} {% block title-section %}Screening Revision{% endblock %} @@ -19,6 +20,17 @@

    + {% if revision.is_cancelled %} +
    +

    + + This revision request was withdrawn on + {{ revision.date_cancelled|date:"Y-m-d H:i" }}. The author was + notified that no response is required. +

    +
    + {% endif %} +

    Request

    @@ -30,6 +42,9 @@

    Request

    {% include "admin/elements/layout/key_value_above.html" with key="Date due" value=revision.date_due %} {% include "admin/elements/layout/key_value_above.html" with key="Type" value=revision.get_type_display %} {% include "admin/elements/layout/key_value_above.html" with key="Date completed" value=revision.date_completed|date:"Y-m-d H:i" %} + {% if revision.is_cancelled %} + {% include "admin/elements/layout/key_value_above.html" with key="Date withdrawn" value=revision.date_cancelled|date:"Y-m-d H:i" %} + {% endif %}
    @@ -48,6 +63,53 @@

    Author's covering letter

    {{ revision.author_note|safe|default:"No covering letter provided." }}
    {% endif %} + +
    +

    Article files

    +
    +
    +

    + + The files currently attached to the article. Files replaced by + the author during revision show their latest version. + +

    + + + + + + + + + + + + + {% for file in article.manuscript_files.all %} + + + + + + + + + {% endfor %} + {% for file in article.data_figure_files.all %} + + + + + + + + + {% empty %} + {% endfor %} + +
    LabelFilenameTypeUploadedSizeDownload
    {{ file.label }}{{ file.original_filename }}Manuscript{{ file.date_uploaded|date:"Y-m-d G:i" }}{% file_size file article %}
    {{ file.label }}{{ file.original_filename }}Data/Figure{{ file.date_uploaded|date:"Y-m-d G:i" }}{% file_size file article %}
    +
    {% endblock body %} diff --git a/src/utils/install/journal_defaults.json b/src/utils/install/journal_defaults.json index 10b13da0d6..144268803c 100644 --- a/src/utils/install/journal_defaults.json +++ b/src/utils/install/journal_defaults.json @@ -5862,5 +5862,81 @@ "editor", "journal-manager" ] + }, + { + "group": { + "name": "email_subject" + }, + "setting": { + "description": "Subject for email sent to the screener when the editor withdraws their screening assignment.", + "is_translatable": true, + "name": "subject_screening_withdrawn", + "pretty_name": "Screening Withdrawn", + "type": "char" + }, + "value": { + "default": "Screening assignment withdrawn" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to the screener when the editor withdraws their screening assignment.", + "is_translatable": true, + "name": "screening_withdrawn", + "pretty_name": "Screening Withdrawn", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ screening_assignment.screener.salutation_name }},

    Your screening assignment for \"{{ article.safe_title }}\" has been withdrawn by the editorial team. You no longer need to take any action on this submission.

    Thank you for your time.

    Regards" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email_subject" + }, + "setting": { + "description": "Subject for email sent to the author when the editor cancels a screening revision request.", + "is_translatable": true, + "name": "subject_screening_revision_withdrawn", + "pretty_name": "Screening Revision Withdrawn", + "type": "char" + }, + "value": { + "default": "Screening revision request cancelled" + }, + "editable_by": [ + "editor", + "journal-manager" + ] + }, + { + "group": { + "name": "email" + }, + "setting": { + "description": "Email sent to the author when the editor cancels a screening revision request.", + "is_translatable": true, + "name": "screening_revision_withdrawn", + "pretty_name": "Screening Revision Withdrawn", + "type": "rich-text" + }, + "value": { + "default": "Dear {{ article.correspondence_author.salutation_name }},

    The revision request for \"{{ article.safe_title }}\" has been cancelled by the editorial team. You no longer need to submit a response.

    The editorial team will be in touch if any further action is needed.

    Regards" + }, + "editable_by": [ + "editor", + "journal-manager" + ] } ] From 0b93f4ef80ecae8603a32da26e980c7b38146a89 Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Mon, 18 May 2026 10:30:34 +0100 Subject: [PATCH 07/13] refactor(screening): consolidate state transitions onto model methods --- src/screening/models.py | 103 +++++++++++++++++++++++++ src/screening/tests/test_models.py | 120 +++++++++++++++++++++++++++++ src/screening/views.py | 68 +++++----------- 3 files changed, 242 insertions(+), 49 deletions(-) diff --git a/src/screening/models.py b/src/screening/models.py index c482037d46..26ce516790 100644 --- a/src/screening/models.py +++ b/src/screening/models.py @@ -173,6 +173,74 @@ def screening_form_answers(self): "frozen_element__order", ) + def accept(self): + """Record acceptance of the screening invitation. + + Returns True if the assignment transitioned, False if it was + already accepted. + """ + if self.date_accepted: + return False + self.date_accepted = timezone.now() + self.save() + return True + + def decline(self): + """Record the screener's decline. + + Returns True if the assignment transitioned, False if it was + already declined. + """ + if self.date_declined: + return False + self.date_declined = timezone.now() + self.save() + return True + + def withdraw(self): + """Set the assignment to the withdrawn state. + + Stamps date_declined when not already set so the screener can + no longer act on the request. Returns True if the assignment + actually transitioned to withdrawn, False if it was already + withdrawn (the view uses this to decide whether to raise + ON_SCREENING_WITHDRAWN). + """ + if self.is_withdrawn: + return False + self.recommendation = SR.WITHDRAWN.value + if not self.date_declined: + self.date_declined = timezone.now() + self.save() + return True + + def reset(self): + """Reset a completed or declined assignment back to in-progress. + + Clears completion state, recommendation, and date_declined so + the screener can revise their report. date_accepted is preserved + so they do not have to re-accept. + """ + self.is_complete = False + self.date_complete = None + self.recommendation = None + self.date_declined = None + self.save() + + def complete(self, recommendation, suggested_reviewers="", comments_for_editor=""): + """Mark the assignment complete with the screener's report. + + Sets the recommendation, optional reviewer suggestions and + editor-only comments, flips is_complete, stamps date_complete, + and saves. + """ + self.recommendation = recommendation + self.suggested_reviewers = suggested_reviewers + self.comments_for_editor = comments_for_editor + self.is_complete = True + self.date_complete = timezone.now() + self.save() + def save_screening_form(self, screening_form): elements_by_pk = { str(e.pk): e @@ -407,6 +475,21 @@ class Meta(BaseScreeningFormElement.Meta): pass +class ScreeningRevisionRequestManager(models.Manager): + def open_for_article(self, article): + """Return the most recent open (not completed, not cancelled) + revision request for an article, or None.""" + return ( + self.filter( + article=article, + date_completed__isnull=True, + date_cancelled__isnull=True, + ) + .order_by("-date_requested") + .first() + ) + + class ScreeningRevisionRequest(models.Model): """Author revision triggered by a screening decision. @@ -454,6 +537,8 @@ class ScreeningRevisionRequest(models.Model): date_completed = models.DateTimeField(blank=True, null=True) date_cancelled = models.DateTimeField(blank=True, null=True) + objects = ScreeningRevisionRequestManager() + def __str__(self): editor_name = self.editor.full_name() if self.editor else "Unknown editor" return "Screening revision of {0} requested by {1}".format( @@ -473,6 +558,24 @@ def is_cancelled(self): def is_open(self): return not self.is_complete and not self.is_cancelled + def cancel(self): + """Cancel an open revision request. Stamps date_cancelled.""" + self.date_cancelled = timezone.now() + self.save() + + def complete(self): + """Mark the revision complete and open a fresh screening round. + + Stamps date_completed, saves, and opens a new ScreeningRound on + the article so the same screener(s) can re-screen the revision. + Returns the new ScreeningRound. + """ + from screening import logic as screening_logic + + self.date_completed = timezone.now() + self.save() + return screening_logic.open_screening_round(self.article) + class ScreeningPool(models.Model): """Per-journal selection of editorial groups whose members are diff --git a/src/screening/tests/test_models.py b/src/screening/tests/test_models.py index 6e92dedce2..519a272951 100644 --- a/src/screening/tests/test_models.py +++ b/src/screening/tests/test_models.py @@ -154,3 +154,123 @@ def test_screening_revision_request_str(self): date_due=datetime.date.today() + datetime.timedelta(days=14), ) self.assertIn("Screening revision", str(request)) + + def test_accept_stamps_date_accepted_once(self): + assignment = screening_models.ScreeningAssignment.objects.create( + article=self.article, + screener=self.screener_one, + editor=self.editor, + screening_round=self.round, + date_due=datetime.date.today() + datetime.timedelta(days=7), + ) + self.assertTrue(assignment.accept()) + first_stamp = assignment.date_accepted + self.assertIsNotNone(first_stamp) + self.assertFalse(assignment.accept()) + self.assertEqual(assignment.date_accepted, first_stamp) + + def test_decline_stamps_date_declined_once(self): + assignment = screening_models.ScreeningAssignment.objects.create( + article=self.article, + screener=self.screener_one, + editor=self.editor, + screening_round=self.round, + date_due=datetime.date.today() + datetime.timedelta(days=7), + ) + self.assertTrue(assignment.decline()) + first_stamp = assignment.date_declined + self.assertIsNotNone(first_stamp) + self.assertFalse(assignment.decline()) + self.assertEqual(assignment.date_declined, first_stamp) + + def test_withdraw_is_idempotent(self): + assignment = screening_models.ScreeningAssignment.objects.create( + article=self.article, + screener=self.screener_one, + editor=self.editor, + screening_round=self.round, + date_due=datetime.date.today() + datetime.timedelta(days=7), + ) + self.assertTrue(assignment.withdraw()) + self.assertTrue(assignment.is_withdrawn) + self.assertIsNotNone(assignment.date_declined) + self.assertFalse(assignment.withdraw()) + + def test_reset_clears_completion_and_decline_but_keeps_acceptance(self): + assignment = screening_models.ScreeningAssignment.objects.create( + article=self.article, + screener=self.screener_one, + editor=self.editor, + screening_round=self.round, + date_due=datetime.date.today() + datetime.timedelta(days=7), + ) + assignment.accept() + assignment.complete(recommendation="accept_for_peer_review") + accepted = assignment.date_accepted + assignment.reset() + self.assertFalse(assignment.is_complete) + self.assertIsNone(assignment.date_complete) + self.assertIsNone(assignment.recommendation) + self.assertIsNone(assignment.date_declined) + self.assertEqual(assignment.date_accepted, accepted) + + def test_complete_sets_full_recommendation_payload(self): + assignment = screening_models.ScreeningAssignment.objects.create( + article=self.article, + screener=self.screener_one, + editor=self.editor, + screening_round=self.round, + date_due=datetime.date.today() + datetime.timedelta(days=7), + ) + assignment.complete( + recommendation="accept_for_peer_review", + suggested_reviewers="Dr. Smith", + comments_for_editor="Strong submission.", + ) + self.assertTrue(assignment.is_complete) + self.assertEqual(assignment.recommendation, "accept_for_peer_review") + self.assertEqual(assignment.suggested_reviewers, "Dr. Smith") + self.assertEqual(assignment.comments_for_editor, "Strong submission.") + self.assertIsNotNone(assignment.date_complete) + + def test_revision_cancel_stamps_date_cancelled(self): + revision = screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + ) + revision.cancel() + self.assertIsNotNone(revision.date_cancelled) + self.assertTrue(revision.is_cancelled) + self.assertFalse(revision.is_open) + + def test_revision_complete_opens_new_round(self): + revision = screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + ) + prior_max = ( + screening_models.ScreeningRound.objects.filter( + article=self.article, + ) + .order_by("-round_number") + .first() + .round_number + ) + new_round = revision.complete() + self.assertIsNotNone(revision.date_completed) + self.assertTrue(revision.is_complete) + self.assertEqual(new_round.round_number, prior_max + 1) + + def test_open_for_article_returns_only_open(self): + manager = screening_models.ScreeningRevisionRequest.objects + self.assertIsNone(manager.open_for_article(self.article)) + first = manager.create( + article=self.article, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + ) + self.assertEqual(manager.open_for_article(self.article), first) + first.cancel() + self.assertIsNone(manager.open_for_article(self.article)) diff --git a/src/screening/views.py b/src/screening/views.py index 8c5079a4e3..e9d2359cb4 100644 --- a/src/screening/views.py +++ b/src/screening/views.py @@ -17,7 +17,6 @@ from core import workflow as core_workflow from events import logic as event_logic from screening import forms, logic, models as screening_models -from screening.const import ScreeningRecommendations from screening.decorators import ( screener_for_assignment_required, screener_or_editor_for_assignment_required, @@ -350,14 +349,7 @@ def withdraw_screening_assignment(request, article_id, assignment_id): pk=assignment_id, article=article, ) - already_withdrawn = ( - assignment.recommendation == ScreeningRecommendations.WITHDRAWN.value - ) - assignment.recommendation = ScreeningRecommendations.WITHDRAWN.value - if not assignment.date_declined: - assignment.date_declined = timezone.now() - assignment.save() - if not already_withdrawn: + if assignment.withdraw(): event_logic.Events.raise_event( event_logic.Events.ON_SCREENING_WITHDRAWN, task_object=article, @@ -428,11 +420,7 @@ def reset_screening_assignment(request, article_id, assignment_id): pk=assignment_id, article=article, ) - assignment.is_complete = False - assignment.date_complete = None - assignment.recommendation = None - assignment.date_declined = None - assignment.save() + assignment.reset() messages.add_message( request, messages.SUCCESS, @@ -476,9 +464,7 @@ def accept_screening_request(request, assignment_id, assignment=None): ) return redirect(reverse("screening_requests")) - if not assignment.date_accepted: - assignment.date_accepted = timezone.now() - assignment.save() + if assignment.accept(): messages.add_message( request, messages.SUCCESS, @@ -499,9 +485,7 @@ def decline_screening_request(request, assignment_id, assignment=None): ) return redirect(reverse("screening_requests")) - if not assignment.date_declined: - assignment.date_declined = timezone.now() - assignment.save() + if assignment.decline(): messages.add_message( request, messages.SUCCESS, @@ -518,9 +502,7 @@ def do_screening(request, assignment_id, assignment=None): screener.""" if assignment.date_declined: raise Http404 - if not assignment.date_accepted: - assignment.date_accepted = timezone.now() - assignment.save() + assignment.accept() form_class = ( forms.build_screening_form_class(assignment.form) if assignment.form else None @@ -543,20 +525,17 @@ def do_screening(request, assignment_id, assignment=None): if screening_form_valid and recommendation_valid: if screening_form is not None: assignment.save_screening_form(screening_form) - assignment.recommendation = recommendation_form.cleaned_data[ - "recommendation" - ] - assignment.suggested_reviewers = recommendation_form.cleaned_data.get( - "suggested_reviewers", - "", - ) - assignment.comments_for_editor = recommendation_form.cleaned_data.get( - "comments_for_editor", - "", + assignment.complete( + recommendation=recommendation_form.cleaned_data["recommendation"], + suggested_reviewers=recommendation_form.cleaned_data.get( + "suggested_reviewers", + "", + ), + comments_for_editor=recommendation_form.cleaned_data.get( + "comments_for_editor", + "", + ), ) - assignment.is_complete = True - assignment.date_complete = timezone.now() - assignment.save() event_logic.Events.raise_event( event_logic.Events.ON_SCREENING_COMPLETE, task_object=assignment.article, @@ -666,14 +645,8 @@ def request_screening_revisions(request, article_id): journal=request.journal, stage=submission_models.STAGE_SCREENING, ) - open_revision = ( - screening_models.ScreeningRevisionRequest.objects.filter( - article=article, - date_completed__isnull=True, - date_cancelled__isnull=True, - ) - .order_by("-date_requested") - .first() + open_revision = screening_models.ScreeningRevisionRequest.objects.open_for_article( + article, ) if open_revision: messages.add_message( @@ -806,8 +779,7 @@ def withdraw_screening_revisions(request, article_id, revision_id): date_completed__isnull=True, date_cancelled__isnull=True, ) - revision.date_cancelled = timezone.now() - revision.save() + revision.cancel() event_logic.Events.raise_event( event_logic.Events.ON_SCREENING_REVISION_WITHDRAWN, task_object=article, @@ -907,9 +879,7 @@ def do_screening_revisions(request, revision_id): ) if "submit" in request.POST and form.is_valid(): form.save() - revision.date_completed = timezone.now() - revision.save() - logic.open_screening_round(revision.article) + revision.complete() event_logic.Events.raise_event( event_logic.Events.ON_SCREENING_REVISIONS_COMPLETED, task_object=revision.article, From 3ba604adc64973b5e92c75201a62e706f7cf042b Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Mon, 18 May 2026 16:13:28 +0100 Subject: [PATCH 08/13] refactor(screening): rename build_email_data and promote deferred imports --- src/screening/models.py | 8 ++++---- src/screening/notifications.py | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/screening/models.py b/src/screening/models.py index 26ce516790..ca913ec6b9 100644 --- a/src/screening/models.py +++ b/src/screening/models.py @@ -4,6 +4,8 @@ __maintainer__ = "Open Library of Humanities" +import datetime + from django.db import models from django.db.models import Max, Q from django.utils import timezone @@ -299,17 +301,15 @@ def days_until_due(self): when overdue, zero on the day, positive when due in the future. Returns None if the assignment has no due date or the value is not comparable to a date.""" - import datetime as _dt - due = self.date_due if due is None: return None # date_due is declared as DateField, but historical rows or # bad data could store a datetime. Normalise to date before # comparing. - if isinstance(due, _dt.datetime): + if isinstance(due, datetime.datetime): due = due.date() - if not isinstance(due, _dt.date): + if not isinstance(due, datetime.date): return None try: return (due - timezone.now().date()).days diff --git a/src/screening/notifications.py b/src/screening/notifications.py index 6c06851076..04975667c3 100644 --- a/src/screening/notifications.py +++ b/src/screening/notifications.py @@ -15,7 +15,7 @@ from utils import notify_helpers, render_template, setting_handler -def _build_email_data(request, subject_setting, body_setting, context): +def build_email_data(request, subject_setting, body_setting, context): """Render the subject and body settings for the active journal and return an EmailData ready for send_email.""" subject = setting_handler.get_setting( @@ -65,7 +65,7 @@ def send_screener_requested(**kwargs): "screening_assignment": screening_assignment, "screening_requests_url": screening_requests_url, } - email_data = _build_email_data( + email_data = build_email_data( request, "subject_screening_invitation", "screening_invitation", @@ -109,7 +109,7 @@ def send_screening_complete(**kwargs): "screening_assignment": screening_assignment, "screening_article_url": screening_article_url, } - email_data = _build_email_data( + email_data = build_email_data( request, "subject_screening_complete", "screening_complete", @@ -158,7 +158,7 @@ def send_screening_passed(**kwargs): "article": article, "next_workflow_element": next_workflow_element, } - email_data = _build_email_data( + email_data = build_email_data( request, "subject_screening_passed", "screening_passed", @@ -223,7 +223,7 @@ def send_screening_revisions_requested(**kwargs): "screening_revision": revision, "do_revisions_url": do_revisions_url, } - email_data = _build_email_data( + email_data = build_email_data( request, "subject_screening_revisions_requested", "screening_revisions_requested", @@ -267,7 +267,7 @@ def send_screening_revisions_completed(**kwargs): "screening_revision": revision, "article_url": article_url, } - email_data = _build_email_data( + email_data = build_email_data( request, "subject_screening_revisions_completed", "screening_revisions_completed", @@ -306,7 +306,7 @@ def send_screening_withdrawn(**kwargs): return context = {"article": article, "screening_assignment": assignment} - email_data = _build_email_data( + email_data = build_email_data( request, "subject_screening_withdrawn", "screening_withdrawn", @@ -347,7 +347,7 @@ def send_screening_revision_withdrawn(**kwargs): return context = {"article": article, "screening_revision": revision} - email_data = _build_email_data( + email_data = build_email_data( request, "subject_screening_revision_withdrawn", "screening_revision_withdrawn", From 3571dd08b8eeb3852965d9b2a93001fc0299d96a Mon Sep 17 00:00:00 2001 From: Andy Byers Date: Mon, 18 May 2026 16:13:45 +0100 Subject: [PATCH 09/13] refactor(editor_assignment): extract stage transition helpers and require POST --- src/editor_assignment/logic.py | 15 +++++++ src/editor_assignment/views.py | 40 ++++++------------- src/screening/tests/test_flows.py | 10 ++--- .../admin/review/unassigned_article.html | 9 ++++- 4 files changed, 39 insertions(+), 35 deletions(-) diff --git a/src/editor_assignment/logic.py b/src/editor_assignment/logic.py index 6e9695f23e..7cc25e1e08 100644 --- a/src/editor_assignment/logic.py +++ b/src/editor_assignment/logic.py @@ -8,6 +8,21 @@ from events import logic as event_logic from review import models +from screening import logic as screening_logic +from screening.models import ScreeningRound + + +def setup_after_editor_assignment(article, next_element): + """Idempotent per-stage setup when an article enters the next workflow + element following editor assignment. New downstream stages should add + their initialisation here (or, when there are enough of them, this + should become a registry). + """ + if next_element.element_name == "review": + models.ReviewRound.objects.get_or_create(article=article, round_number=1) + elif next_element.element_name == "screening": + if not ScreeningRound.objects.filter(article=article).exists(): + screening_logic.open_screening_round(article) def get_assignment_context(request, article, editor, assignment): diff --git a/src/editor_assignment/views.py b/src/editor_assignment/views.py index b5556f0895..fab1ee836d 100644 --- a/src/editor_assignment/views.py +++ b/src/editor_assignment/views.py @@ -7,11 +7,13 @@ from django.contrib import messages from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.views.decorators.http import require_POST from core import ( forms as core_forms, logic as core_logic, models as core_models, + workflow as core_workflow, ) from editor_assignment import logic from events import logic as event_logic @@ -101,7 +103,10 @@ def unassigned_article(request, article_id): "article": article, "editors": editors, "section_editors": section_editors, - "next_workflow_element": _next_workflow_element(request.journal), + "next_workflow_element": core_workflow.get_next_workflow_element( + request.journal, + "editor_assignment", + ), } return render(request, template, context) @@ -309,29 +314,7 @@ def assignment_notification(request, article_id, editor_id): return render(request, template, context) -def _next_workflow_element(journal): - """Compatibility shim. Prefer core.workflow.get_next_workflow_element.""" - from core import workflow as core_workflow - - return core_workflow.get_next_workflow_element(journal, "editor_assignment") - - -def _setup_next_stage(article, next_element): - """Idempotent per-stage setup when an article enters the next workflow - element. New downstream stages should add their initialisation here - (or, when there are enough of them, this should become a registry). - """ - if next_element.element_name == "review": - models.ReviewRound.objects.get_or_create(article=article, round_number=1) - elif next_element.element_name == "screening": - from screening import logic as screening_logic - - if not screening_logic.screening_models.ScreeningRound.objects.filter( - article=article, - ).exists(): - screening_logic.open_screening_round(article) - - +@require_POST @editor_user_required def move_to_next_stage(request, article_id, should_redirect=True): """Move an article out of editor assignment into whichever workflow @@ -359,7 +342,10 @@ def move_to_next_stage(request, article_id, should_redirect=True): ) return - next_element = _next_workflow_element(request.journal) + next_element = core_workflow.get_next_workflow_element( + request.journal, + "editor_assignment", + ) if next_element is None: messages.add_message( request, @@ -374,12 +360,10 @@ def move_to_next_stage(request, article_id, should_redirect=True): # Pre-create the next stage's artefacts (Round 1, etc.) so they exist # when the user lands on the next page. - _setup_next_stage(article, next_element) + logic.setup_after_editor_assignment(article, next_element) # Delegate the stage transition, log entry, and redirect to the # canonical core.workflow machinery. - from core import workflow as core_workflow - workflow = request.journal.workflow() current_element = workflow.elements.get(element_name="editor_assignment") response = core_workflow.workflow_next( diff --git a/src/screening/tests/test_flows.py b/src/screening/tests/test_flows.py index 4935c2f5c3..bd52b8f7bd 100644 --- a/src/screening/tests/test_flows.py +++ b/src/screening/tests/test_flows.py @@ -176,7 +176,7 @@ def test_move_to_next_stage_routes_to_screening_when_enabled(self): editor_type="editor", ) self.client.force_login(self.editor) - response = self.client.get( + response = self.client.post( reverse( "editor_assignment_move_to_next_stage", kwargs={"article_id": self.article.pk}, @@ -214,7 +214,7 @@ def test_move_to_next_stage_routes_to_review_when_screening_disabled(self): editor_type="editor", ) self.client.force_login(editor_on_two) - self.client.get( + self.client.post( reverse( "editor_assignment_move_to_next_stage", kwargs={"article_id": article.pk}, @@ -229,7 +229,7 @@ def test_move_to_next_stage_routes_to_review_when_screening_disabled(self): def test_move_to_next_stage_requires_editor_assignment(self): self.client.force_login(self.editor) - self.client.get( + self.client.post( reverse( "editor_assignment_move_to_next_stage", kwargs={"article_id": self.article.pk}, @@ -378,7 +378,7 @@ def test_move_to_next_stage_from_editor_assignment_creates_workflow_log(self): editor_type="editor", ) self.client.force_login(self.editor) - self.client.get( + self.client.post( reverse( "editor_assignment_move_to_next_stage", kwargs={"article_id": self.article.pk}, @@ -418,7 +418,7 @@ def test_move_to_next_stage_from_editor_assignment_logs_review_when_screening_di editor_type="editor", ) self.client.force_login(editor_on_two) - self.client.get( + self.client.post( reverse( "editor_assignment_move_to_next_stage", kwargs={"article_id": article.pk}, diff --git a/src/templates/admin/review/unassigned_article.html b/src/templates/admin/review/unassigned_article.html index 11bed8740b..14424d889e 100644 --- a/src/templates/admin/review/unassigned_article.html +++ b/src/templates/admin/review/unassigned_article.html @@ -280,8 +280,13 @@

    Actions

    {% endif %} - {% include "admin/screening/_checklist_panel.html" %} + {% include "admin/screening/elements/checklist_panel.html" %}
    diff --git a/src/templates/admin/screening/_checklist_item_row.html b/src/templates/admin/screening/elements/checklist_item_row.html similarity index 100% rename from src/templates/admin/screening/_checklist_item_row.html rename to src/templates/admin/screening/elements/checklist_item_row.html diff --git a/src/templates/admin/screening/_checklist_panel.html b/src/templates/admin/screening/elements/checklist_panel.html similarity index 98% rename from src/templates/admin/screening/_checklist_panel.html rename to src/templates/admin/screening/elements/checklist_panel.html index 904dab54f8..526975db5f 100644 --- a/src/templates/admin/screening/_checklist_panel.html +++ b/src/templates/admin/screening/elements/checklist_panel.html @@ -28,7 +28,7 @@

    Technical Checklist

    {% for item in items %} - {% include "admin/screening/_checklist_item_row.html" %} + {% include "admin/screening/elements/checklist_item_row.html" %} {% empty %} This checklist has no items. {% endfor %}