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/views.py b/src/core/views.py index 24706c8c8d..3bcb8b41fd 100755 --- a/src/core/views.py +++ b/src/core/views.py @@ -52,6 +52,8 @@ 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 screening.const import ScreeningRecommendations from journal import models as journal_models from proofing import logic as proofing_logic from proofing import models as proofing_models @@ -827,9 +829,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 +854,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 +886,26 @@ 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, + ) + .exclude(recommendation=ScreeningRecommendations.WITHDRAWN.value) + .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 +2303,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 +2320,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/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..7cc25e1e08 --- /dev/null +++ b/src/editor_assignment/logic.py @@ -0,0 +1,108 @@ +__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 +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): + 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..fab1ee836d --- /dev/null +++ b/src/editor_assignment/views.py @@ -0,0 +1,385 @@ +__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 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 +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": core_workflow.get_next_workflow_element( + request.journal, + "editor_assignment", + ), + } + + 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) + + +@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 + 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 = core_workflow.get_next_workflow_element( + request.journal, + "editor_assignment", + ) + 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. + logic.setup_after_editor_assignment(article, next_element) + + # Delegate the stage transition, log entry, and redirect to the + # canonical core.workflow machinery. + 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/events/logic.py b/src/events/logic.py index 0d9af844f7..8b63893fcb 100755 --- a/src/events/logic.py +++ b/src/events/logic.py @@ -297,6 +297,36 @@ class Events: ON_TYPESETTING_COMPLETE = "on_typesetting_complete" ON_TYPESETTING_ASSIGN_NOTIFICATION = "on_typesetting_assign_notification" ON_TYPESETTING_ASSIGN_DECISION = "on_typesetting_assign_decision" + + # Screening Events (bau#271) + # kwargs: request, screening_assignment + # raised when a screener is invited to screen an article + ON_SCREENER_REQUESTED = "on_screener_requested" + # kwargs: request, screening_assignment + # raised when a screener submits their screening report + ON_SCREENING_COMPLETE = "on_screening_complete" + # kwargs: request, article, next_workflow_element + # raised when an article exits screening into the next workflow stage + # so the corresponding author can be notified the submission has + # passed screening + ON_SCREENING_PASSED = "on_screening_passed" + # kwargs: request, screening_revision + # raised when an editor requests revisions from the author after + # screening; handler emails the corresponding author with a link to + # the revision page + ON_SCREENING_REVISIONS_REQUESTED = "on_screening_revisions_requested" + # kwargs: request, screening_revision + # 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 70ab0db7a9..6e0d592bcf 100755 --- a/src/events/registration.py +++ b/src/events/registration.py @@ -9,6 +9,7 @@ from journal import logic as journal_logic from identifiers import logic as id_logic, reviews from typesetting.notifications import emails +from screening import notifications as screening_notifications # wire up event notifications # Submission @@ -308,6 +309,43 @@ emails.send_typesetting_assign_decision, ) +# Screening Events (bau#271) + +event_logic.Events.register_for_event( + event_logic.Events.ON_SCREENER_REQUESTED, + screening_notifications.send_screener_requested, +) + +event_logic.Events.register_for_event( + event_logic.Events.ON_SCREENING_COMPLETE, + screening_notifications.send_screening_complete, +) + +event_logic.Events.register_for_event( + event_logic.Events.ON_SCREENING_PASSED, + screening_notifications.send_screening_passed, +) + +event_logic.Events.register_for_event( + event_logic.Events.ON_SCREENING_REVISIONS_REQUESTED, + screening_notifications.send_screening_revisions_requested, +) + +event_logic.Events.register_for_event( + event_logic.Events.ON_SCREENING_REVISIONS_COMPLETED, + 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/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/screening/__init__.py b/src/screening/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/screening/admin.py b/src/screening/admin.py new file mode 100644 index 0000000000..16ea25a392 --- /dev/null +++ b/src/screening/admin.py @@ -0,0 +1,118 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + +from django.contrib import admin + +from screening import models +from utils import admin_utils + + +class ScreeningRoundAdmin(admin_utils.ArticleFKModelAdmin): + list_display = ("pk", "_article", "round_number", "date_started", "_journal") + list_filter = ("article__journal", "round_number", "date_started") + search_fields = ("article__pk", "article__title", "article__journal__code") + raw_id_fields = ("article",) + date_hierarchy = "date_started" + + +class ScreeningAssignmentAdmin(admin_utils.ArticleFKModelAdmin): + list_display = ( + "pk", + "_article", + "_journal", + "screener", + "editor", + "recommendation", + "anonymous_to_author", + "anonymous_to_coscreeners", + "date_due", + "date_complete", + "is_complete", + ) + list_filter = ( + "article__journal", + "recommendation", + "anonymous_to_author", + "anonymous_to_coscreeners", + "is_complete", + "date_due", + ) + search_fields = ( + "article__pk", + "article__title", + "screener__email", + "screener__first_name", + "screener__last_name", + "editor__email", + "editor__first_name", + "editor__last_name", + ) + raw_id_fields = ( + "article", + "screener", + "editor", + "screening_round", + "form", + ) + date_hierarchy = "date_requested" + + +class ScreeningFormAdmin(admin.ModelAdmin): + list_display = ("pk", "name", "journal", "deleted") + list_filter = ("journal", "deleted") + search_fields = ("name", "journal__code") + raw_id_fields = ("elements",) + + +class ScreeningFormElementAdmin(admin.ModelAdmin): + list_display = ("pk", "name", "kind", "required", "order") + list_filter = ("kind", "required") + search_fields = ("name",) + + +class FrozenScreeningFormElementAdmin(admin.ModelAdmin): + list_display = ("pk", "name", "kind", "order", "answer") + list_filter = ("kind",) + search_fields = ("name",) + raw_id_fields = ("form_element", "answer") + + +class ScreeningAssignmentAnswerAdmin(admin.ModelAdmin): + list_display = ("pk", "assignment", "original_element") + raw_id_fields = ("assignment", "original_element") + + +class ScreeningRevisionRequestAdmin(admin_utils.ArticleFKModelAdmin): + list_display = ( + "pk", + "_article", + "_journal", + "editor", + "type", + "date_requested", + "date_due", + "date_completed", + ) + list_filter = ("type", "article__journal", "date_due") + search_fields = ( + "article__pk", + "article__title", + "editor__email", + ) + raw_id_fields = ("article", "editor") + date_hierarchy = "date_requested" + + +admin_list = [ + (models.ScreeningRound, ScreeningRoundAdmin), + (models.ScreeningAssignment, ScreeningAssignmentAdmin), + (models.ScreeningForm, ScreeningFormAdmin), + (models.ScreeningFormElement, ScreeningFormElementAdmin), + (models.FrozenScreeningFormElement, FrozenScreeningFormElementAdmin), + (models.ScreeningAssignmentAnswer, ScreeningAssignmentAnswerAdmin), + (models.ScreeningRevisionRequest, ScreeningRevisionRequestAdmin), +] + +[admin.site.register(*t) for t in admin_list] diff --git a/src/screening/apps.py b/src/screening/apps.py new file mode 100644 index 0000000000..d32235040f --- /dev/null +++ b/src/screening/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ScreeningConfig(AppConfig): + name = "screening" + verbose_name = "Screening" diff --git a/src/screening/const.py b/src/screening/const.py new file mode 100644 index 0000000000..2c4395594e --- /dev/null +++ b/src/screening/const.py @@ -0,0 +1,71 @@ +""" +Constants defined by the screening app. + +The recommendation vocabulary is intentionally softer than peer review's +per the ILR specification in bau#271 — editorial teams asked for language +that does not feel as harsh to authors when their submission is being +filtered before peer review. +""" + +from utils.const import EnumContains + + +class ScreeningRecommendations(EnumContains): + """Recommendations a screener may make on a submission.""" + + ACCEPT_FOR_PEER_REVIEW = "accept_for_peer_review" + REVISIONS_REQUIRED = "revisions_required" + DECLINE = "decline" + NO_RECOMMENDATION = "no_recommendation" + WITHDRAWN = "withdrawn" + + +class ScreeningRevisionTypes(EnumContains): + """Revision types that can be requested following screening.""" + + MINOR_REVISIONS = "minor_revisions" + MAJOR_REVISIONS = "major_revisions" + + +def screener_recommendation_choices(): + return ( + (None, "-----------"), + ( + ScreeningRecommendations.ACCEPT_FOR_PEER_REVIEW.value, + "Accept for Peer Review", + ), + ( + ScreeningRecommendations.REVISIONS_REQUIRED.value, + "Revisions Required", + ), + (ScreeningRecommendations.DECLINE.value, "Decline"), + ) + + +def all_screener_recommendations(): + return ( + ( + ScreeningRecommendations.ACCEPT_FOR_PEER_REVIEW.value, + "Accept for Peer Review", + ), + ( + ScreeningRecommendations.REVISIONS_REQUIRED.value, + "Revisions Required", + ), + (ScreeningRecommendations.DECLINE.value, "Decline"), + ( + ScreeningRecommendations.NO_RECOMMENDATION.value, + "No Recommendation", + ), + ( + ScreeningRecommendations.WITHDRAWN.value, + "Withdrawn", + ), + ) + + +def screening_revision_type_choices(): + return ( + (ScreeningRevisionTypes.MINOR_REVISIONS.value, "Minor Revisions"), + (ScreeningRevisionTypes.MAJOR_REVISIONS.value, "Major Revisions"), + ) diff --git a/src/screening/decorators.py b/src/screening/decorators.py new file mode 100644 index 0000000000..ba7fb4224a --- /dev/null +++ b/src/screening/decorators.py @@ -0,0 +1,66 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + +from functools import wraps + +from django.contrib.auth.decorators import login_required +from django.http import Http404 + + +def screener_for_assignment_required(func): + """Allow only the screener assigned to a given ScreeningAssignment to + proceed. Returns 404 to anonymous or other users so the assignment's + existence is not leaked. + """ + + @login_required + @wraps(func) + def wrapper(request, *args, **kwargs): + from screening import models as screening_models + + assignment_id = kwargs.get("assignment_id") + assignment = screening_models.ScreeningAssignment.objects.filter( + pk=assignment_id, + article__journal=request.journal, + ).first() + if assignment is None or assignment.screener != request.user: + raise Http404 + kwargs["assignment"] = assignment + return func(request, *args, **kwargs) + + return wrapper + + +def screener_or_editor_for_assignment_required(func): + """Permit either the named screener or any journal editor / staff to + access a ScreeningAssignment-scoped view. Used for the form-filling + and confirmation pages where editors may need to act on the + screener's behalf. + """ + + @login_required + @wraps(func) + def wrapper(request, *args, **kwargs): + from screening import models as screening_models + + assignment_id = kwargs.get("assignment_id") + assignment = screening_models.ScreeningAssignment.objects.filter( + pk=assignment_id, + article__journal=request.journal, + ).first() + if assignment is None: + raise Http404 + is_screener = assignment.screener == request.user + is_editor = ( + request.user.is_staff + or request.user.is_editor(request) + or request.user.is_section_editor(request) + ) + if not (is_screener or is_editor): + raise Http404 + kwargs["assignment"] = assignment + return func(request, *args, **kwargs) + + return wrapper diff --git a/src/screening/forms.py b/src/screening/forms.py new file mode 100644 index 0000000000..9c649b5e67 --- /dev/null +++ b/src/screening/forms.py @@ -0,0 +1,230 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + + +import datetime + +from django import forms +from django.utils.safestring import mark_safe + +from screening import logic, models +from screening.const import screener_recommendation_choices +from utils.forms import HTMLDateInput + + +class ScreeningAssignmentForm(forms.ModelForm): + """Form for inviting a screener to a round. + + The screener choice list is restricted to the journal's editorial team + and excludes anyone already assigned to this round. + """ + + class Meta: + model = models.ScreeningAssignment + fields = ( + "screener", + "form", + "date_due", + "anonymous_to_author", + "anonymous_to_coscreeners", + ) + widgets = {"date_due": HTMLDateInput} + + def __init__(self, *args, **kwargs): + self.article = kwargs.pop("article") + self.journal = kwargs.pop("journal") + self.screening_round = kwargs.pop("screening_round") + self.editor = kwargs.pop("editor") + super().__init__(*args, **kwargs) + + if self.instance and self.instance.pk: + del self.fields["screener"] + else: + already_assigned_ids = list( + logic.current_screeners_on_round(self.screening_round).values_list( + "pk", + flat=True, + ) + ) + self.fields["screener"].queryset = logic.eligible_screeners( + self.journal, + exclude_user_ids=already_assigned_ids, + ) + self.fields["form"].queryset = models.ScreeningForm.objects.filter( + journal=self.journal, + deleted=False, + ) + self.fields["form"].required = False + + if not self.initial.get("date_due"): + self.fields["date_due"].initial = ( + datetime.date.today() + datetime.timedelta(days=14) + ) + + def save(self, commit=True): + assignment = super().save(commit=False) + assignment.article = self.article + assignment.screening_round = self.screening_round + assignment.editor = self.editor + if commit: + assignment.save() + return assignment + + +def _field_for_element(element): + """Build a Django form field that matches the shape of a + ScreeningFormElement. Mirrors review's GeneratedForm pattern; kept + minimal to the kinds in production use.""" + kind = element.kind + common = { + "label": element.name, + "required": element.required, + "help_text": mark_safe(element.help_text) if element.help_text else "", + } + if kind == "text": + return forms.CharField(**common) + if kind == "textarea": + return forms.CharField(widget=forms.Textarea, **common) + if kind == "check": + return forms.BooleanField(**{**common, "required": False}) + if kind == "select": + raw_choices = (element.choices or "").split("|") + choices = [ + (choice.strip(), choice.strip()) for choice in raw_choices if choice.strip() + ] + return forms.ChoiceField(choices=choices, **common) + if kind == "email": + return forms.EmailField(**common) + if kind == "date": + return forms.DateField(**common) + if kind == "upload": + return forms.FileField(**common) + return forms.CharField(**common) + + +def build_screening_form_class(screening_form): + """Return a dynamic Django Form class whose fields match the + elements declared on the given ScreeningForm. Field keys are the + integer PK of each ScreeningFormElement so the view can map answers + back to the element when saving.""" + field_map = {} + for element in screening_form.elements.all().order_by("order"): + field_map[str(element.pk)] = _field_for_element(element) + return type("DynamicScreeningForm", (forms.Form,), field_map) + + +class ScreeningPoolForm(forms.ModelForm): + """Manager-side form letting a journal select the editorial groups + that make up the screener pool.""" + + class Meta: + model = models.ScreeningPool + fields = ("groups",) + widgets = {"groups": forms.CheckboxSelectMultiple} + + def __init__(self, *args, **kwargs): + journal = kwargs.pop("journal") + super().__init__(*args, **kwargs) + from core import models as core_models + + self.fields["groups"].queryset = core_models.EditorialGroup.objects.filter( + journal=journal, + ) + self.fields["groups"].label = "Editorial groups whose members may screen" + + +class ChecklistTemplateForm(forms.ModelForm): + class Meta: + model = models.TechnicalChecklistTemplate + exclude = ("journal", "deleted") + + +class ChecklistTemplateItemForm(forms.ModelForm): + class Meta: + model = models.TechnicalChecklistTemplateItem + exclude = ("template",) + + +class NewScreeningForm(forms.ModelForm): + """Manager-side form to create / edit a ScreeningForm (the + container for elements).""" + + class Meta: + model = models.ScreeningForm + exclude = ("journal", "elements", "deleted") + + +class ScreeningElementForm(forms.ModelForm): + """Manager-side form to create / edit a single ScreeningFormElement.""" + + class Meta: + model = models.ScreeningFormElement + exclude = ("",) + + +class ScreeningRevisionRequestForm(forms.ModelForm): + """Editor-side form to request revisions from the author after + screening. Captures the editor's note, due date, and severity.""" + + class Meta: + model = models.ScreeningRevisionRequest + fields = ("type", "editor_note", "date_due") + widgets = {"date_due": HTMLDateInput} + + def __init__(self, *args, **kwargs): + self.article = kwargs.pop("article") + self.editor = kwargs.pop("editor") + super().__init__(*args, **kwargs) + self.fields["editor_note"].required = True + if not self.initial.get("date_due"): + self.fields["date_due"].initial = ( + datetime.date.today() + datetime.timedelta(days=14) + ) + + def save(self, commit=True): + revision = super().save(commit=False) + revision.article = self.article + revision.editor = self.editor + if commit: + revision.save() + return revision + + +class AuthorRevisionResponseForm(forms.ModelForm): + """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 + fields = ("author_note",) + + +class ScreeningRecommendationForm(forms.Form): + """The screener's final recommendation, captured alongside their + answers to the screening form.""" + + recommendation = forms.ChoiceField( + choices=screener_recommendation_choices(), + required=True, + label="Recommendation", + ) + suggested_reviewers = forms.CharField( + widget=forms.Textarea(attrs={"rows": 4}), + required=False, + label="Suggested reviewers", + help_text=( + "If recommending the article for peer review, you may suggest " + "reviewers here. These suggestions are hidden from the author." + ), + ) + comments_for_editor = forms.CharField( + widget=forms.Textarea(attrs={"rows": 4}), + required=False, + label="Comments for the editor", + help_text=( + "Visible only to the managing editor; will not be shared with the author." + ), + ) diff --git a/src/screening/logic.py b/src/screening/logic.py new file mode 100644 index 0000000000..f748e1c850 --- /dev/null +++ b/src/screening/logic.py @@ -0,0 +1,248 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + + +from django.db.models import Count, Max, Q +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 +from review import models as review_models +from screening import models as screening_models + + +SCREENER_POOL_ROLES = ("editor", "section-editor") + + +def open_screening_round(article): + """Open a new screening round for an article. + + Round numbers are sequential per article; the first round opens at 1 + and each subsequent round is one higher than the most recent round on + that article. + """ + existing = screening_models.ScreeningRound.objects.filter(article=article) + if existing.exists(): + next_number = existing.order_by("-round_number").first().round_number + 1 + else: + next_number = 1 + return screening_models.ScreeningRound.objects.create( + article=article, + round_number=next_number, + ) + + +def eligible_screeners(journal, exclude_user_ids=None): + """Return Accounts eligible to act as screeners on the journal. + + If the journal has configured a ScreeningPool with one or more + editorial groups selected, members of those groups make up the pool. + Otherwise the pool falls back to holders of the editor or + section-editor roles on this journal. + """ + exclude_user_ids = set(exclude_user_ids or []) + pool = screening_models.ScreeningPool.objects.filter(journal=journal).first() + if pool is not None and pool.groups.exists(): + queryset = core_models.Account.objects.filter( + editorialgroupmember__group__in=pool.groups.all(), + ) + else: + role_filter = core_models.AccountRole.objects.filter( + role__slug__in=SCREENER_POOL_ROLES, + journal=journal, + ) + queryset = core_models.Account.objects.filter( + accountrole__in=role_filter, + ) + return queryset.exclude(pk__in=exclude_user_ids).distinct() + + +def assign_screener( + article, + screener, + editor, + screening_round, + date_due, + anonymous_to_author=True, + anonymous_to_coscreeners=False, + form=None, +): + """Create a ScreeningAssignment for the given article and screener. + + Returns the assignment and a created boolean (matching Django's + get_or_create signature). + """ + return screening_models.ScreeningAssignment.objects.get_or_create( + article=article, + screener=screener, + screening_round=screening_round, + defaults={ + "editor": editor, + "date_due": date_due, + "anonymous_to_author": anonymous_to_author, + "anonymous_to_coscreeners": anonymous_to_coscreeners, + "form": form, + }, + ) + + +def current_screeners_on_round(screening_round): + """Return the set of screener Accounts already assigned to this round.""" + return core_models.Account.objects.filter( + screener_assignments__screening_round=screening_round, + ).distinct() + + +def annotate_candidate_screeners(queryset, journal): + """Decorate the candidate Account queryset with the per-row data the + invitation table renders: editorial roles on this journal, current + active screening count and the date of the screener's last completed + screening on this journal.""" + annotated = queryset.annotate( + active_screenings_count=Count( + "screener_assignments", + filter=Q( + screener_assignments__article__journal=journal, + screener_assignments__is_complete=False, + screener_assignments__date_declined__isnull=True, + ), + distinct=True, + ), + last_screening_completed=Max( + "screener_assignments__date_complete", + filter=Q( + screener_assignments__article__journal=journal, + screener_assignments__is_complete=True, + ), + ), + ) + role_lookup = editorial_role_labels_by_user(journal) + group_lookup = editorial_group_labels_by_user(journal) + candidates = list(annotated) + for candidate in candidates: + candidate.role_labels = role_lookup.get(candidate.pk, []) + candidate.group_labels = group_lookup.get(candidate.pk, []) + return candidates + + +def editorial_role_labels_by_user(journal): + """Return a mapping of user_id -> [role display name, ...] for the + editorial team on the journal. Keeps the per-candidate role list to + a single query rather than touching account_role for every row.""" + role_qs = core_models.AccountRole.objects.filter( + journal=journal, + role__slug__in=SCREENER_POOL_ROLES, + ).select_related("role") + mapping = {} + for row in role_qs: + mapping.setdefault(row.user_id, []).append(row.role.name) + return mapping + + +def editorial_group_labels_by_user(journal): + """Return a mapping of user_id -> [editorial group name, ...] for + members of the groups in this journal's screener pool. Falls back + to an empty mapping when no pool is configured.""" + pool = screening_models.ScreeningPool.objects.filter(journal=journal).first() + if pool is None: + return {} + members = core_models.EditorialGroupMember.objects.filter( + group__in=pool.groups.all(), + ).select_related("group") + mapping = {} + for member in members: + mapping.setdefault(member.user_id, []).append(member.group.name) + return mapping + + +def back_url_for_assignment(request, assignment): + """Return the URL the user should be sent to after viewing or + completing a screening report. Editors go back to the article's + screening page; screeners go back to their Screening Requests list.""" + if request.user.pk == assignment.screener_id: + return reverse("screening_requests") + return reverse( + "screening_article", + kwargs={"article_id": assignment.article_id}, + ) + + +def setup_after_screening(article, next_element): + """Per-stage setup when an article exits screening into the next + workflow element. Mirrors editor_assignment's dispatcher so that + going screening → review still creates ReviewRound 1, etc.""" + if next_element.element_name == "review": + review_models.ReviewRound.objects.get_or_create( + article=article, + round_number=1, + ) + + +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 + table can swap just the affected row), or a full-page redirect + back to the screening article otherwise.""" + if request.headers.get("HX-Request"): + return render( + request, + "admin/screening/elements/checklist_item_row.html", + {"item": item}, + ) + return redirect( + reverse( + "screening_article", + kwargs={"article_id": item.checklist.article.pk}, + ) + ) + + +def ensure_checklist_for_article(article): + """Return the article's TechnicalChecklist, creating one from the + journal's default template if none exists yet. Returns None if the + journal has no default checklist template configured.""" + existing = screening_models.TechnicalChecklist.objects.filter( + article=article, + ).first() + if existing: + return existing + + default_template = screening_models.TechnicalChecklistTemplate.objects.filter( + journal=article.journal, + is_default=True, + deleted=False, + ).first() + if default_template is None: + return None + + checklist = screening_models.TechnicalChecklist.objects.create( + article=article, + template=default_template, + ) + for template_item in default_template.items.all(): + screening_models.TechnicalChecklistItem.objects.create( + checklist=checklist, + template_item=template_item, + label=template_item.label, + order=template_item.order, + ) + return checklist diff --git a/src/screening/migrations/0001_initial.py b/src/screening/migrations/0001_initial.py new file mode 100644 index 0000000000..56f81afddf --- /dev/null +++ b/src/screening/migrations/0001_initial.py @@ -0,0 +1,587 @@ +# Generated by Django 4.2.29 on 2026-05-15 15:46 + +import core.model_utils +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("core", "0112_editor_assignment_primary_urls"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("journal", "0068_issue_cached_display_title_a11y_and_more"), + ("submission", "0090_alter_article_stage"), + ] + + operations = [ + migrations.CreateModel( + name="ScreeningAssignment", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "recommendation", + models.CharField( + blank=True, + choices=[ + ("accept_for_peer_review", "Accept for Peer Review"), + ("revisions_required", "Revisions Required"), + ("decline", "Decline"), + ("no_recommendation", "No Recommendation"), + ("withdrawn", "Withdrawn"), + ], + max_length=40, + null=True, + verbose_name="Recommendation", + ), + ), + ( + "suggested_reviewers", + models.TextField( + blank=True, + help_text="If recommending the article for peer review, the screener may suggest reviewers here. Hidden from the author.", + null=True, + ), + ), + ( + "anonymous_to_author", + models.BooleanField( + default=True, + help_text="Hide the screener's name from the corresponding author.", + ), + ), + ( + "anonymous_to_coscreeners", + models.BooleanField( + default=False, + help_text="Hide the screener's name from other screeners on this round.", + ), + ), + ( + "comments_for_editor", + core.model_utils.JanewayBleachField( + blank=True, + help_text="Comments visible only to the managing editor; will not be shared with the author.", + null=True, + verbose_name="Comments for the Editor", + ), + ), + ("date_requested", models.DateTimeField(auto_now_add=True)), + ("date_due", models.DateField()), + ("date_accepted", models.DateTimeField(blank=True, null=True)), + ("date_declined", models.DateTimeField(blank=True, null=True)), + ("date_complete", models.DateTimeField(blank=True, null=True)), + ("is_complete", models.BooleanField(default=False)), + ( + "article", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="submission.article", + ), + ), + ( + "editor", + models.ForeignKey( + help_text="Editor requesting the screening report", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="editor_screening_assignments", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="ScreeningFormElement", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200)), + ( + "kind", + models.CharField( + choices=[ + ("text", "Text Field"), + ("textarea", "Text Area"), + ("check", "Check Box"), + ("select", "Select"), + ("email", "Email"), + ("upload", "Upload"), + ("date", "Date"), + ], + max_length=50, + ), + ), + ( + "choices", + models.CharField( + blank=True, + help_text="Separate choices with the bar | character.", + max_length=1000, + null=True, + ), + ), + ("required", models.BooleanField(default=True)), + ("order", models.IntegerField()), + ( + "help_text", + core.model_utils.JanewayBleachField(blank=True, null=True), + ), + ( + "default_visibility", + models.BooleanField( + default=True, + help_text="If true, this answer will be available to the author by default; if false, it will be hidden from the author.", + ), + ), + ], + options={ + "ordering": ("order", "name"), + "abstract": False, + }, + ), + migrations.CreateModel( + name="TechnicalChecklist", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ( + "article", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="technical_checklist", + to="submission.article", + ), + ), + ], + ), + migrations.CreateModel( + name="TechnicalChecklistTemplate", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200)), + ("is_default", models.BooleanField(default=False)), + ("deleted", models.BooleanField(default=False)), + ( + "journal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="journal.journal", + ), + ), + ], + options={ + "ordering": ("-is_default", "name"), + }, + ), + migrations.CreateModel( + name="TechnicalChecklistTemplateItem", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("label", models.CharField(max_length=255)), + ("help_text", models.CharField(blank=True, default="", max_length=500)), + ("order", models.IntegerField(default=0)), + ( + "template", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="screening.technicalchecklisttemplate", + ), + ), + ], + options={ + "ordering": ("order", "label"), + }, + ), + migrations.CreateModel( + name="TechnicalChecklistItem", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("label", models.CharField(max_length=255)), + ("is_complete", models.BooleanField(default=False)), + ("comment", models.TextField(blank=True, default="")), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("order", models.IntegerField(default=0)), + ( + "checklist", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to="screening.technicalchecklist", + ), + ), + ( + "completed_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "template_item", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="screening.technicalchecklisttemplateitem", + ), + ), + ], + options={ + "ordering": ("order", "label"), + }, + ), + migrations.AddField( + model_name="technicalchecklist", + name="template", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="screening.technicalchecklisttemplate", + ), + ), + migrations.CreateModel( + name="ScreeningRound", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("round_number", models.IntegerField()), + ("date_started", models.DateTimeField(auto_now_add=True)), + ( + "article", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="submission.article", + ), + ), + ], + options={ + "ordering": ("-round_number",), + "unique_together": {("article", "round_number")}, + }, + ), + migrations.CreateModel( + name="ScreeningRevisionRequest", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "editor_note", + core.model_utils.JanewayBleachField( + blank=True, + help_text="Description of the changes the author should make. Shown to the author on the revision page.", + null=True, + verbose_name="Note to Author", + ), + ), + ( + "author_note", + core.model_utils.JanewayBleachField( + blank=True, + help_text="Optional covering letter from the author describing the changes they made.", + null=True, + verbose_name="Covering Letter", + ), + ), + ( + "type", + models.CharField( + choices=[ + ("minor_revisions", "Minor Revisions"), + ("major_revisions", "Major Revisions"), + ], + default="minor_revisions", + max_length=40, + ), + ), + ( + "date_requested", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("date_due", models.DateField()), + ("date_completed", models.DateTimeField(blank=True, null=True)), + ( + "article", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="submission.article", + ), + ), + ( + "editor", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="ScreeningPool", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="Editorial groups whose members appear in the screener selection list. Leave empty to fall back to the journal's editor / section-editor role-holders.", + to="core.editorialgroup", + ), + ), + ( + "journal", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="screening_pool", + to="journal.journal", + ), + ), + ], + ), + migrations.CreateModel( + name="ScreeningForm", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200)), + ( + "intro", + core.model_utils.JanewayBleachField( + help_text="Message displayed at the start of the screening form." + ), + ), + ( + "thanks", + core.model_utils.JanewayBleachField( + help_text="Message displayed after the screener is finished." + ), + ), + ("deleted", models.BooleanField(default=False)), + ( + "elements", + models.ManyToManyField(to="screening.screeningformelement"), + ), + ( + "journal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="journal.journal", + ), + ), + ], + ), + migrations.CreateModel( + name="ScreeningAssignmentAnswer", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("answer", core.model_utils.JanewayBleachField(blank=True, null=True)), + ( + "assignment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="screening.screeningassignment", + ), + ), + ( + "original_element", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="screening.screeningformelement", + ), + ), + ], + ), + migrations.AddField( + model_name="screeningassignment", + name="form", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="screening.screeningform", + ), + ), + migrations.AddField( + model_name="screeningassignment", + name="screener", + field=models.ForeignKey( + help_text="User to undertake the screening report", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="screener_assignments", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="screeningassignment", + name="screening_round", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="screening.screeninground", + ), + ), + migrations.CreateModel( + name="FrozenScreeningFormElement", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=200)), + ( + "kind", + models.CharField( + choices=[ + ("text", "Text Field"), + ("textarea", "Text Area"), + ("check", "Check Box"), + ("select", "Select"), + ("email", "Email"), + ("upload", "Upload"), + ("date", "Date"), + ], + max_length=50, + ), + ), + ( + "choices", + models.CharField( + blank=True, + help_text="Separate choices with the bar | character.", + max_length=1000, + null=True, + ), + ), + ("required", models.BooleanField(default=True)), + ("order", models.IntegerField()), + ( + "help_text", + core.model_utils.JanewayBleachField(blank=True, null=True), + ), + ( + "default_visibility", + models.BooleanField( + default=True, + help_text="If true, this answer will be available to the author by default; if false, it will be hidden from the author.", + ), + ), + ( + "answer", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="frozen_element", + to="screening.screeningassignmentanswer", + ), + ), + ( + "form_element", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="screening.screeningformelement", + ), + ), + ], + options={ + "ordering": ("order", "name"), + "abstract": False, + }, + ), + ] 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/migrations/__init__.py b/src/screening/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/screening/models.py b/src/screening/models.py new file mode 100644 index 0000000000..ca913ec6b9 --- /dev/null +++ b/src/screening/models.py @@ -0,0 +1,694 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + + +import datetime + +from django.db import models +from django.db.models import Max, Q +from django.utils import timezone +from django.utils.translation import gettext as _ + +from core import model_utils +from screening.const import ( + ScreeningRecommendations as SR, + all_screener_recommendations, + screening_revision_type_choices, +) +from utils import shared + + +def element_kind_choices(): + return ( + ("text", "Text Field"), + ("textarea", "Text Area"), + ("check", "Check Box"), + ("select", "Select"), + ("email", "Email"), + ("upload", "Upload"), + ("date", "Date"), + ) + + +class ScreeningRound(models.Model): + article = models.ForeignKey( + "submission.Article", + on_delete=models.CASCADE, + ) + round_number = models.IntegerField() + date_started = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("article", "round_number") + ordering = ("-round_number",) + + def __str__(self): + return "%s - %s round_number: %s" % ( + self.pk, + self.article.title, + self.round_number, + ) + + def active_screenings(self): + return self.screeningassignment_set.exclude( + Q(date_declined__isnull=False) | Q(recommendation=SR.WITHDRAWN.value) + ).order_by("-recommendation") + + def inactive_screenings(self): + return self.screeningassignment_set.filter( + Q(date_declined__isnull=False) | Q(recommendation=SR.WITHDRAWN.value) + ).order_by("recommendation") + + @classmethod + def latest_article_round(cls, article): + """Return the most recent ScreeningRound for an article.""" + latest_round_number = ( + cls.objects.filter(article=article) + .aggregate(latest_round_number=Max("round_number")) + .get("latest_round_number", 0) + ) + return cls.objects.get(article=article, round_number=latest_round_number) + + @property + def completion_summary(self): + """Return (complete_count, total_count) for live (not withdrawn / + declined) assignments on this round, used to render the per-round + tab label as e.g. 'Round 1 (2 / 3)'.""" + active = self.screeningassignment_set.exclude( + Q(date_declined__isnull=False) | Q(recommendation=SR.WITHDRAWN.value), + ) + return ( + active.filter(is_complete=True).count(), + active.count(), + ) + + +class ScreeningAssignment(models.Model): + """A request to a member of the editorial team to screen a submission. + + Distinct from ReviewAssignment: a screener is internal editorial staff + (not an external peer reviewer), screening reports are never published, + and anonymity is configured per-assignment rather than per-round. + """ + + # FKs + article = models.ForeignKey( + "submission.Article", + on_delete=models.CASCADE, + ) + screener = models.ForeignKey( + "core.Account", + related_name="screener_assignments", + help_text="User to undertake the screening report", + null=True, + on_delete=models.SET_NULL, + ) + editor = models.ForeignKey( + "core.Account", + related_name="editor_screening_assignments", + help_text="Editor requesting the screening report", + null=True, + on_delete=models.SET_NULL, + ) + screening_round = models.ForeignKey( + ScreeningRound, + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + form = models.ForeignKey("ScreeningForm", null=True, on_delete=models.SET_NULL) + + # Recommendation + conditional reviewer suggestion + recommendation = models.CharField( + max_length=40, + blank=True, + null=True, + choices=all_screener_recommendations(), + verbose_name="Recommendation", + ) + suggested_reviewers = models.TextField( + blank=True, + null=True, + help_text=( + "If recommending the article for peer review, the screener may " + "suggest reviewers here. Hidden from the author." + ), + ) + + anonymous_to_author = models.BooleanField( + default=True, + help_text="Hide the screener's name from the corresponding author.", + ) + anonymous_to_coscreeners = models.BooleanField( + default=False, + help_text="Hide the screener's name from other screeners on this round.", + ) + + comments_for_editor = model_utils.JanewayBleachField( + blank=True, + null=True, + help_text=( + "Comments visible only to the managing editor; will not be " + "shared with the author." + ), + verbose_name="Comments for the Editor", + ) + + date_requested = models.DateTimeField(auto_now_add=True) + date_due = models.DateField() + date_accepted = models.DateTimeField(blank=True, null=True) + date_declined = models.DateTimeField(blank=True, null=True) + date_complete = models.DateTimeField(blank=True, null=True) + + is_complete = models.BooleanField(default=False) + + def __str__(self): + screener_name = self.screener.full_name() if self.screener else "No screener" + return "{0} - Article: {1}, Screener: {2}".format( + self.id, self.article.title, screener_name + ) + + def screening_form_answers(self): + return ScreeningAssignmentAnswer.objects.filter(assignment=self).order_by( + "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 + for e in ScreeningFormElement.objects.filter(screeningform=self.form) + } + for k, v in screening_form.cleaned_data.items(): + form_element = elements_by_pk[str(k)] + answer, _ = ScreeningAssignmentAnswer.objects.update_or_create( + assignment=self, + original_element=form_element, + defaults={"answer": v}, + ) + form_element.snapshot(answer) + + def screener_display(self, viewer=None): + """Return the screener's display name, honouring anonymity flags. + + Editorial staff always see the real name. The corresponding author + and other screeners are subject to the per-assignment anonymity + booleans. + """ + real_name = self.screener.full_name() if self.screener else "Unknown" + if viewer is None: + return real_name + + if self.anonymous_to_author and viewer == self.article.correspondence_author: + return _("Anonymous screener") + + if self.anonymous_to_coscreeners and self.screening_round: + co_screener_ids = self.screening_round.screeningassignment_set.exclude( + pk=self.pk, + ).values_list("screener_id", flat=True) + if viewer and viewer.pk in co_screener_ids: + return _("Anonymous screener") + + return real_name + + @property + def is_withdrawn(self): + return self.recommendation == SR.WITHDRAWN.value + + @property + def is_declined(self): + return self.date_declined is not None and not self.is_withdrawn + + @property + def is_late(self): + days = self.days_until_due + if days is None: + return False + return days <= 0 + + @property + def days_until_due(self): + """Signed number of days from today to the due date. Negative + 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.""" + 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, datetime.datetime): + due = due.date() + if not isinstance(due, datetime.date): + return None + try: + return (due - timezone.now().date()).days + except (TypeError, ValueError): + return None + + @property + def status(self): + if self.recommendation == SR.WITHDRAWN.value: + return { + "code": "withdrawn", + "display": "Withdrawn", + "span_class": "alert", + "date": "", + "reminder": None, + } + if self.date_complete and self.date_accepted: + return { + "code": "complete", + "display": "Complete", + "span_class": "success", + "date": shared.day_month(self.date_complete), + "reminder": None, + } + if self.date_accepted: + return { + "code": "accept", + "display": "Accepted", + "span_class": "success", + "date": shared.day_month(self.date_accepted), + "reminder": "accepted", + } + if self.date_declined: + return { + "code": "declined", + "display": "Declined", + "span_class": "alert", + "date": shared.day_month(self.date_declined), + "reminder": None, + } + return { + "code": "wait", + "display": "Awaiting Response", + "span_class": "warning", + "date": "", + "reminder": "request", + } + + +class ScreeningForm(models.Model): + journal = models.ForeignKey( + "journal.Journal", + on_delete=models.CASCADE, + ) + name = models.CharField(max_length=200) + intro = model_utils.JanewayBleachField( + help_text="Message displayed at the start of the screening form.", + ) + thanks = model_utils.JanewayBleachField( + help_text="Message displayed after the screener is finished.", + ) + elements = models.ManyToManyField("ScreeningFormElement") + deleted = models.BooleanField(default=False) + + def __str__(self): + return self.name + + +class BaseScreeningFormElement(models.Model): + name = models.CharField(max_length=200) + kind = models.CharField(max_length=50, choices=element_kind_choices()) + choices = models.CharField( + max_length=1000, + null=True, + blank=True, + help_text="Separate choices with the bar | character.", + ) + required = models.BooleanField(default=True) + order = models.IntegerField() + help_text = model_utils.JanewayBleachField(blank=True, null=True) + default_visibility = models.BooleanField( + default=True, + help_text=( + "If true, this answer will be available to the author by " + "default; if false, it will be hidden from the author." + ), + ) + + class Meta: + ordering = ("order", "name") + abstract = True + + def __str__(self): + return "Element: {0} ({1})".format(self.name, self.kind) + + +class ScreeningFormElement(BaseScreeningFormElement): + class Meta(BaseScreeningFormElement.Meta): + pass + + def snapshot(self, answer): + frozen, _ = FrozenScreeningFormElement.objects.update_or_create( + answer=answer, + defaults=dict( + form_element=self, + name=self.name, + kind=self.kind, + choices=self.choices, + required=self.required, + order=self.order, + help_text=self.help_text, + default_visibility=self.default_visibility, + ), + ) + return frozen + + +class ScreeningAssignmentAnswer(models.Model): + assignment = models.ForeignKey( + ScreeningAssignment, + on_delete=models.CASCADE, + ) + original_element = models.ForeignKey( + ScreeningFormElement, + null=True, + on_delete=models.SET_NULL, + ) + answer = model_utils.JanewayBleachField(blank=True, null=True) + + def __str__(self): + return "{0}, {1}".format(self.assignment, self.best_label) + + @property + def element(self): + return self.frozen_element + + @property + def best_label(self): + if self.original_element: + return self.original_element.name + if getattr(self, "frozen_element", None): + return self.frozen_element.name + return "element" + + +class FrozenScreeningFormElement(BaseScreeningFormElement): + """A snapshot of a screening form element at the time an answer is + created. Preserves the question label and shape so that subsequent + edits to the live form do not corrupt historical answers.""" + + form_element = models.ForeignKey( + ScreeningFormElement, + null=True, + on_delete=models.SET_NULL, + ) + answer = models.OneToOneField( + ScreeningAssignmentAnswer, + related_name="frozen_element", + on_delete=models.CASCADE, + ) + + 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. + + The editor opens a revision request from the screening article page; + the author lands on a dedicated screening revision page, uploads + revised files via the article's file machinery, optionally adds a + covering letter, and submits. A fresh ScreeningRound is opened on + submission so the same screener(s) can re-screen the revision. + """ + + article = models.ForeignKey( + "submission.Article", + on_delete=models.CASCADE, + ) + editor = models.ForeignKey( + "core.Account", + null=True, + on_delete=models.SET_NULL, + ) + editor_note = model_utils.JanewayBleachField( + blank=True, + null=True, + verbose_name="Note to Author", + help_text=( + "Description of the changes the author should make. Shown to " + "the author on the revision page." + ), + ) + author_note = model_utils.JanewayBleachField( + blank=True, + null=True, + verbose_name="Covering Letter", + help_text=( + "Optional covering letter from the author describing the changes they made." + ), + ) + type = models.CharField( + max_length=40, + choices=screening_revision_type_choices(), + default="minor_revisions", + ) + + 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) + + 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( + self.article.title, + editor_name, + ) + + @property + 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 + + 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 + eligible to be invited as screeners. When no groups are selected + the pool falls back to all editor and section-editor role-holders + on the journal.""" + + journal = models.OneToOneField( + "journal.Journal", + on_delete=models.CASCADE, + related_name="screening_pool", + ) + groups = models.ManyToManyField( + "core.EditorialGroup", + blank=True, + help_text=( + "Editorial groups whose members appear in the screener " + "selection list. Leave empty to fall back to the journal's " + "editor / section-editor role-holders." + ), + ) + + def __str__(self): + return "Screening pool for {0}".format(self.journal.code) + + +class TechnicalChecklistTemplate(models.Model): + """A journal-level template of items that screeners run through during + the technical check. One template per journal can be marked default; + that default is auto-applied to each article entering Screening. + """ + + journal = models.ForeignKey( + "journal.Journal", + on_delete=models.CASCADE, + ) + name = models.CharField(max_length=200) + is_default = models.BooleanField(default=False) + deleted = models.BooleanField(default=False) + + class Meta: + ordering = ("-is_default", "name") + + def __str__(self): + return "{0} ({1})".format(self.name, self.journal.code) + + +class TechnicalChecklistTemplateItem(models.Model): + template = models.ForeignKey( + TechnicalChecklistTemplate, + on_delete=models.CASCADE, + related_name="items", + ) + label = models.CharField(max_length=255) + help_text = models.CharField(max_length=500, blank=True, default="") + order = models.IntegerField(default=0) + + class Meta: + ordering = ("order", "label") + + def __str__(self): + return self.label + + +class TechnicalChecklist(models.Model): + """The per-article instantiation of a TechnicalChecklistTemplate. Each + article in Screening gets at most one checklist, derived from the + journal's default template at the moment of first visit.""" + + article = models.OneToOneField( + "submission.Article", + on_delete=models.CASCADE, + related_name="technical_checklist", + ) + template = models.ForeignKey( + TechnicalChecklistTemplate, + on_delete=models.SET_NULL, + null=True, + ) + created = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return "Checklist for {0}".format(self.article.title) + + +class TechnicalChecklistItem(models.Model): + checklist = models.ForeignKey( + TechnicalChecklist, + on_delete=models.CASCADE, + related_name="items", + ) + template_item = models.ForeignKey( + TechnicalChecklistTemplateItem, + on_delete=models.SET_NULL, + null=True, + ) + label = models.CharField(max_length=255) + is_complete = models.BooleanField(default=False) + comment = models.TextField(blank=True, default="") + completed_by = models.ForeignKey( + "core.Account", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + completed_at = models.DateTimeField(blank=True, null=True) + order = models.IntegerField(default=0) + + class Meta: + ordering = ("order", "label") + + def __str__(self): + return "{0}: {1}".format( + self.checklist.article.title, + self.label, + ) diff --git a/src/screening/notifications.py b/src/screening/notifications.py new file mode 100644 index 0000000000..04975667c3 --- /dev/null +++ b/src/screening/notifications.py @@ -0,0 +1,373 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + +""" +Email notifications for the Screening app, raised via the Janeway events +framework. See events/registration.py for handler wiring and +journal_defaults.json for the templated subject + body strings. +""" + +from django.urls import reverse + +from core import email as core_email +from utils import notify_helpers, render_template, setting_handler + + +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( + "email_subject", + subject_setting, + request.journal, + ).processed_value + body = render_template.get_message_content(request, context, body_setting) + return core_email.EmailData(subject=subject, body=body) + + +def send_screener_requested(**kwargs): + """Handler for ON_SCREENER_REQUESTED. + + Notifies the screener that they have been invited to provide a + screening report on an article. When the request was raised with + ``email_data`` (the editor previewed and edited the email body), use + that verbatim instead of re-rendering from settings. When ``skip`` + is truthy, log the assignment but suppress the email entirely. + """ + screening_assignment = kwargs["screening_assignment"] + request = kwargs["request"] + skip = kwargs.get("skip", False) + custom_email_data = kwargs.get("email_data") + article = screening_assignment.article + + description = 'Screening invitation sent for "{0}" to {1}'.format( + article.title, + screening_assignment.screener.full_name(), + ) + + 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: + screening_requests_url = request.journal.site_url( + reverse("screening_requests"), + ) + context = { + "article": article, + "screening_assignment": screening_assignment, + "screening_requests_url": screening_requests_url, + } + email_data = build_email_data( + request, + "subject_screening_invitation", + "screening_invitation", + context, + ) + + log_dict = { + "level": "Info", + "action_text": description, + "types": "Screening Invitation", + "target": article, + } + core_email.send_email( + screening_assignment.screener, + email_data, + request, + article=article, + log_dict=log_dict, + ) + notify_helpers.send_slack(request, description, ["slack_editors"]) + + +def send_screening_complete(**kwargs): + """Handler for ON_SCREENING_COMPLETE. + + Notifies the managing editor on the screening assignment that the + screener has submitted their report. + """ + screening_assignment = kwargs["screening_assignment"] + request = kwargs["request"] + article = screening_assignment.article + + if not screening_assignment.editor: + return + + screening_article_url = request.journal.site_url( + reverse("screening_article", kwargs={"article_id": article.pk}), + ) + context = { + "article": article, + "screening_assignment": screening_assignment, + "screening_article_url": screening_article_url, + } + email_data = build_email_data( + request, + "subject_screening_complete", + "screening_complete", + context, + ) + + description = ( + '{0} submitted a screening report for "{1}" with recommendation {2}'.format( + screening_assignment.screener.full_name() + if screening_assignment.screener + else "A screener", + article.title, + screening_assignment.get_recommendation_display() or "(unrecorded)", + ) + ) + log_dict = { + "level": "Info", + "action_text": description, + "types": "Screening Report", + "target": article, + } + core_email.send_email( + screening_assignment.editor, + email_data, + request, + article=article, + log_dict=log_dict, + ) + notify_helpers.send_slack(request, description, ["slack_editors"]) + + +def send_screening_passed(**kwargs): + """Handler for ON_SCREENING_PASSED. + + Notifies the corresponding author that their submission has passed + screening and is moving into the next workflow stage. + """ + request = kwargs["request"] + article = kwargs["article"] + next_workflow_element = kwargs.get("next_workflow_element") + + if not article.correspondence_author: + return + + context = { + "article": article, + "next_workflow_element": next_workflow_element, + } + email_data = build_email_data( + request, + "subject_screening_passed", + "screening_passed", + context, + ) + + description = 'Screening-passed notification sent to author of "{0}"'.format( + article.title, + ) + log_dict = { + "level": "Info", + "action_text": description, + "types": "Screening Passed", + "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"]) + + +def send_screening_revisions_requested(**kwargs): + """Handler for ON_SCREENING_REVISIONS_REQUESTED. + + 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 + + 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, + "types": "Screening Revisions Requested", + "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"]) + + +def send_screening_revisions_completed(**kwargs): + """Handler for ON_SCREENING_REVISIONS_COMPLETED. + + Notifies the requesting editor that the author has submitted their + revisions so the editor can reopen a screening round. + """ + request = kwargs["request"] + revision = kwargs["screening_revision"] + article = revision.article + + if not revision.editor: + return + + article_url = request.journal.site_url( + reverse("screening_article", kwargs={"article_id": article.pk}), + ) + context = { + "article": article, + "screening_revision": revision, + "article_url": article_url, + } + email_data = build_email_data( + request, + "subject_screening_revisions_completed", + "screening_revisions_completed", + context, + ) + + description = 'Screening revisions submitted for "{0}"'.format(article.title) + log_dict = { + "level": "Info", + "action_text": description, + "types": "Screening Revisions Completed", + "target": article, + } + core_email.send_email( + revision.editor, + email_data, + request, + article=article, + 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/__init__.py b/src/screening/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/screening/tests/test_checklist.py b/src/screening/tests/test_checklist.py new file mode 100644 index 0000000000..6f15450411 --- /dev/null +++ b/src/screening/tests/test_checklist.py @@ -0,0 +1,184 @@ +__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 + +from core import logic as core_logic +from screening import logic as screening_logic, models as screening_models +from submission import models as submission_models +from utils.testing import helpers + + +class TechnicalChecklistTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.journal_one, cls.journal_two = helpers.create_journals() + cls.editor = helpers.create_user( + "checklist-editor@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.editor.is_active = True + cls.editor.save() + cls.author = helpers.create_user( + "checklist-author@example.org", + roles=["author"], + journal=cls.journal_one, + ) + cls.author.is_active = True + cls.author.save() + + # Enable screening on journal_one. + journal_workflow = cls.journal_one.workflow() + request = helpers.get_request(press=cls.press, journal=cls.journal_one) + screening_element = core_logic.handle_element_post( + journal_workflow, + "screening", + request, + ) + journal_workflow.elements.add(screening_element) + + cls.template = screening_models.TechnicalChecklistTemplate.objects.create( + journal=cls.journal_one, + name="Default Tech Check", + is_default=True, + ) + cls.template_item = ( + screening_models.TechnicalChecklistTemplateItem.objects.create( + template=cls.template, + label="Article uploaded in correct format", + order=1, + ) + ) + cls.article = submission_models.Article.objects.create( + journal=cls.journal_one, + title="Checklist Article", + stage=submission_models.STAGE_SCREENING, + owner=cls.author, + correspondence_author=cls.author, + ) + + def test_ensure_checklist_creates_from_default_template(self): + checklist = screening_logic.ensure_checklist_for_article(self.article) + self.assertIsNotNone(checklist) + self.assertEqual(checklist.items.count(), 1) + self.assertEqual( + checklist.items.first().label, + "Article uploaded in correct format", + ) + + def test_ensure_checklist_idempotent(self): + first = screening_logic.ensure_checklist_for_article(self.article) + second = screening_logic.ensure_checklist_for_article(self.article) + self.assertEqual(first, second) + self.assertEqual(self.article.technical_checklist.items.count(), 1) + + def test_ensure_checklist_returns_none_without_default_template(self): + article = submission_models.Article.objects.create( + journal=self.journal_two, + title="Other Journal Article", + stage=submission_models.STAGE_SCREENING, + owner=self.author, + correspondence_author=self.author, + ) + self.assertIsNone(screening_logic.ensure_checklist_for_article(article)) + + def test_checklist_templates_list_renders(self): + self.client.force_login(self.editor) + response = self.client.get( + reverse("screening_checklist_templates"), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Default Tech Check") + + def test_create_template(self): + self.client.force_login(self.editor) + self.client.post( + reverse("screening_checklist_templates"), + {"name": "Second Template"}, + SERVER_NAME=self.journal_one.domain, + ) + self.assertTrue( + screening_models.TechnicalChecklistTemplate.objects.filter( + journal=self.journal_one, + name="Second Template", + ).exists() + ) + + def test_add_template_item(self): + self.client.force_login(self.editor) + self.client.post( + reverse( + "edit_screening_checklist_template", + kwargs={"template_id": self.template.pk}, + ), + { + "item": "1", + "label": "Anonymised PDF supplied", + "order": "2", + }, + SERVER_NAME=self.journal_one.domain, + ) + self.template.refresh_from_db() + self.assertEqual(self.template.items.count(), 2) + + def test_toggle_checklist_item(self): + checklist = screening_logic.ensure_checklist_for_article(self.article) + item = checklist.items.first() + self.client.force_login(self.editor) + self.client.post( + reverse("toggle_checklist_item", kwargs={"item_id": item.pk}), + SERVER_NAME=self.journal_one.domain, + ) + item.refresh_from_db() + self.assertTrue(item.is_complete) + self.assertEqual(item.completed_by, self.editor) + self.assertIsNotNone(item.completed_at) + + def test_toggle_checklist_item_clears_state_on_second_press(self): + checklist = screening_logic.ensure_checklist_for_article(self.article) + item = checklist.items.first() + self.client.force_login(self.editor) + self.client.post( + reverse("toggle_checklist_item", kwargs={"item_id": item.pk}), + SERVER_NAME=self.journal_one.domain, + ) + self.client.post( + reverse("toggle_checklist_item", kwargs={"item_id": item.pk}), + SERVER_NAME=self.journal_one.domain, + ) + item.refresh_from_db() + self.assertFalse(item.is_complete) + self.assertIsNone(item.completed_by) + self.assertIsNone(item.completed_at) + + def test_save_checklist_item_comment(self): + checklist = screening_logic.ensure_checklist_for_article(self.article) + item = checklist.items.first() + self.client.force_login(self.editor) + self.client.post( + reverse("save_checklist_item_comment", kwargs={"item_id": item.pk}), + {"comment": "Confirmed."}, + SERVER_NAME=self.journal_one.domain, + ) + item.refresh_from_db() + self.assertEqual(item.comment, "Confirmed.") + + def test_checklist_panel_visible_on_screening_article_page(self): + screening_logic.open_screening_round(self.article) + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "screening_article", + kwargs={"article_id": self.article.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Technical Checklist") + self.assertContains(response, "Article uploaded in correct format") diff --git a/src/screening/tests/test_editor_management.py b/src/screening/tests/test_editor_management.py new file mode 100644 index 0000000000..b3a3341597 --- /dev/null +++ b/src/screening/tests/test_editor_management.py @@ -0,0 +1,270 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + +import datetime + +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 logic as screening_logic, models as screening_models +from screening.const import ScreeningRecommendations as SR +from submission import models as submission_models +from utils.testing import helpers + + +class ScreeningEditorManagementTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.journal_one, cls.journal_two = helpers.create_journals() + cls.editor = helpers.create_user( + "editor-mgmt@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.editor.is_active = True + cls.editor.save() + cls.screener = helpers.create_user( + "screener-mgmt@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.screener.is_active = True + cls.screener.save() + cls.other_user = helpers.create_user( + "other-mgmt@example.org", + roles=["author"], + journal=cls.journal_one, + ) + cls.other_user.is_active = True + cls.other_user.save() + cls.author = helpers.create_user( + "author-mgmt@example.org", + roles=["author"], + journal=cls.journal_one, + ) + + journal_workflow = cls.journal_one.workflow() + request = helpers.get_request(press=cls.press, journal=cls.journal_one) + screening_element = core_logic.handle_element_post( + journal_workflow, + "screening", + request, + ) + journal_workflow.elements.add(screening_element) + + cls.article = submission_models.Article.objects.create( + journal=cls.journal_one, + title="Editor Management Article", + stage=submission_models.STAGE_SCREENING, + owner=cls.author, + correspondence_author=cls.author, + ) + cls.round = screening_logic.open_screening_round(cls.article) + cls.assignment = screening_models.ScreeningAssignment.objects.create( + article=cls.article, + screener=cls.screener, + editor=cls.editor, + screening_round=cls.round, + date_due=datetime.date.today() + datetime.timedelta(days=10), + ) + + def test_editor_can_view_edit_assignment_page(self): + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "edit_screening_assignment", + kwargs={ + "article_id": self.article.pk, + "assignment_id": self.assignment.pk, + }, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Edit Screening Assignment") + + def test_editor_can_change_due_date(self): + new_due = datetime.date.today() + datetime.timedelta(days=30) + self.client.force_login(self.editor) + self.client.post( + reverse( + "edit_screening_assignment", + kwargs={ + "article_id": self.article.pk, + "assignment_id": self.assignment.pk, + }, + ), + { + "screener": str(self.screener.pk), + "date_due": new_due.isoformat(), + "anonymous_to_author": "on", + }, + SERVER_NAME=self.journal_one.domain, + ) + self.assignment.refresh_from_db() + self.assertEqual(self.assignment.date_due, new_due) + + def test_withdraw_sets_assignment_withdrawn(self): + 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.assignment.refresh_from_db() + 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( + reverse( + "withdraw_screening_assignment", + kwargs={ + "article_id": self.article.pk, + "assignment_id": self.assignment.pk, + }, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 405) + self.assignment.refresh_from_db() + self.assertIsNone(self.assignment.recommendation) + self.assertIsNone(self.assignment.date_declined) + + def test_reset_clears_completion_state(self): + self.assignment.is_complete = True + self.assignment.date_complete = timezone.now() + self.assignment.recommendation = SR.ACCEPT_FOR_PEER_REVIEW.value + self.assignment.save() + self.client.force_login(self.editor) + self.client.post( + reverse( + "reset_screening_assignment", + kwargs={ + "article_id": self.article.pk, + "assignment_id": self.assignment.pk, + }, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assignment.refresh_from_db() + self.assertFalse(self.assignment.is_complete) + self.assertIsNone(self.assignment.date_complete) + self.assertIsNone(self.assignment.recommendation) + + def test_reset_rejects_get(self): + self.assignment.is_complete = True + self.assignment.date_complete = timezone.now() + self.assignment.recommendation = SR.ACCEPT_FOR_PEER_REVIEW.value + self.assignment.save() + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "reset_screening_assignment", + kwargs={ + "article_id": self.article.pk, + "assignment_id": self.assignment.pk, + }, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 405) + self.assignment.refresh_from_db() + self.assertTrue(self.assignment.is_complete) + + def test_editor_can_submit_on_behalf_via_do_screening(self): + self.client.force_login(self.editor) + response = self.client.get( + reverse("do_screening", kwargs={"assignment_id": self.assignment.pk}), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + + def test_screener_can_still_access_do_screening(self): + self.client.force_login(self.screener) + response = self.client.get( + reverse("do_screening", kwargs={"assignment_id": self.assignment.pk}), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + + def test_other_user_cannot_access_do_screening(self): + self.client.force_login(self.other_user) + response = self.client.get( + reverse("do_screening", kwargs={"assignment_id": self.assignment.pk}), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 404) + + def test_dashboard_passes_screening_counts(self): + self.client.force_login(self.screener) + response = self.client.get( + reverse("core_dashboard"), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Screening Requests") + + def test_dashboard_accepted_count_excludes_withdrawn_after_accept(self): + """Regression: an assignment the screener accepted and that the + editor later withdrew must not be counted as an open accepted + screening on the dashboard.""" + self.assignment.accept() + self.assignment.withdraw() + self.client.force_login(self.screener) + response = self.client.get( + reverse("core_dashboard"), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual( + response.context["assigned_screenings_for_user_accepted_count"], + 0, + ) diff --git a/src/screening/tests/test_emails.py b/src/screening/tests/test_emails.py new file mode 100644 index 0000000000..de0f308db9 --- /dev/null +++ b/src/screening/tests/test_emails.py @@ -0,0 +1,138 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + +import datetime + +from django.core import mail +from django.test import TestCase +from django.urls import reverse + +from core import logic as core_logic +from screening import logic as screening_logic, models as screening_models +from submission import models as submission_models +from utils import install +from utils.testing import helpers + + +class ScreeningEmailEventTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.journal_one, cls.journal_two = helpers.create_journals() + install.update_settings(journal_object=cls.journal_one) + install.update_settings(journal_object=cls.journal_two) + cls.editor = helpers.create_user( + "screening-email-editor@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.editor.is_active = True + cls.editor.save() + cls.screener = helpers.create_user( + "screening-email-screener@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.screener.is_active = True + cls.screener.save() + cls.author = helpers.create_user( + "screening-email-author@example.org", + roles=["author"], + journal=cls.journal_one, + ) + + journal_workflow = cls.journal_one.workflow() + request = helpers.get_request(press=cls.press, journal=cls.journal_one) + screening_element = core_logic.handle_element_post( + journal_workflow, + "screening", + request, + ) + journal_workflow.elements.add(screening_element) + for element in journal_workflow.elements.exclude( + element_name="editor_assignment", + ): + element.order += 1 + element.save() + screening_element.order = 1 + screening_element.save() + + cls.article = submission_models.Article.objects.create( + journal=cls.journal_one, + title="Email Event Article", + stage=submission_models.STAGE_SCREENING, + owner=cls.author, + correspondence_author=cls.author, + ) + cls.round = screening_logic.open_screening_round(cls.article) + + def setUp(self): + mail.outbox = [] + + def test_inviting_screener_sends_invitation_email(self): + self.client.force_login(self.editor) + response = self.client.post( + reverse( + "add_screening_assignment", + kwargs={"article_id": self.article.pk, "round_id": self.round.pk}, + ), + { + "screener": str(self.screener.pk), + "date_due": ( + datetime.date.today() + datetime.timedelta(days=14) + ).isoformat(), + "anonymous_to_author": "on", + }, + SERVER_NAME=self.journal_one.domain, + ) + # The add view now redirects to the notification preview; the + # email only goes out when the editor confirms on the preview. + self.assertEqual(response.status_code, 302) + assignment = screening_models.ScreeningAssignment.objects.filter( + article=self.article, + screener=self.screener, + ).first() + self.assertIsNotNone(assignment) + self.client.post( + reverse( + "screening_assignment_notification", + kwargs={ + "article_id": self.article.pk, + "assignment_id": assignment.pk, + }, + ), + { + "subject": "Screening Invitation", + "body": "Please screen this article.", + }, + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(len(mail.outbox), 1) + self.assertIn(self.screener.email, mail.outbox[0].to) + self.assertIn("Screening Invitation", mail.outbox[0].subject) + + def test_screening_completion_notifies_editor(self): + assignment = screening_models.ScreeningAssignment.objects.create( + article=self.article, + screener=self.screener, + editor=self.editor, + screening_round=self.round, + date_due=datetime.date.today() + datetime.timedelta(days=10), + ) + self.client.force_login(self.screener) + self.client.post( + reverse("do_screening", kwargs={"assignment_id": assignment.pk}), + { + "recommendation": "accept_for_peer_review", + }, + SERVER_NAME=self.journal_one.domain, + ) + # mail.outbox accumulates messages across the test client's POST; + # the screening-complete email is the most recently sent. + self.assertTrue(any(self.editor.email in message.to for message in mail.outbox)) + completion_email = next( + message for message in mail.outbox if self.editor.email in message.to + ) + self.assertIn("Screening Report Complete", completion_email.subject) diff --git a/src/screening/tests/test_flows.py b/src/screening/tests/test_flows.py new file mode 100644 index 0000000000..bd52b8f7bd --- /dev/null +++ b/src/screening/tests/test_flows.py @@ -0,0 +1,456 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + +import datetime + +from django.test import TestCase +from django.urls import reverse + +from core import logic as core_logic, models as core_models +from review import models as review_models +from screening import logic as screening_logic, models as screening_models +from submission import models as submission_models +from utils.testing import helpers + + +class ScreeningFlowTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.journal_one, cls.journal_two = helpers.create_journals() + cls.editor = helpers.create_user( + "screening-flow-editor@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.editor.is_active = True + cls.editor.save() + cls.section_editor = helpers.create_user( + "screening-flow-section-editor@example.org", + roles=["section-editor"], + journal=cls.journal_one, + ) + cls.outsider = helpers.create_user( + "screening-flow-outsider@example.org", + roles=["author"], + journal=cls.journal_one, + ) + cls.author = helpers.create_user( + "screening-flow-author@example.org", + roles=["author"], + journal=cls.journal_one, + ) + + # Enable screening on journal_one's workflow and position it + # between Editor Assignment and Review so that "next stage" from + # editor assignment resolves to Screening. + journal_workflow = cls.journal_one.workflow() + request = helpers.get_request(press=cls.press, journal=cls.journal_one) + screening_element = core_logic.handle_element_post( + journal_workflow, + "screening", + request, + ) + journal_workflow.elements.add(screening_element) + for element in journal_workflow.elements.exclude( + element_name="editor_assignment", + ): + element.order += 1 + element.save() + screening_element.order = 1 + screening_element.save() + + cls.article = submission_models.Article.objects.create( + journal=cls.journal_one, + title="Article for Screening Flow", + stage=submission_models.STAGE_UNASSIGNED, + owner=cls.author, + correspondence_author=cls.author, + ) + + def test_open_screening_round_starts_at_one(self): + new_round = screening_logic.open_screening_round(self.article) + self.assertEqual(new_round.round_number, 1) + + def test_open_screening_round_increments(self): + screening_logic.open_screening_round(self.article) + second = screening_logic.open_screening_round(self.article) + self.assertEqual(second.round_number, 2) + + def test_eligible_screeners_includes_editorial_team(self): + pool = screening_logic.eligible_screeners(self.journal_one) + pool_ids = set(pool.values_list("pk", flat=True)) + self.assertIn(self.editor.pk, pool_ids) + self.assertIn(self.section_editor.pk, pool_ids) + self.assertNotIn(self.outsider.pk, pool_ids) + + def test_eligible_screeners_can_exclude_users(self): + pool = screening_logic.eligible_screeners( + self.journal_one, + exclude_user_ids=[self.editor.pk], + ) + pool_ids = set(pool.values_list("pk", flat=True)) + self.assertNotIn(self.editor.pk, pool_ids) + self.assertIn(self.section_editor.pk, pool_ids) + + def test_eligible_screeners_uses_pool_when_groups_configured(self): + """When ScreeningPool.groups is non-empty, only members of those + editorial groups should be returned — even if there are other + editors on the journal.""" + group = core_models.EditorialGroup.objects.create( + name="Screening Pool Group", + journal=self.journal_one, + sequence=1, + ) + group_member = helpers.create_user( + "screening-pool-member@example.org", + roles=["author"], + journal=self.journal_one, + ) + core_models.EditorialGroupMember.objects.create( + group=group, + user=group_member, + sequence=1, + ) + pool, _ = screening_models.ScreeningPool.objects.get_or_create( + journal=self.journal_one, + ) + pool.groups.add(group) + + pool_qs = screening_logic.eligible_screeners(self.journal_one) + pool_ids = set(pool_qs.values_list("pk", flat=True)) + self.assertIn(group_member.pk, pool_ids) + self.assertNotIn(self.editor.pk, pool_ids) + self.assertNotIn(self.section_editor.pk, pool_ids) + + def test_eligible_screeners_falls_back_when_pool_empty(self): + """A ScreeningPool with no groups should not constrain the pool + — the editor/section-editor role fallback applies.""" + screening_models.ScreeningPool.objects.get_or_create( + journal=self.journal_one, + ) + pool_qs = screening_logic.eligible_screeners(self.journal_one) + pool_ids = set(pool_qs.values_list("pk", flat=True)) + self.assertIn(self.editor.pk, pool_ids) + self.assertIn(self.section_editor.pk, pool_ids) + + def test_assign_screener_creates_assignment_with_anonymity_flags(self): + screening_round = screening_logic.open_screening_round(self.article) + assignment, created = screening_logic.assign_screener( + article=self.article, + screener=self.section_editor, + editor=self.editor, + screening_round=screening_round, + date_due=datetime.date.today() + datetime.timedelta(days=10), + anonymous_to_author=True, + anonymous_to_coscreeners=True, + ) + self.assertTrue(created) + self.assertTrue(assignment.anonymous_to_author) + self.assertTrue(assignment.anonymous_to_coscreeners) + + def test_assign_screener_is_idempotent_per_round(self): + screening_round = screening_logic.open_screening_round(self.article) + screening_logic.assign_screener( + article=self.article, + screener=self.section_editor, + editor=self.editor, + screening_round=screening_round, + date_due=datetime.date.today() + datetime.timedelta(days=10), + ) + _, created = screening_logic.assign_screener( + article=self.article, + screener=self.section_editor, + editor=self.editor, + screening_round=screening_round, + date_due=datetime.date.today() + datetime.timedelta(days=10), + ) + self.assertFalse(created) + + def test_move_to_next_stage_routes_to_screening_when_enabled(self): + review_models.EditorAssignment.objects.create( + article=self.article, + editor=self.editor, + editor_type="editor", + ) + self.client.force_login(self.editor) + response = self.client.post( + reverse( + "editor_assignment_move_to_next_stage", + kwargs={"article_id": self.article.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.article.refresh_from_db() + self.assertEqual(self.article.stage, submission_models.STAGE_SCREENING) + self.assertTrue( + screening_models.ScreeningRound.objects.filter( + article=self.article, + ).exists() + ) + + def test_move_to_next_stage_routes_to_review_when_screening_disabled(self): + # journal_two has no screening element; default workflow goes + # editor_assignment -> review. + article = submission_models.Article.objects.create( + journal=self.journal_two, + title="Article without screening", + stage=submission_models.STAGE_UNASSIGNED, + owner=self.author, + correspondence_author=self.author, + ) + editor_on_two = helpers.create_user( + "editor-on-two@example.org", + roles=["editor"], + journal=self.journal_two, + ) + editor_on_two.is_active = True + editor_on_two.save() + review_models.EditorAssignment.objects.create( + article=article, + editor=editor_on_two, + editor_type="editor", + ) + self.client.force_login(editor_on_two) + self.client.post( + reverse( + "editor_assignment_move_to_next_stage", + kwargs={"article_id": article.pk}, + ), + SERVER_NAME=self.journal_two.domain, + ) + article.refresh_from_db() + self.assertEqual(article.stage, submission_models.STAGE_ASSIGNED) + self.assertTrue( + review_models.ReviewRound.objects.filter(article=article).exists() + ) + + def test_move_to_next_stage_requires_editor_assignment(self): + self.client.force_login(self.editor) + self.client.post( + reverse( + "editor_assignment_move_to_next_stage", + kwargs={"article_id": self.article.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.article.refresh_from_db() + self.assertEqual(self.article.stage, submission_models.STAGE_UNASSIGNED) + + def test_add_screening_round_url_creates_round(self): + self.article.stage = submission_models.STAGE_SCREENING + self.article.save() + self.client.force_login(self.editor) + self.client.post( + reverse("add_screening_round", kwargs={"article_id": self.article.pk}), + SERVER_NAME=self.journal_one.domain, + ) + self.assertTrue( + screening_models.ScreeningRound.objects.filter( + article=self.article, + ).exists() + ) + + def test_screening_article_view_renders(self): + self.article.stage = submission_models.STAGE_SCREENING + self.article.save() + screening_logic.open_screening_round(self.article) + self.client.force_login(self.editor) + response = self.client.get( + reverse("screening_article", kwargs={"article_id": self.article.pk}), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Round 1") + self.assertContains(response, "Screeners") + + def test_add_assignment_view_renders_candidate_table(self): + """The invitation page renders a table of editorial-team candidates + with one radio per row, and explains the screener pool in plain + language.""" + self.article.stage = submission_models.STAGE_SCREENING + self.article.save() + screening_round = screening_logic.open_screening_round(self.article) + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "add_screening_assignment", + kwargs={ + "article_id": self.article.pk, + "round_id": screening_round.pk, + }, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + # callout text identifying the pool source + self.assertContains(response, "Screener Pool") + # at least one radio with name=screener (the section editor) + self.assertContains( + response, + 'name="screener"', + ) + self.assertContains(response, self.section_editor.email) + + def test_add_assignment_view_excludes_already_invited(self): + """A screener already on the round is dropped from the candidate + list rendered by the invitation page.""" + self.article.stage = submission_models.STAGE_SCREENING + self.article.save() + screening_round = screening_logic.open_screening_round(self.article) + screening_logic.assign_screener( + article=self.article, + screener=self.section_editor, + editor=self.editor, + screening_round=screening_round, + date_due=datetime.date.today() + datetime.timedelta(days=10), + ) + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "add_screening_assignment", + kwargs={ + "article_id": self.article.pk, + "round_id": screening_round.pk, + }, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + # The section editor is already on the round so should not appear + # in the candidate radio column. + self.assertNotContains( + response, + 'value="{0}"'.format(self.section_editor.pk), + ) + # The remaining eligible editor still appears. + self.assertContains( + response, + 'value="{0}"'.format(self.editor.pk), + ) + + def test_add_assignment_post_creates_assignment(self): + """POSTing the invitation form with a candidate pk and the options + creates a ScreeningAssignment with the chosen options.""" + self.article.stage = submission_models.STAGE_SCREENING + self.article.save() + screening_round = screening_logic.open_screening_round(self.article) + self.client.force_login(self.editor) + response = self.client.post( + reverse( + "add_screening_assignment", + kwargs={ + "article_id": self.article.pk, + "round_id": screening_round.pk, + }, + ), + data={ + "screener": str(self.section_editor.pk), + "date_due": ( + datetime.date.today() + datetime.timedelta(days=14) + ).isoformat(), + "anonymous_to_author": "on", + }, + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue( + screening_models.ScreeningAssignment.objects.filter( + article=self.article, + screener=self.section_editor, + screening_round=screening_round, + anonymous_to_author=True, + ).exists() + ) + + def test_move_to_next_stage_from_editor_assignment_creates_workflow_log(self): + """Regression: moving an article from editor_assignment into the + next stage must create a WorkflowLog entry naming the element + being entered. On journal_one screening sits immediately after + editor_assignment, so the log entry must point at the screening + element. Without it, the Screening stage never appears in the + article timeline.""" + review_models.EditorAssignment.objects.create( + article=self.article, + editor=self.editor, + editor_type="editor", + ) + self.client.force_login(self.editor) + self.client.post( + reverse( + "editor_assignment_move_to_next_stage", + kwargs={"article_id": self.article.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + log_entry = core_models.WorkflowLog.objects.filter( + article=self.article, + element__element_name="screening", + ).first() + self.assertIsNotNone(log_entry) + self.assertEqual(log_entry.user, self.editor) + + def test_move_to_next_stage_from_editor_assignment_logs_review_when_screening_disabled( + self, + ): + """Regression: the same delegation must also produce a + WorkflowLog entry on journals where screening is not configured, + in which case the element entered is review.""" + article = submission_models.Article.objects.create( + journal=self.journal_two, + title="Article without screening (log)", + stage=submission_models.STAGE_UNASSIGNED, + owner=self.author, + correspondence_author=self.author, + ) + editor_on_two = helpers.create_user( + "editor-on-two-log@example.org", + roles=["editor"], + journal=self.journal_two, + ) + editor_on_two.is_active = True + editor_on_two.save() + review_models.EditorAssignment.objects.create( + article=article, + editor=editor_on_two, + editor_type="editor", + ) + self.client.force_login(editor_on_two) + self.client.post( + reverse( + "editor_assignment_move_to_next_stage", + kwargs={"article_id": article.pk}, + ), + SERVER_NAME=self.journal_two.domain, + ) + self.assertTrue( + core_models.WorkflowLog.objects.filter( + article=article, + element__element_name="review", + ).exists() + ) + + def test_screening_move_to_next_stage_creates_workflow_log(self): + """Regression: moving an article out of screening must create a + WorkflowLog entry naming the next element (review on + journal_one), so the transition is visible in the article + timeline.""" + self.article.stage = submission_models.STAGE_SCREENING + self.article.save() + screening_logic.open_screening_round(self.article) + self.client.force_login(self.editor) + self.client.post( + reverse( + "screening_move_to_next_stage", + kwargs={"article_id": self.article.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + log_entry = core_models.WorkflowLog.objects.filter( + article=self.article, + element__element_name="review", + ).first() + self.assertIsNotNone(log_entry) + self.assertEqual(log_entry.user, self.editor) diff --git a/src/screening/tests/test_forms_manager.py b/src/screening/tests/test_forms_manager.py new file mode 100644 index 0000000000..0f21aacaf7 --- /dev/null +++ b/src/screening/tests/test_forms_manager.py @@ -0,0 +1,218 @@ +__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 + +from screening import models as screening_models +from utils.testing import helpers + + +class ScreeningFormsManagerTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.journal_one, cls.journal_two = helpers.create_journals() + cls.editor = helpers.create_user( + "forms-manager-editor@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.editor.is_active = True + cls.editor.save() + cls.author = helpers.create_user( + "forms-manager-author@example.org", + roles=["author"], + journal=cls.journal_one, + ) + cls.author.is_active = True + cls.author.save() + cls.screening_form = screening_models.ScreeningForm.objects.create( + journal=cls.journal_one, + name="ILR Default", + intro="Welcome", + thanks="Thanks", + ) + + def test_screening_forms_list_renders_for_editor(self): + self.client.force_login(self.editor) + response = self.client.get( + reverse("screening_forms"), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "ILR Default") + + def test_screening_forms_list_blocks_non_editor(self): + self.client.force_login(self.author) + response = self.client.get( + reverse("screening_forms"), + SERVER_NAME=self.journal_one.domain, + ) + self.assertNotEqual(response.status_code, 200) + + def test_create_screening_form(self): + self.client.force_login(self.editor) + self.client.post( + reverse("screening_forms"), + { + "name": "Internal Review Form", + "intro": "

Please read carefully.

", + "thanks": "

Thank you.

", + }, + SERVER_NAME=self.journal_one.domain, + ) + self.assertTrue( + screening_models.ScreeningForm.objects.filter( + journal=self.journal_one, + name="Internal Review Form", + ).exists() + ) + + def test_soft_delete_screening_form(self): + self.client.force_login(self.editor) + self.client.post( + reverse("screening_forms"), + {"delete": str(self.screening_form.pk)}, + SERVER_NAME=self.journal_one.domain, + ) + self.screening_form.refresh_from_db() + self.assertTrue(self.screening_form.deleted) + + def test_edit_screening_form_metadata(self): + self.client.force_login(self.editor) + self.client.post( + reverse("edit_screening_form", kwargs={"form_id": self.screening_form.pk}), + { + "screening_form": "1", + "name": "Renamed Form", + "intro": "Updated intro", + "thanks": "Updated thanks", + }, + SERVER_NAME=self.journal_one.domain, + ) + self.screening_form.refresh_from_db() + self.assertEqual(self.screening_form.name, "Renamed Form") + + def test_add_element_to_form(self): + self.client.force_login(self.editor) + self.client.post( + reverse("edit_screening_form", kwargs={"form_id": self.screening_form.pk}), + { + "element": "1", + "name": "Recommendation Notes", + "kind": "textarea", + "required": "on", + "order": "1", + "default_visibility": "on", + }, + SERVER_NAME=self.journal_one.domain, + ) + self.screening_form.refresh_from_db() + self.assertEqual(self.screening_form.elements.count(), 1) + self.assertEqual( + self.screening_form.elements.first().name, + "Recommendation Notes", + ) + + def test_delete_element_from_form(self): + element = screening_models.ScreeningFormElement.objects.create( + name="Temp Element", + kind="text", + required=False, + order=2, + ) + self.screening_form.elements.add(element) + self.client.force_login(self.editor) + self.client.post( + reverse("edit_screening_form", kwargs={"form_id": self.screening_form.pk}), + {"delete": str(element.pk)}, + SERVER_NAME=self.journal_one.domain, + ) + self.assertFalse( + screening_models.ScreeningFormElement.objects.filter(pk=element.pk).exists() + ) + + def test_edit_element_via_element_url(self): + element = screening_models.ScreeningFormElement.objects.create( + name="Old Name", + kind="text", + required=False, + order=3, + ) + self.screening_form.elements.add(element) + self.client.force_login(self.editor) + self.client.post( + reverse( + "edit_screening_form_element", + kwargs={"form_id": self.screening_form.pk, "element_id": element.pk}, + ), + { + "element": "1", + "name": "Renamed Element", + "kind": "text", + "order": "3", + "default_visibility": "on", + }, + SERVER_NAME=self.journal_one.domain, + ) + element.refresh_from_db() + self.assertEqual(element.name, "Renamed Element") + + def test_cross_journal_element_lookup_is_404(self): + """An element belonging to journal_two's form must not be loadable + via journal_one's edit_screening_form URL even if its pk is known.""" + other_form = screening_models.ScreeningForm.objects.create( + journal=self.journal_two, + name="Other Journal Form", + ) + other_element = screening_models.ScreeningFormElement.objects.create( + name="Foreign Element", + kind="text", + required=False, + order=1, + ) + other_form.elements.add(other_element) + + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "edit_screening_form_element", + kwargs={ + "form_id": self.screening_form.pk, + "element_id": other_element.pk, + }, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 404) + + def test_cross_journal_element_delete_is_404(self): + """Posting a delete for an element on another journal's form must + not delete it via journal_one's URL.""" + other_form = screening_models.ScreeningForm.objects.create( + journal=self.journal_two, + name="Other Journal Form 2", + ) + other_element = screening_models.ScreeningFormElement.objects.create( + name="Foreign Element 2", + kind="text", + required=False, + order=1, + ) + other_form.elements.add(other_element) + + self.client.force_login(self.editor) + response = self.client.post( + reverse("edit_screening_form", kwargs={"form_id": self.screening_form.pk}), + {"delete": str(other_element.pk)}, + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 404) + self.assertTrue( + screening_models.ScreeningFormElement.objects.filter( + pk=other_element.pk + ).exists() + ) diff --git a/src/screening/tests/test_managing_editor.py b/src/screening/tests/test_managing_editor.py new file mode 100644 index 0000000000..77dfb7e80e --- /dev/null +++ b/src/screening/tests/test_managing_editor.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" + +import datetime + +from django.test import TestCase +from django.urls import reverse + +from core import logic as core_logic, workflow as core_workflow +from review import models as review_models +from screening import logic as screening_logic, models as screening_models +from submission import models as submission_models +from utils.testing import helpers + + +class ScreeningManagingEditorTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.journal_one, cls.journal_two = helpers.create_journals() + cls.editor = helpers.create_user( + "screening-me-editor@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.editor.is_active = True + cls.editor.save() + cls.author = helpers.create_user( + "screening-me-author@example.org", + roles=["author"], + journal=cls.journal_one, + ) + # Enable screening on journal_one, placed between editor_assignment + # and review so that "next stage" from screening resolves to review. + journal_workflow = cls.journal_one.workflow() + request = helpers.get_request(press=cls.press, journal=cls.journal_one) + screening_element = core_logic.handle_element_post( + journal_workflow, + "screening", + request, + ) + journal_workflow.elements.add(screening_element) + for element in journal_workflow.elements.exclude( + element_name="editor_assignment", + ): + element.order += 1 + element.save() + screening_element.order = 1 + screening_element.save() + + cls.article = submission_models.Article.objects.create( + journal=cls.journal_one, + title="Screening Managing Editor Article", + stage=submission_models.STAGE_SCREENING, + owner=cls.author, + correspondence_author=cls.author, + ) + screening_logic.open_screening_round(cls.article) + + def test_get_next_workflow_element_after_screening_is_review(self): + next_element = core_workflow.get_next_workflow_element( + self.journal_one, + "screening", + ) + self.assertIsNotNone(next_element) + self.assertEqual(next_element.element_name, "review") + + def test_get_next_workflow_element_returns_none_when_no_successor(self): + # On journal_two, screening is not in the workflow at all. + result = core_workflow.get_next_workflow_element( + self.journal_two, + "screening", + ) + self.assertIsNone(result) + + def test_screening_move_to_next_stage_routes_to_review(self): + self.client.force_login(self.editor) + self.client.post( + reverse( + "screening_move_to_next_stage", + kwargs={"article_id": self.article.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.article.refresh_from_db() + self.assertEqual(self.article.stage, submission_models.STAGE_ASSIGNED) + self.assertTrue( + review_models.ReviewRound.objects.filter(article=self.article).exists() + ) + + 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.post( + reverse( + "screening_move_to_next_stage", + kwargs={"article_id": self.article.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + 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( + reverse( + "screening_article", + kwargs={"article_id": self.article.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Move to Review") + self.assertContains(response, "Reject Article") diff --git a/src/screening/tests/test_models.py b/src/screening/tests/test_models.py new file mode 100644 index 0000000000..519a272951 --- /dev/null +++ b/src/screening/tests/test_models.py @@ -0,0 +1,276 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + +import datetime + +from django.test import TestCase + +from screening import models as screening_models +from submission import models as submission_models +from utils.testing import helpers + + +class ScreeningModelTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.journal_one, cls.journal_two = helpers.create_journals() + cls.author = helpers.create_user( + "screening-author@example.org", + roles=["author"], + journal=cls.journal_one, + ) + cls.screener_one = helpers.create_user( + "screener-one@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.screener_two = helpers.create_user( + "screener-two@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.editor = helpers.create_user( + "screening-editor@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.article = submission_models.Article.objects.create( + journal=cls.journal_one, + title="A Screening Test Article", + stage=submission_models.STAGE_SCREENING, + owner=cls.author, + correspondence_author=cls.author, + ) + cls.round = screening_models.ScreeningRound.objects.create( + article=cls.article, + round_number=1, + ) + cls.assignment_anonymous = screening_models.ScreeningAssignment.objects.create( + article=cls.article, + screener=cls.screener_one, + editor=cls.editor, + screening_round=cls.round, + date_due=datetime.date.today() + datetime.timedelta(days=7), + anonymous_to_author=True, + anonymous_to_coscreeners=False, + ) + cls.assignment_open = screening_models.ScreeningAssignment.objects.create( + article=cls.article, + screener=cls.screener_two, + editor=cls.editor, + screening_round=cls.round, + date_due=datetime.date.today() + datetime.timedelta(days=7), + anonymous_to_author=False, + anonymous_to_coscreeners=False, + ) + + def test_screening_round_str(self): + self.assertIn("round_number: 1", str(self.round)) + + def test_screening_round_latest_article_round(self): + latest = screening_models.ScreeningRound.latest_article_round(self.article) + self.assertEqual(latest, self.round) + + def test_screening_round_unique_per_article_number(self): + with self.assertRaises(Exception): + screening_models.ScreeningRound.objects.create( + article=self.article, + round_number=1, + ) + + def test_screener_display_hidden_from_author_when_anonymous(self): + self.assertEqual( + self.assignment_anonymous.screener_display(viewer=self.author), + "Anonymous screener", + ) + + def test_screener_display_visible_to_author_when_not_anonymous(self): + self.assertEqual( + self.assignment_open.screener_display(viewer=self.author), + self.screener_two.full_name(), + ) + + def test_screener_display_visible_to_editor_regardless_of_flag(self): + self.assertEqual( + self.assignment_anonymous.screener_display(viewer=self.editor), + self.screener_one.full_name(), + ) + + def test_screener_display_hidden_from_coscreener_when_flag_set(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), + anonymous_to_author=False, + anonymous_to_coscreeners=True, + ) + coscreener_assignment = screening_models.ScreeningAssignment.objects.create( + article=self.article, + screener=self.screener_two, + editor=self.editor, + screening_round=self.round, + date_due=datetime.date.today() + datetime.timedelta(days=7), + anonymous_to_author=False, + anonymous_to_coscreeners=False, + ) + self.assertEqual( + assignment.screener_display(viewer=self.screener_two), + "Anonymous screener", + ) + self.assertEqual( + coscreener_assignment.screener_display(viewer=self.screener_one), + self.screener_two.full_name(), + ) + + def test_screening_assignment_is_late(self): + late_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=1), + ) + self.assertTrue(late_assignment.is_late) + self.assertFalse(self.assignment_open.is_late) + + def test_screening_form_str(self): + form = screening_models.ScreeningForm.objects.create( + journal=self.journal_one, + name="Default Screening Form", + intro="Welcome", + thanks="Thank you", + ) + self.assertEqual(str(form), "Default Screening Form") + + def test_screening_revision_request_str(self): + request = screening_models.ScreeningRevisionRequest.objects.create( + article=self.article, + editor=self.editor, + 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/tests/test_revisions.py b/src/screening/tests/test_revisions.py new file mode 100644 index 0000000000..525107bbed --- /dev/null +++ b/src/screening/tests/test_revisions.py @@ -0,0 +1,443 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + +import datetime + +from django.core import mail +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 +from submission import models as submission_models +from utils.testing import helpers + + +class ScreeningRevisionsTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.journal_one, cls.journal_two = helpers.create_journals() + cls.editor = helpers.create_user( + "revisions-editor@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.editor.is_active = True + cls.editor.save() + cls.author = helpers.create_user( + "revisions-author@example.org", + roles=["author"], + journal=cls.journal_one, + ) + cls.author.is_active = True + cls.author.save() + + journal_workflow = cls.journal_one.workflow() + request = helpers.get_request(press=cls.press, journal=cls.journal_one) + screening_element = core_logic.handle_element_post( + journal_workflow, + "screening", + request, + ) + journal_workflow.elements.add(screening_element) + + cls.article = submission_models.Article.objects.create( + journal=cls.journal_one, + title="Revisions Test Article", + stage=submission_models.STAGE_SCREENING, + owner=cls.author, + correspondence_author=cls.author, + ) + + def setUp(self): + mail.outbox = [] + + def test_editor_can_open_request_revisions_page(self): + 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") + 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) + due = datetime.date.today() + datetime.timedelta(days=14) + self.client.post( + reverse( + "request_screening_revisions", + kwargs={"article_id": self.article.pk}, + ), + { + "type": "minor_revisions", + "editor_note": "

Please clarify section 3.

", + "date_due": due.isoformat(), + }, + SERVER_NAME=self.journal_one.domain, + ) + revision = screening_models.ScreeningRevisionRequest.objects.get( + article=self.article, + ) + self.assertEqual(revision.editor, self.editor) + 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( + 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, + ) + 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, + editor=self.editor, + date_due=datetime.date.today() + datetime.timedelta(days=14), + ) + stranger = helpers.create_user( + "stranger-revisions@example.org", + roles=["author"], + journal=self.journal_one, + ) + stranger.is_active = True + stranger.save() + self.client.force_login(stranger) + response = self.client.get( + reverse( + "do_screening_revisions", + kwargs={"revision_id": revision.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 404) + + def test_author_submits_revisions_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), + ) + self.client.force_login(self.author) + self.client.post( + reverse( + "do_screening_revisions", + kwargs={"revision_id": revision.pk}, + ), + { + "author_note": "Section 3 has been clarified.", + "submit": "1", + }, + SERVER_NAME=self.journal_one.domain, + ) + revision.refresh_from_db() + self.assertIsNotNone(revision.date_completed) + self.assertTrue( + screening_models.ScreeningRound.objects.filter( + article=self.article, + ).exists() + ) + + def test_author_completion_emails_editor(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": "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, + 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.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) + self.assertIn( + reverse("view_screening_revision", kwargs={"revision_id": revision.pk}), + response.url, + ) + + def test_editor_can_view_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="Please tidy section 3.", + ) + self.client.force_login(self.editor) + response = self.client.get( + reverse( + "view_screening_revision", + kwargs={"revision_id": revision.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + 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_scaffold.py b/src/screening/tests/test_scaffold.py new file mode 100644 index 0000000000..0becf988b9 --- /dev/null +++ b/src/screening/tests/test_scaffold.py @@ -0,0 +1,88 @@ +__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 + +from core import logic as core_logic, models as core_models, workflow +from submission import models as submission_models +from utils.testing import helpers + + +class ScreeningScaffoldTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.journal_one, cls.journal_two = helpers.create_journals() + + def test_stage_screening_constant_is_defined(self): + self.assertEqual(submission_models.STAGE_SCREENING, "Screening") + + def test_screening_in_element_stages(self): + self.assertEqual( + workflow.ELEMENT_STAGES["screening"], + [submission_models.STAGE_SCREENING], + ) + + def test_screening_in_stages_elements(self): + self.assertEqual( + workflow.STAGES_ELEMENTS[submission_models.STAGE_SCREENING], + "screening", + ) + + def test_screening_in_base_elements(self): + screening_entry = next( + entry for entry in core_models.BASE_ELEMENTS if entry["name"] == "screening" + ) + self.assertEqual(screening_entry["stage"], submission_models.STAGE_SCREENING) + self.assertEqual(screening_entry["handshake_url"], "screening_list") + self.assertEqual(screening_entry["jump_url"], "screening_article") + + def test_screening_is_not_in_default_workflow(self): + names = [ + element.element_name + for element in self.journal_one.workflow().elements.all() + ] + self.assertNotIn("screening", names) + + def test_screening_is_available_to_add(self): + available = core_logic.get_available_elements(self.journal_one.workflow()) + names = [element["name"] for element in available] + self.assertIn("screening", names) + + def test_screening_can_be_added_to_workflow(self): + journal_workflow = self.journal_one.workflow() + request = helpers.get_request(press=self.press, journal=self.journal_one) + element = core_logic.handle_element_post( + journal_workflow, + "screening", + request, + ) + self.assertIsNotNone(element) + journal_workflow.elements.add(element) + self.assertTrue(self.journal_one.element_in_workflow("screening")) + + def test_screening_list_url_resolves(self): + path = reverse("screening_list") + self.assertIn("/screening/", path) + + def test_screening_article_url_resolves(self): + path = reverse("screening_article", kwargs={"article_id": 1}) + self.assertIn("/screening/article/1/", path) + + def test_screening_list_404s_when_element_not_enabled(self): + editor = helpers.create_user( + "screening-editor@example.org", + roles=["editor"], + journal=self.journal_one, + ) + editor.is_active = True + editor.save() + self.client.force_login(editor) + response = self.client.get( + reverse("screening_list"), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 404) diff --git a/src/screening/tests/test_screener_flows.py b/src/screening/tests/test_screener_flows.py new file mode 100644 index 0000000000..7aff379476 --- /dev/null +++ b/src/screening/tests/test_screener_flows.py @@ -0,0 +1,214 @@ +__copyright__ = "Copyright 2026 Birkbeck, University of London" +__author__ = "Open Library of Humanities" +__license__ = "AGPL v3" +__maintainer__ = "Open Library of Humanities" + +import datetime + +from django.test import TestCase +from django.urls import reverse + +from screening import logic as screening_logic, models as screening_models +from submission import models as submission_models +from utils.testing import helpers + + +class ScreenerFlowTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.press = helpers.create_press() + cls.journal_one, cls.journal_two = helpers.create_journals() + cls.screener = helpers.create_user( + "screener-flows@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.screener.is_active = True + cls.screener.save() + cls.other_user = helpers.create_user( + "other-flows@example.org", + roles=["author"], + journal=cls.journal_one, + ) + cls.other_user.is_active = True + cls.other_user.save() + cls.editor = helpers.create_user( + "editor-flows@example.org", + roles=["editor"], + journal=cls.journal_one, + ) + cls.author = helpers.create_user( + "author-flows@example.org", + roles=["author"], + journal=cls.journal_one, + ) + cls.article = submission_models.Article.objects.create( + journal=cls.journal_one, + title="Screener-side Flow Article", + stage=submission_models.STAGE_SCREENING, + owner=cls.author, + correspondence_author=cls.author, + ) + cls.round = screening_models.ScreeningRound.objects.create( + article=cls.article, + round_number=1, + ) + cls.assignment = screening_models.ScreeningAssignment.objects.create( + article=cls.article, + screener=cls.screener, + editor=cls.editor, + screening_round=cls.round, + date_due=datetime.date.today() + datetime.timedelta(days=10), + ) + cls.screening_form = screening_models.ScreeningForm.objects.create( + journal=cls.journal_one, + name="Default Screening Form", + intro="", + thanks="", + ) + cls.assignment.form = cls.screening_form + cls.assignment.save() + cls.form_element = screening_models.ScreeningFormElement.objects.create( + name="General comments", + kind="textarea", + required=True, + order=1, + ) + cls.screening_form.elements.add(cls.form_element) + + def test_pending_list_visible_to_screener(self): + self.client.force_login(self.screener) + response = self.client.get( + reverse("screening_requests"), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.article.title) + + def test_accept_records_date_accepted(self): + self.client.force_login(self.screener) + self.client.post( + reverse( + "accept_screening_request", + kwargs={"assignment_id": self.assignment.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assignment.refresh_from_db() + self.assertIsNotNone(self.assignment.date_accepted) + + def test_decline_records_date_declined(self): + self.client.force_login(self.screener) + self.client.post( + reverse( + "decline_screening_request", + kwargs={"assignment_id": self.assignment.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assignment.refresh_from_db() + self.assertIsNotNone(self.assignment.date_declined) + + def test_decline_blocked_after_complete(self): + self.assignment.is_complete = True + self.assignment.save() + self.client.force_login(self.screener) + self.client.post( + reverse( + "decline_screening_request", + kwargs={"assignment_id": self.assignment.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assignment.refresh_from_db() + self.assertIsNone(self.assignment.date_declined) + + def test_other_user_cannot_access_assignment(self): + self.client.force_login(self.other_user) + response = self.client.get( + reverse( + "do_screening", + kwargs={"assignment_id": self.assignment.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 404) + + def test_do_screening_get_renders(self): + self.client.force_login(self.screener) + response = self.client.get( + reverse( + "do_screening", + kwargs={"assignment_id": self.assignment.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Recommendation") + + def test_do_screening_post_completes_assignment(self): + self.client.force_login(self.screener) + response = self.client.post( + reverse( + "do_screening", + kwargs={"assignment_id": self.assignment.pk}, + ), + { + str(self.form_element.pk): "Looks suitable for peer review.", + "recommendation": "accept_for_peer_review", + "suggested_reviewers": "Dr. Smith", + "comments_for_editor": "Strong submission.", + }, + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 302) + self.assignment.refresh_from_db() + self.assertTrue(self.assignment.is_complete) + self.assertEqual( + self.assignment.recommendation, + "accept_for_peer_review", + ) + self.assertIsNotNone(self.assignment.date_complete) + + def test_do_screening_blocks_declined_assignment(self): + self.assignment.date_declined = datetime.datetime.now() + self.assignment.save() + self.client.force_login(self.screener) + response = self.client.get( + reverse( + "do_screening", + kwargs={"assignment_id": self.assignment.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 404) + + def test_thanks_visible_to_screener(self): + self.client.force_login(self.screener) + response = self.client.get( + reverse( + "screening_thanks", + kwargs={"assignment_id": self.assignment.pk}, + ), + SERVER_NAME=self.journal_one.domain, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.article.title) + + def test_screening_form_answer_persisted_after_submission(self): + self.client.force_login(self.screener) + self.client.post( + reverse( + "do_screening", + kwargs={"assignment_id": self.assignment.pk}, + ), + { + str(self.form_element.pk): "Reasonable submission.", + "recommendation": "revisions_required", + }, + SERVER_NAME=self.journal_one.domain, + ) + answer = screening_models.ScreeningAssignmentAnswer.objects.get( + assignment=self.assignment, + ) + self.assertEqual(answer.answer, "Reasonable submission.") diff --git a/src/screening/urls.py b/src/screening/urls.py new file mode 100644 index 0000000000..5a0f7796fa --- /dev/null +++ b/src/screening/urls.py @@ -0,0 +1,178 @@ +__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 screening import views + + +urlpatterns = [ + re_path( + r"^$", + views.screening_list, + name="screening_list", + ), + re_path( + r"^article/(?P\d+)/$", + views.screening_article, + name="screening_article", + ), + re_path( + r"^article/(?P\d+)/round/add/$", + views.add_screening_round, + name="add_screening_round", + ), + re_path( + r"^article/(?P\d+)/round/(?P\d+)/assign/$", + views.add_screening_assignment, + name="add_screening_assignment", + ), + re_path( + r"^article/(?P\d+)/assignment/(?P\d+)/notify/$", + views.screening_assignment_notification, + name="screening_assignment_notification", + ), + re_path( + r"^requests/$", + views.screening_requests, + name="screening_requests", + ), + re_path( + r"^request/(?P\d+)/accept/$", + views.accept_screening_request, + name="accept_screening_request", + ), + re_path( + r"^request/(?P\d+)/decline/$", + views.decline_screening_request, + name="decline_screening_request", + ), + re_path( + r"^assignment/(?P\d+)/$", + views.do_screening, + name="do_screening", + ), + re_path( + r"^assignment/(?P\d+)/thanks/$", + views.screening_thanks, + name="screening_thanks", + ), + re_path( + r"^article/(?P\d+)/move/next/$", + views.move_to_next_stage, + name="screening_move_to_next_stage", + ), + re_path( + r"^article/(?P\d+)/revisions/request/$", + 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, + name="view_screening_revision", + ), + re_path( + r"^manager/pool/$", + views.screening_pool, + name="screening_pool", + ), + re_path( + r"^manager/forms/$", + views.screening_forms, + name="screening_forms", + ), + re_path( + r"^manager/forms/(?P\d+)/$", + views.edit_screening_form, + name="edit_screening_form", + ), + re_path( + r"^manager/forms/(?P\d+)/element/(?P\d+)/$", + views.edit_screening_form, + name="edit_screening_form_element", + ), + re_path( + r"^manager/checklist-templates/$", + views.screening_checklist_templates, + name="screening_checklist_templates", + ), + re_path( + r"^manager/checklist-templates/(?P\d+)/$", + views.edit_screening_checklist_template, + name="edit_screening_checklist_template", + ), + re_path( + r"^manager/checklist-templates/(?P\d+)/item/(?P\d+)/$", + views.edit_screening_checklist_template, + name="edit_screening_checklist_template_item", + ), + re_path( + r"^checklist-item/(?P\d+)/toggle/$", + views.toggle_checklist_item, + name="toggle_checklist_item", + ), + re_path( + r"^checklist-item/(?P\d+)/comment/$", + views.save_checklist_item_comment, + name="save_checklist_item_comment", + ), + re_path( + r"^article/(?P\d+)/checklist/switch/$", + views.switch_checklist_template, + name="switch_checklist_template", + ), + re_path( + r"^article/(?P\d+)/assignment/(?P\d+)/edit/$", + views.edit_screening_assignment, + name="edit_screening_assignment", + ), + re_path( + r"^article/(?P\d+)/assignment/(?P\d+)/withdraw/$", + views.withdraw_screening_assignment, + name="withdraw_screening_assignment", + ), + re_path( + r"^article/(?P\d+)/assignment/(?P\d+)/reset/$", + views.reset_screening_assignment, + name="reset_screening_assignment", + ), + re_path( + r"^article/(?P\d+)/assignment/(?P\d+)/report/$", + views.view_screening_report, + name="view_screening_report", + ), +] diff --git a/src/screening/views.py b/src/screening/views.py new file mode 100644 index 0000000000..b898da4f87 --- /dev/null +++ b/src/screening/views.py @@ -0,0 +1,1341 @@ +__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.contrib.auth.decorators import login_required +from django.db.models import Q +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils import timezone +from django.views.decorators.http import require_POST + +from core import files as core_files +from core import forms as core_forms +from core import models as core_models +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.decorators import ( + screener_for_assignment_required, + screener_or_editor_for_assignment_required, +) +from security.decorators import ( + any_editor_user_required, + editor_user_required, + senior_editor_user_required, +) +from submission import models as submission_models + + +def journal_has_screening_element(journal): + return journal.element_in_workflow("screening") + + +@any_editor_user_required +def screening_list(request): + """List articles currently in the screening stage for this journal.""" + if not journal_has_screening_element(request.journal): + raise Http404("Screening is not enabled for this journal.") + + articles = submission_models.Article.objects.filter( + stage=submission_models.STAGE_SCREENING, + journal=request.journal, + ).select_related("correspondence_author", "section") + + template = "admin/screening/list.html" + context = {"articles": articles} + return render(request, template, context) + + +@editor_user_required +def screening_article(request, article_id): + """Per-article screening dashboard. + + Shows every screening round on the article, the assignments on each + round, and editor-facing actions for opening a new round or inviting + a screener to the latest round. + """ + 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, + ) + is_screening_stage = article.stage == submission_models.STAGE_SCREENING + + rounds = ( + screening_models.ScreeningRound.objects.filter(article=article) + .prefetch_related("screeningassignment_set__screener") + .order_by("round_number") + ) + latest_round = rounds.last() if rounds.exists() else None + next_workflow_element = core_workflow.get_next_workflow_element( + request.journal, + "screening", + ) + checklist = logic.ensure_checklist_for_article(article) + checklist_templates = screening_models.TechnicalChecklistTemplate.objects.filter( + journal=request.journal, + deleted=False, + ) + + revision_requests = screening_models.ScreeningRevisionRequest.objects.filter( + article=article, + ).order_by("-date_requested") + + template = "admin/screening/article.html" + context = { + "article": article, + "rounds": rounds, + "latest_round": latest_round, + "next_workflow_element": next_workflow_element, + "checklist": checklist, + "checklist_templates": checklist_templates, + "is_screening_stage": is_screening_stage, + "revision_requests": revision_requests, + } + return render(request, template, context) + + +@editor_user_required +def add_screening_round(request, article_id): + """Open a new screening round on an article. + + Idempotent on GET — round opening is POST-only. If no rounds exist + yet, this is the first round; otherwise it is one higher than the + most recent. + """ + 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, + ) + + if request.method != "POST": + return redirect(reverse("screening_article", kwargs={"article_id": article.pk})) + + new_round = logic.open_screening_round(article) + messages.add_message( + request, + messages.SUCCESS, + "Screening round {0} opened.".format(new_round.round_number), + ) + return redirect(reverse("screening_article", kwargs={"article_id": article.pk})) + + +@editor_user_required +def add_screening_assignment(request, article_id, round_id): + """Invite a screener to a specific screening round. + + The screener choice list is restricted to the journal's editorial + team and excludes anyone already invited to this round. + """ + 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, + ) + screening_round = get_object_or_404( + screening_models.ScreeningRound, + pk=round_id, + article=article, + ) + + form = forms.ScreeningAssignmentForm( + request.POST or None, + article=article, + journal=request.journal, + screening_round=screening_round, + editor=request.user, + ) + + if request.method == "POST" and form.is_valid(): + assignment = form.save() + return redirect( + reverse( + "screening_assignment_notification", + kwargs={ + "article_id": article.pk, + "assignment_id": assignment.pk, + }, + ) + ) + + candidates = logic.annotate_candidate_screeners( + form.fields["screener"].queryset, + journal=request.journal, + ) + + pool = screening_models.ScreeningPool.objects.filter( + journal=request.journal, + ).first() + pool_groups = list(pool.groups.all()) if pool else [] + + template = "admin/screening/add_assignment.html" + context = { + "article": article, + "screening_round": screening_round, + "form": form, + "candidates": candidates, + "pool_groups": pool_groups, + } + return render(request, template, context) + + +@editor_user_required +def screening_assignment_notification(request, article_id, assignment_id): + """Preview and edit the invitation email before sending it to the + screener. Mirrors the editor_assignment / review notification + flow. POST sends the email (or skips it) and raises + ON_SCREENER_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, + ) + assignment = get_object_or_404( + screening_models.ScreeningAssignment, + pk=assignment_id, + article=article, + ) + + screening_requests_url = request.journal.site_url( + reverse("screening_requests"), + ) + email_context = { + "article": article, + "screening_assignment": assignment, + "screening_requests_url": screening_requests_url, + } + form = core_forms.SettingEmailForm( + setting_name="screening_invitation", + email_context=email_context, + request=request, + ) + + if request.method == "POST": + form = core_forms.SettingEmailForm( + request.POST, + request.FILES, + setting_name="screening_invitation", + email_context=email_context, + request=request, + ) + skip = request.POST.get("skip") + if skip or form.is_valid(): + event_logic.Events.raise_event( + event_logic.Events.ON_SCREENER_REQUESTED, + task_object=article, + request=request, + screening_assignment=assignment, + email_data=form.as_dataclass() if not skip else None, + skip=bool(skip), + ) + messages.add_message( + request, + messages.SUCCESS, + ( + "{} invited as a screener (notification skipped).".format( + assignment.screener.full_name(), + ) + if skip + else "{} invited as a screener and notified.".format( + assignment.screener.full_name(), + ) + ), + ) + return redirect( + reverse("screening_article", kwargs={"article_id": article.pk}) + ) + + template = "admin/screening/assignment_notification.html" + context = { + "article": article, + "assignment": assignment, + "form": form, + } + return render(request, template, context) + + +@editor_user_required +def edit_screening_assignment(request, article_id, assignment_id): + """Allow an editor to amend an open screening assignment — change + due date, anonymity flags, form, or the screener identity.""" + 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, + ) + assignment = get_object_or_404( + screening_models.ScreeningAssignment, + pk=assignment_id, + article=article, + ) + if assignment.is_withdrawn: + messages.add_message( + request, + messages.WARNING, + "Withdrawn assignments cannot be edited. Reset the assignment first.", + ) + return redirect( + reverse("screening_article", kwargs={"article_id": article.pk}), + ) + form = forms.ScreeningAssignmentForm( + request.POST or None, + instance=assignment, + article=article, + journal=request.journal, + screening_round=assignment.screening_round, + editor=request.user, + ) + + if request.method == "POST" and form.is_valid(): + form.save() + messages.add_message( + request, + messages.SUCCESS, + "Screening assignment updated.", + ) + return redirect( + reverse("screening_article", kwargs={"article_id": article.pk}), + ) + + template = "admin/screening/edit_assignment.html" + context = { + "article": article, + "screening_round": assignment.screening_round, + "assignment": assignment, + "form": form, + } + return render(request, template, context) + + +@editor_user_required +@require_POST +def withdraw_screening_assignment(request, article_id, assignment_id): + """Withdraw an open screening assignment — sets it to the withdrawn + state so the screener can no longer act on it. Idempotent.""" + 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, + ) + assignment = get_object_or_404( + screening_models.ScreeningAssignment, + pk=assignment_id, + article=article, + ) + if assignment.withdraw(): + 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, + "Screening assignment withdrawn.", + ) + return redirect( + reverse("screening_article", kwargs={"article_id": article.pk}), + ) + + +@editor_user_required +def view_screening_report(request, article_id, assignment_id): + """Editor-facing read-only view of a completed screening report. + + Shows the screener's identity, recommendation, comments for the + editor, suggested reviewers (if any), and each form answer + submitted on the screening form. + """ + 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, + ) + assignment = get_object_or_404( + screening_models.ScreeningAssignment, + pk=assignment_id, + article=article, + ) + answers = assignment.screening_form_answers() + + template = "admin/screening/view_report.html" + context = { + "article": article, + "assignment": assignment, + "answers": answers, + "back_url": logic.back_url_for_assignment(request, assignment), + } + return render(request, template, context) + + +@editor_user_required +@require_POST +def reset_screening_assignment(request, article_id, assignment_id): + """Reset a completed screening assignment back to the in-progress + state so the screener can revise their report. Clears completion + state, the recommendation, and the saved date_complete; keeps the + date_accepted so the screener does not have to re-accept.""" + 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, + ) + assignment = get_object_or_404( + screening_models.ScreeningAssignment, + pk=assignment_id, + article=article, + ) + assignment.reset() + messages.add_message( + request, + messages.SUCCESS, + "Screening assignment reset.", + ) + return redirect( + reverse("screening_article", kwargs={"article_id": article.pk}), + ) + + +@login_required +def screening_requests(request): + """List screening invitations for the logged-in user on this journal. + + Pending requests are those not yet declined and not yet complete; + completed requests are those the screener has already submitted. + """ + base = screening_models.ScreeningAssignment.objects.filter( + screener=request.user, + article__journal=request.journal, + ).select_related("article", "screening_round") + pending = base.filter(date_declined__isnull=True, is_complete=False) + completed = base.filter( + Q(is_complete=True) | Q(date_declined__isnull=False), + ).order_by("-date_complete", "-date_declined") + + template = "admin/screening/requests.html" + context = {"pending": pending, "completed": completed} + 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.""" + if assignment.date_declined: + messages.add_message( + request, + messages.WARNING, + "This screening request has already been declined.", + ) + return redirect(reverse("screening_requests")) + + if assignment.accept(): + messages.add_message( + request, + messages.SUCCESS, + "Screening request accepted.", + ) + 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.""" + if assignment.is_complete: + messages.add_message( + request, + messages.WARNING, + "This screening report is already complete and cannot be declined.", + ) + return redirect(reverse("screening_requests")) + + if assignment.decline(): + messages.add_message( + request, + messages.SUCCESS, + "Screening request declined.", + ) + return redirect(reverse("screening_requests")) + + +@screener_or_editor_for_assignment_required +def do_screening(request, assignment_id, assignment=None): + """The screener fills out the screening form and records a + recommendation. POST submits the report, marking the assignment + complete. Editors may access this page to submit on behalf of the + screener.""" + if assignment.date_declined: + raise Http404 + assignment.accept() + + form_class = ( + forms.build_screening_form_class(assignment.form) if assignment.form else None + ) + initial_recommendation = { + "recommendation": assignment.recommendation or "", + "suggested_reviewers": assignment.suggested_reviewers or "", + "comments_for_editor": assignment.comments_for_editor or "", + } + + screening_form = form_class(request.POST or None) if form_class else None + recommendation_form = forms.ScreeningRecommendationForm( + request.POST or None, + initial=initial_recommendation, + ) + + if request.method == "POST": + screening_form_valid = screening_form.is_valid() if screening_form else True + recommendation_valid = recommendation_form.is_valid() + if screening_form_valid and recommendation_valid: + if screening_form is not None: + assignment.save_screening_form(screening_form) + 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", + "", + ), + ) + event_logic.Events.raise_event( + event_logic.Events.ON_SCREENING_COMPLETE, + task_object=assignment.article, + request=request, + screening_assignment=assignment, + ) + return redirect( + reverse("screening_thanks", kwargs={"assignment_id": assignment.pk}) + ) + + template = "admin/screening/do_screening.html" + context = { + "assignment": assignment, + "article": assignment.article, + "screening_form": screening_form, + "recommendation_form": recommendation_form, + "back_url": logic.back_url_for_assignment(request, assignment), + } + return render(request, template, context) + + +@screener_or_editor_for_assignment_required +def screening_thanks(request, assignment_id, assignment=None): + """Confirmation page after a screening report is submitted.""" + template = "admin/screening/thanks.html" + context = { + "assignment": assignment, + "article": assignment.article, + "back_url": logic.back_url_for_assignment(request, assignment), + } + return render(request, template, context) + + +@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 + action: the managing editor takes the article forward whenever they + are satisfied with the screening reports — no formal decision + artefact is recorded here. + """ + 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, + ) + + next_element = core_workflow.get_next_workflow_element( + request.journal, + "screening", + ) + if next_element is None: + messages.add_message( + request, + messages.WARNING, + "There is no next workflow element configured after screening.", + ) + return redirect(reverse("screening_article", kwargs={"article_id": article.pk})) + + # Pre-create the next stage's artefacts (e.g. ReviewRound 1) so they + # exist when the user lands on the next page. + logic.setup_after_screening(article, next_element) + + # Delegate the stage transition, log entry, and redirect to the + # canonical core.workflow machinery. + workflow = request.journal.workflow() + current_element = workflow.elements.get(element_name="screening") + response = core_workflow.workflow_next( + handshake_url=current_element.handshake_url, + request=request, + article=article, + switch_stage=True, + ) + + # Author notification — fired after the stage transition has been + # logged so any per-author tasks downstream see the new stage. + event_logic.Events.raise_event( + event_logic.Events.ON_SCREENING_PASSED, + task_object=article, + request=request, + article=article, + next_workflow_element=next_element, + ) + if response: + if request.GET.get("return", None): + return redirect(request.GET.get("return")) + return response + return redirect(reverse("core_dashboard")) + + +@editor_user_required +def request_screening_revisions(request, article_id): + """Editor opens a revision request, asking the corresponding author + to revise in place. Article remains in screening stage; on author + completion a new ScreeningRound is opened automatically.""" + 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, + ) + open_revision = screening_models.ScreeningRevisionRequest.objects.open_for_article( + article, + ) + 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, + editor=request.user, + ) + if request.method == "POST" and form.is_valid(): + revision = form.save() + 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, + ) + 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.cancel() + 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 updated.", + ) + return redirect( + reverse( + "view_screening_revision", + kwargs={"revision_id": revision.pk}, + ) + ) + + template = "admin/screening/edit_revisions.html" + context = {"article": article, "revision": revision, "form": form} + return render(request, template, context) + + +@login_required +def do_screening_revisions(request, revision_id): + """Author surface for completing a screening revision. The author + uses Janeway's existing article-files mechanism to upload the + revised manuscript and may add a covering letter. Submitting marks + the revision complete and opens a new ScreeningRound.""" + revision = get_object_or_404( + screening_models.ScreeningRevisionRequest, + pk=revision_id, + article__journal=request.journal, + ) + if request.user != revision.article.correspondence_author: + raise Http404 + if revision.date_completed or revision.date_cancelled: + return redirect( + reverse( + "view_screening_revision", + kwargs={"revision_id": revision.pk}, + ) + ) + + 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.complete() + 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 = { + "article": revision.article, + "revision": revision, + "form": form, + } + 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`.""" + 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`.""" + 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.""" + if not journal_has_screening_element(request.journal): + raise Http404("Screening is not enabled for this journal.") + + revision = get_object_or_404( + screening_models.ScreeningRevisionRequest, + pk=revision_id, + article__journal=request.journal, + ) + template = "admin/screening/view_revision.html" + context = {"article": revision.article, "revision": revision} + return render(request, template, context) + + +@senior_editor_user_required +def screening_checklist_templates(request): + """List technical-check checklist templates for this journal, with + create + delete actions.""" + template_list = screening_models.TechnicalChecklistTemplate.objects.filter( + journal=request.journal, + deleted=False, + ) + form = forms.ChecklistTemplateForm() + + if request.method == "POST": + if "delete" in request.POST: + obj = get_object_or_404( + screening_models.TechnicalChecklistTemplate, + pk=request.POST["delete"], + journal=request.journal, + ) + obj.deleted = True + obj.save() + messages.add_message(request, messages.SUCCESS, "Template deleted.") + return redirect(reverse("screening_checklist_templates")) + + form = forms.ChecklistTemplateForm(request.POST) + if form.is_valid(): + new_template = form.save(commit=False) + new_template.journal = request.journal + new_template.save() + return redirect( + reverse( + "edit_screening_checklist_template", + kwargs={"template_id": new_template.pk}, + ) + ) + + template = "admin/screening/manager/checklist_templates.html" + context = {"template_list": template_list, "form": form} + return render(request, template, context) + + +@senior_editor_user_required +def edit_screening_checklist_template(request, template_id, item_id=None): + """Edit a checklist template's metadata and its items.""" + edit_template = get_object_or_404( + screening_models.TechnicalChecklistTemplate, + pk=template_id, + journal=request.journal, + ) + form = forms.ChecklistTemplateForm(instance=edit_template) + item_form = forms.ChecklistTemplateItemForm() + item, modal = None, None + + if item_id: + item = get_object_or_404( + screening_models.TechnicalChecklistTemplateItem, + pk=item_id, + template=edit_template, + ) + item_form = forms.ChecklistTemplateItemForm(instance=item) + modal = "item" + + if request.method == "POST": + if "delete" in request.POST: + target = get_object_or_404( + screening_models.TechnicalChecklistTemplateItem, + pk=request.POST["delete"], + template=edit_template, + ) + target.delete() + return redirect( + reverse( + "edit_screening_checklist_template", + kwargs={"template_id": edit_template.pk}, + ) + ) + + if "item" in request.POST: + if item_id: + item_form = forms.ChecklistTemplateItemForm( + request.POST, + instance=item, + ) + else: + item_form = forms.ChecklistTemplateItemForm(request.POST) + if item_form.is_valid(): + saved = item_form.save(commit=False) + saved.template = edit_template + saved.save() + return redirect( + reverse( + "edit_screening_checklist_template", + kwargs={"template_id": edit_template.pk}, + ) + ) + + if "template" in request.POST: + form = forms.ChecklistTemplateForm( + request.POST, + instance=edit_template, + ) + if form.is_valid(): + form.save() + return redirect( + reverse( + "edit_screening_checklist_template", + kwargs={"template_id": edit_template.pk}, + ) + ) + + template = "admin/screening/manager/edit_checklist_template.html" + context = { + "edit_template": edit_template, + "form": form, + "item_form": item_form, + "modal": modal, + } + return render(request, template, context) + + +@require_POST +@editor_user_required +def toggle_checklist_item(request, item_id): + """Toggle a single checklist item's complete state for an article in + Screening. POST-only; records who toggled it and when.""" + item = get_object_or_404( + screening_models.TechnicalChecklistItem, + pk=item_id, + checklist__article__journal=request.journal, + ) + item.is_complete = not item.is_complete + if item.is_complete: + item.completed_by = request.user + item.completed_at = timezone.now() + else: + item.completed_by = None + item.completed_at = None + item.save() + return logic.render_checklist_item_response(request, item) + + +@require_POST +@editor_user_required +def switch_checklist_template(request, article_id): + """Replace the checklist applied to an article with a different + journal-level template. Existing item state is discarded — the + operation re-seeds items from the chosen template.""" + article = get_object_or_404( + submission_models.Article, + pk=article_id, + journal=request.journal, + stage=submission_models.STAGE_SCREENING, + ) + template = get_object_or_404( + screening_models.TechnicalChecklistTemplate, + pk=request.POST.get("template_id"), + journal=request.journal, + deleted=False, + ) + checklist, _ = screening_models.TechnicalChecklist.objects.get_or_create( + article=article, + ) + checklist.template = template + checklist.save() + checklist.items.all().delete() + for tpl_item in template.items.all(): + screening_models.TechnicalChecklistItem.objects.create( + checklist=checklist, + template_item=tpl_item, + label=tpl_item.label, + order=tpl_item.order, + ) + + checklist_templates = screening_models.TechnicalChecklistTemplate.objects.filter( + journal=request.journal, + deleted=False, + ) + if request.headers.get("HX-Request"): + return render( + request, + "admin/screening/elements/checklist_panel.html", + { + "article": article, + "checklist": checklist, + "checklist_templates": checklist_templates, + }, + ) + return redirect( + reverse("screening_article", kwargs={"article_id": article.pk}), + ) + + +@require_POST +@editor_user_required +def save_checklist_item_comment(request, item_id): + """Persist the editor's comment on a checklist item.""" + item = get_object_or_404( + screening_models.TechnicalChecklistItem, + pk=item_id, + checklist__article__journal=request.journal, + ) + item.comment = request.POST.get("comment", "")[:5000] + item.save() + return logic.render_checklist_item_response(request, item) + + +@senior_editor_user_required +def screening_pool(request): + """Per-journal manager page selecting which editorial groups + contribute members to the screener pool.""" + pool, _ = screening_models.ScreeningPool.objects.get_or_create( + journal=request.journal, + ) + form = forms.ScreeningPoolForm( + request.POST or None, + instance=pool, + journal=request.journal, + ) + if request.method == "POST" and form.is_valid(): + form.save() + messages.add_message( + request, + messages.SUCCESS, + "Screener pool updated.", + ) + return redirect(reverse("screening_pool")) + + template = "admin/screening/manager/screening_pool.html" + context = {"form": form, "pool": pool} + return render(request, template, context) + + +@senior_editor_user_required +def screening_forms(request): + """List screening forms on this journal and offer creation of new + forms or soft-deletion of existing ones.""" + form_list = screening_models.ScreeningForm.objects.filter( + journal=request.journal, + deleted=False, + ) + + form = forms.NewScreeningForm() + + if request.method == "POST": + if "delete" in request.POST: + form_id = request.POST["delete"] + form_obj = get_object_or_404( + screening_models.ScreeningForm, + id=form_id, + journal=request.journal, + ) + form_obj.deleted = True + form_obj.save() + messages.add_message(request, messages.SUCCESS, "Screening form deleted.") + return redirect(reverse("screening_forms")) + + form = forms.NewScreeningForm(request.POST) + if form.is_valid(): + new_form = form.save(commit=False) + new_form.journal = request.journal + new_form.save() + messages.add_message(request, messages.SUCCESS, "Screening form created.") + return redirect( + reverse("edit_screening_form", kwargs={"form_id": new_form.pk}) + ) + + template = "admin/screening/manager/screening_forms.html" + context = {"form_list": form_list, "form": form} + return render(request, template, context) + + +@senior_editor_user_required +def edit_screening_form(request, form_id, element_id=None): + """Edit a screening form's metadata and manage its elements.""" + edit_form = get_object_or_404( + screening_models.ScreeningForm, + pk=form_id, + journal=request.journal, + ) + form = forms.NewScreeningForm(instance=edit_form) + element_form = forms.ScreeningElementForm() + element, modal = None, None + + if element_id: + element = get_object_or_404( + screening_models.ScreeningFormElement, + pk=element_id, + screeningform=edit_form, + ) + modal = "element" + element_form = forms.ScreeningElementForm(instance=element) + + if request.method == "POST": + if "delete" in request.POST: + delete_id = request.POST.get("delete") + element_to_delete = get_object_or_404( + screening_models.ScreeningFormElement, + pk=delete_id, + screeningform=edit_form, + ) + element_to_delete.delete() + messages.add_message(request, messages.SUCCESS, "Element deleted.") + return redirect( + reverse("edit_screening_form", kwargs={"form_id": edit_form.pk}) + ) + + if "element" in request.POST: + if element_id: + element_form = forms.ScreeningElementForm( + request.POST, + instance=element, + ) + else: + element_form = forms.ScreeningElementForm(request.POST) + if element_form.is_valid(): + saved_element = element_form.save() + edit_form.elements.add(saved_element) + messages.add_message(request, messages.SUCCESS, "Element saved.") + return redirect( + reverse( + "edit_screening_form", + kwargs={"form_id": edit_form.pk}, + ) + ) + + if "screening_form" in request.POST: + form = forms.NewScreeningForm(request.POST, instance=edit_form) + if form.is_valid(): + form.save() + messages.add_message(request, messages.SUCCESS, "Form updated.") + return redirect( + reverse( + "edit_screening_form", + kwargs={"form_id": edit_form.pk}, + ) + ) + + template = "admin/screening/manager/edit_screening_form.html" + context = { + "form": form, + "edit_form": edit_form, + "element_form": element_form, + "modal": modal, + } + return render(request, template, context) diff --git a/src/static/admin/js/screening.js b/src/static/admin/js/screening.js new file mode 100644 index 0000000000..2a258739c9 --- /dev/null +++ b/src/static/admin/js/screening.js @@ -0,0 +1,13 @@ +(function () { + var root = document.getElementById('recommendation-form-wrap'); + if (!root) return; + var sel = root.querySelector('select[name="recommendation"]'); + var input = root.querySelector('[name="suggested_reviewers"]'); + if (!sel || !input) return; + var wrap = input.closest('label') || input.closest('.row') || input.parentElement; + function toggle() { + wrap.style.display = (sel.value === 'accept_for_peer_review') ? '' : 'none'; + } + sel.addEventListener('change', toggle); + toggle(); +})(); 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..34d595c1d0 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"), @@ -2246,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/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/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/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/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/screener_base.html b/src/templates/admin/elements/breadcrumbs/screener_base.html new file mode 100644 index 0000000000..82080d04c2 --- /dev/null +++ b/src/templates/admin/elements/breadcrumbs/screener_base.html @@ -0,0 +1,2 @@ +
  • Screening Requests
  • +{% if assignment %}
  • Screening #{{ assignment.pk }}
  • {% endif %} diff --git a/src/templates/admin/elements/breadcrumbs/screening_base.html b/src/templates/admin/elements/breadcrumbs/screening_base.html new file mode 100644 index 0000000000..c6d42749d1 --- /dev/null +++ b/src/templates/admin/elements/breadcrumbs/screening_base.html @@ -0,0 +1 @@ +
  • Screening
  • 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/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/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" %} 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 %} diff --git a/src/templates/admin/elements/screening/add_screener_table_row.html b/src/templates/admin/elements/screening/add_screener_table_row.html new file mode 100644 index 0000000000..f8fb77a5de --- /dev/null +++ b/src/templates/admin/elements/screening/add_screener_table_row.html @@ -0,0 +1,29 @@ +{% load i18n %} + + + + + + +{{ 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:"—" }} 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/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..14424d889e 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,16 @@

    Actions

    {% endif %} {% if article.editors %}
    + + + + + + + + + + + + {% 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..101e3119b2 --- /dev/null +++ b/src/templates/admin/screening/article.html @@ -0,0 +1,429 @@ +{% 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 %} + +
    RequestedTypeDueStatusActions
    {{ rev.date_requested|date:"Y-m-d" }}{{ rev.get_type_display }}{{ rev.date_due }} + {% if rev.is_complete %} + Submitted + {% elif rev.is_cancelled %} + Withdrawn + {% else %} + Awaiting Author + {% endif %} + + {% 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 %} +
    +
    +
    + {% endif %} + + {% include "admin/screening/elements/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" %} +
    +
    + + {% if is_screening_stage %} +
    +
    +
    +

     Reject article

    +
    +
    +

    + Rejecting this article ends the editorial process. The author will be + notified, and the article will be moved out of the screening stage. +

    +

    + On the next screen you will be able to review and edit the rejection + email before it is sent. +

    +

    Continue?

    + + Continue to reject + + +
    +
    + +
    + {% endif %} + +{% 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..edf2a2d960 --- /dev/null +++ b/src/templates/admin/screening/do_revisions.html @@ -0,0 +1,134 @@ +{% extends "admin/core/base.html" %} +{% load foundation %} +{% load files %} + +{% 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 %} +
    +

    Covering letter

    +
    +
    +

    + Optionally describe the changes you made for the editor. +

    + {{ form|foundation }} + +
    + +
    +

    Finishing up

    +
    +
    +

    + 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. +

    + +
    +
    +
    +
    +{% 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/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/elements/checklist_item_row.html b/src/templates/admin/screening/elements/checklist_item_row.html new file mode 100644 index 0000000000..b4cf9550b8 --- /dev/null +++ b/src/templates/admin/screening/elements/checklist_item_row.html @@ -0,0 +1,31 @@ + + +
    + {% 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 %} + + diff --git a/src/templates/admin/screening/elements/checklist_panel.html b/src/templates/admin/screening/elements/checklist_panel.html new file mode 100644 index 0000000000..526975db5f --- /dev/null +++ b/src/templates/admin/screening/elements/checklist_panel.html @@ -0,0 +1,103 @@ +
    +{% if checklist %} + {% with items=checklist.items.all %} +
    +
    +

    Technical Checklist

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

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

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

     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 %} + {% endwith %} +{% endif %} +
    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/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 new file mode 100644 index 0000000000..33bbeb2713 --- /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 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 %} + {{ 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..3c0b6a5da3 --- /dev/null +++ b/src/templates/admin/screening/requests.html @@ -0,0 +1,130 @@ +{% 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 %} +
    + {% csrf_token %} + +
    +
    + {% csrf_token %} + +
    + {% 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/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/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/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_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..f3828db964 --- /dev/null +++ b/src/templates/admin/screening/view_revision.html @@ -0,0 +1,115 @@ +{% extends "admin/core/base.html" %} +{% load files %} + +{% 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 + +

    + + {% 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

    +
    +
    +
    + {% 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" %} + {% 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 %} +
    +
    + +
    +

    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 %} + +
    +

    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 ee1653bb10..144268803c 100644 --- a/src/utils/install/journal_defaults.json +++ b/src/utils/install/journal_defaults.json @@ -5672,5 +5672,271 @@ "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" + ] + }, + { + "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" + ] } ]