From d9b02f0a46868a6786f0c73c03bd46fa78e5845b Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Mon, 24 Aug 2026 15:50:20 +0200 Subject: [PATCH 01/11] Add new workflow: Request external then internal review. --- docs/references/workflows.md | 31 ++- ..._external_then_internal_review_workflow.py | 253 ++++++++++++++++++ hypha/apply/funds/tests/test_workflows.py | 113 ++++++++ hypha/apply/funds/workflows/constants.py | 2 + .../definitions/single_stage_ext_int.py | 191 +++++++++++++ hypha/apply/funds/workflows/models/stage.py | 1 + hypha/apply/funds/workflows/registry.py | 8 + hypha/apply/review/views.py | 14 + 8 files changed, 611 insertions(+), 2 deletions(-) create mode 100644 hypha/apply/funds/migrations/0136_add_external_then_internal_review_workflow.py create mode 100644 hypha/apply/funds/tests/test_workflows.py create mode 100644 hypha/apply/funds/workflows/definitions/single_stage_ext_int.py diff --git a/docs/references/workflows.md b/docs/references/workflows.md index 944e24c366..5f666e07dd 100644 --- a/docs/references/workflows.md +++ b/docs/references/workflows.md @@ -8,13 +8,14 @@ Each workflow offers different statuses (e.g. External Review, Ready for Determi Hypha covers more than just the application phase, but workflows are used in the application process only. -## What are the 4 workflows? +## What are the 6 workflows? 1. [Request](#request) 2. [Request with same time review](#request-with-same-time-review) (new in v5.20.0) 3. [Request with external review](#request-with-external-review) 4. [Request with community review](#request-with-community-review) -5. [Concept and Proposal](#concept-and-proposal) +5. [Request external then internal review](#request-external-then-internal-review) +6. [Concept and Proposal](#concept-and-proposal) All workflows begin with applicant drafting, revising and submitting an application (`DRAFT_STATE`) β€” only transition available is to `INITIAL_STATE`, upon applicant taking the action to submit their application/request. @@ -112,6 +113,32 @@ Once an application is submitted (`INITIAL_STATE`) β€” it can transition into th - Accept application but additional information is required (`com_almost`) β€” can transition to accepting application (`com_accepted`) or revert back to ready for discussion (`com_post_external_review_discussion`) - Reject application (`com_rejected`) +### πŸ”€ Request external then internal review + +This workflow is a single stage process with both an external review and an internal review stage. It is very similar to the "Request with external review" workflow, see above, but the order of the two review steps is reversed: the external reviewers see the application first, and the internal review only opens once the external review has been closed. + +It is also the only workflow that offers **Waitlisted** as an outcome, alongside Accepted and Dismissed. + +**Proposal Persona:** + +Funding organization wants an advisory board or external partners to give their opinion before staff spend time on an internal review, and needs to be able to park promising applications on a waitlist while funding decisions are made. + +Once an application is submitted (`INITIAL_STATE`) β€” it can transition into the following: + +- A request for more information (`ext_int_more_info`) β€” opens editing permissions to applicant again to revise their application to provide the information requested by the screeners. +- Open external review (`ext_int_external_review`) β€” can transition only between closing the review period (`ext_int_post_external_review_discussion`) and reverting back to the internal screening phase. + - `ext_int_post_external_review_discussion` β€” after the external review is closed, you can request more information (`ext_int_post_external_review_more_info`), open the internal review (`ext_int_internal_review`), mark ready for determination (`ext_int_determination`), revert back to opening the external review (`ext_int_external_review`), or dismiss (`ext_int_rejected`) + - `ext_int_post_external_review_more_info` β€” opens editing permissions to applicant again to revise their application to provide the information requested by the external reviewers. +- Open internal review (`ext_int_internal_review`) β€” can transition only between closing the review period (`ext_int_post_review_discussion`) and reverting back to discussion (`ext_int_post_external_review_discussion`) + - `ext_int_post_review_discussion` β€” after the internal review is closed, you can request more information (`ext_int_post_review_more_info`), mark ready for determination (`ext_int_determination`), revert back to opening the internal review (`ext_int_internal_review`), accept (`ext_int_accepted`), waitlist (`ext_int_waitlisted`) or dismiss (`ext_int_rejected`) + - `ext_int_post_review_more_info` β€” opens editing permissions to applicant again to revise their application to provide the information requested by the reviewers. +- Ready for determination (`ext_int_determination`) β€” can revert back to discussion (β€œReady For Discussion (revert)” β€” `ext_int_post_review_discussion`), accept (`ext_int_accepted`), waitlist (`ext_int_waitlisted`) or dismiss (`ext_int_rejected`) +- Waitlisted (`ext_int_waitlisted`) β€” the application is kept in play without a decision. Can transition to accept (`ext_int_accepted`), dismiss (`ext_int_rejected`), or revert back to ready for determination (`ext_int_determination`). Staff can still edit this submission. Waitlisting is a plain status change β€” unlike Accept and Dismiss it does not open the determination form and does not create a determination record. +- Accepted (`ext_int_accepted`) β€” application accepted. Staff can still edit this submission. +- Dismissed (`ext_int_rejected`) β€” application rejected. Permissions removed from all roles. + +There is no "Accepted but additional info required" state in this workflow. + ### πŸ’‘ Concept and Proposal This workflow is a two-stage process: the first stage is the request and the second stage includes an advisory council review or external review stage. diff --git a/hypha/apply/funds/migrations/0136_add_external_then_internal_review_workflow.py b/hypha/apply/funds/migrations/0136_add_external_then_internal_review_workflow.py new file mode 100644 index 0000000000..c1501f7816 --- /dev/null +++ b/hypha/apply/funds/migrations/0136_add_external_then_internal_review_workflow.py @@ -0,0 +1,253 @@ +# Generated by Django 5.2.17 on 2026-08-24 13:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("funds", "0135_alter_applicationsubmission_user_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="anonymizedsubmission", + name="status", + field=models.CharField( + choices=[ + ("accepted", "Accepted"), + ("almost", "Accepted but additional info required"), + ("com_accepted", "Accepted"), + ("com_almost", "Accepted but additional info required"), + ("com_community_review", "Community Review"), + ("com_determination", "Ready for Determination"), + ("com_external_review", "External Review"), + ("com_internal_review", "Internal Review"), + ("com_more_info", "More information required"), + ("com_open_call", "Open Call (public)"), + ("com_post_external_review_discussion", "Ready For Discussion"), + ("com_post_external_review_more_info", "More information required"), + ("com_post_review_discussion", "Ready For Discussion"), + ("com_post_review_more_info", "More information required"), + ("com_rejected", "Dismissed"), + ("concept_determination", "Ready for Preliminary Determination"), + ("concept_internal_review", "Internal Review"), + ("concept_more_info", "More information required"), + ("concept_rejected", "Dismissed"), + ("concept_review_discussion", "Ready For Discussion"), + ("concept_review_more_info", "More information required"), + ("determination", "Ready for Determination"), + ("draft", "Draft"), + ("draft_proposal", "Invited for Proposal"), + ("ext_accepted", "Accepted"), + ("ext_almost", "Accepted but additional info required"), + ("ext_determination", "Ready for Determination"), + ("ext_external_review", "External Review"), + ("ext_int_accepted", "Accepted"), + ("ext_int_determination", "Ready for Determination"), + ("ext_int_external_review", "External Review"), + ("ext_int_internal_review", "Internal Review"), + ("ext_int_more_info", "More information required"), + ("ext_int_post_external_review_discussion", "Ready For Discussion"), + ( + "ext_int_post_external_review_more_info", + "More information required", + ), + ("ext_int_post_review_discussion", "Ready For Discussion"), + ("ext_int_post_review_more_info", "More information required"), + ("ext_int_rejected", "Dismissed"), + ("ext_int_waitlisted", "Waitlisted"), + ("ext_internal_review", "Internal Review"), + ("ext_more_info", "More information required"), + ("ext_post_external_review_discussion", "Ready For Discussion"), + ("ext_post_external_review_more_info", "More information required"), + ("ext_post_review_discussion", "Ready For Discussion"), + ("ext_post_review_more_info", "More information required"), + ("ext_rejected", "Dismissed"), + ("external_review", "External Review"), + ("in_discussion", "Need screening"), + ("internal_review", "Internal Review"), + ("invited_to_proposal", "Concept Accepted"), + ("more_info", "More information required"), + ("post_external_review_discussion", "Ready For Discussion"), + ("post_external_review_more_info", "More information required"), + ("post_proposal_review_discussion", "Ready For Discussion"), + ("post_proposal_review_more_info", "More information required"), + ("post_review_discussion", "Ready For Discussion"), + ("post_review_more_info", "More information required"), + ("proposal_accepted", "Accepted"), + ("proposal_almost", "Accepted but additional info required"), + ("proposal_determination", "Ready for Final Determination"), + ("proposal_discussion", "Proposal Received"), + ("proposal_internal_review", "Internal Review"), + ("proposal_more_info", "More information required"), + ("proposal_rejected", "Dismissed"), + ("rejected", "Dismissed"), + ("same_accepted", "Accepted"), + ("same_almost", "Accepted but additional info required"), + ("same_determination", "Ready for Determination"), + ("same_internal_review", "Review"), + ("same_more_info", "More information required"), + ("same_post_review_discussion", "Ready For Discussion"), + ("same_post_review_more_info", "More information required"), + ("same_rejected", "Dismissed"), + ], + default="in_discussion", + max_length=100, + ), + ), + migrations.AlterField( + model_name="applicationbase", + name="workflow_name", + field=models.CharField( + choices=[ + ("single", "Request"), + ("single_same", "Request with same time review"), + ("single_ext", "Request with external review"), + ("single_com", "Request with community review"), + ("single_ext_int", "Request external then internal review"), + ("double", "Concept & Proposal"), + ], + default="single", + max_length=100, + verbose_name="workflow", + ), + ), + migrations.AlterField( + model_name="applicationsubmission", + name="status", + field=models.CharField( + choices=[ + ("accepted", "Accepted"), + ("almost", "Accepted but additional info required"), + ("com_accepted", "Accepted"), + ("com_almost", "Accepted but additional info required"), + ("com_community_review", "Community Review"), + ("com_determination", "Ready for Determination"), + ("com_external_review", "External Review"), + ("com_internal_review", "Internal Review"), + ("com_more_info", "More information required"), + ("com_open_call", "Open Call (public)"), + ("com_post_external_review_discussion", "Ready For Discussion"), + ("com_post_external_review_more_info", "More information required"), + ("com_post_review_discussion", "Ready For Discussion"), + ("com_post_review_more_info", "More information required"), + ("com_rejected", "Dismissed"), + ("concept_determination", "Ready for Preliminary Determination"), + ("concept_internal_review", "Internal Review"), + ("concept_more_info", "More information required"), + ("concept_rejected", "Dismissed"), + ("concept_review_discussion", "Ready For Discussion"), + ("concept_review_more_info", "More information required"), + ("determination", "Ready for Determination"), + ("draft", "Draft"), + ("draft_proposal", "Invited for Proposal"), + ("ext_accepted", "Accepted"), + ("ext_almost", "Accepted but additional info required"), + ("ext_determination", "Ready for Determination"), + ("ext_external_review", "External Review"), + ("ext_int_accepted", "Accepted"), + ("ext_int_determination", "Ready for Determination"), + ("ext_int_external_review", "External Review"), + ("ext_int_internal_review", "Internal Review"), + ("ext_int_more_info", "More information required"), + ("ext_int_post_external_review_discussion", "Ready For Discussion"), + ( + "ext_int_post_external_review_more_info", + "More information required", + ), + ("ext_int_post_review_discussion", "Ready For Discussion"), + ("ext_int_post_review_more_info", "More information required"), + ("ext_int_rejected", "Dismissed"), + ("ext_int_waitlisted", "Waitlisted"), + ("ext_internal_review", "Internal Review"), + ("ext_more_info", "More information required"), + ("ext_post_external_review_discussion", "Ready For Discussion"), + ("ext_post_external_review_more_info", "More information required"), + ("ext_post_review_discussion", "Ready For Discussion"), + ("ext_post_review_more_info", "More information required"), + ("ext_rejected", "Dismissed"), + ("external_review", "External Review"), + ("in_discussion", "Need screening"), + ("internal_review", "Internal Review"), + ("invited_to_proposal", "Concept Accepted"), + ("more_info", "More information required"), + ("post_external_review_discussion", "Ready For Discussion"), + ("post_external_review_more_info", "More information required"), + ("post_proposal_review_discussion", "Ready For Discussion"), + ("post_proposal_review_more_info", "More information required"), + ("post_review_discussion", "Ready For Discussion"), + ("post_review_more_info", "More information required"), + ("proposal_accepted", "Accepted"), + ("proposal_almost", "Accepted but additional info required"), + ("proposal_determination", "Ready for Final Determination"), + ("proposal_discussion", "Proposal Received"), + ("proposal_internal_review", "Internal Review"), + ("proposal_more_info", "More information required"), + ("proposal_rejected", "Dismissed"), + ("rejected", "Dismissed"), + ("same_accepted", "Accepted"), + ("same_almost", "Accepted but additional info required"), + ("same_determination", "Ready for Determination"), + ("same_internal_review", "Review"), + ("same_more_info", "More information required"), + ("same_post_review_discussion", "Ready For Discussion"), + ("same_post_review_more_info", "More information required"), + ("same_rejected", "Dismissed"), + ], + default="in_discussion", + max_length=100, + verbose_name="status", + ), + ), + migrations.AlterField( + model_name="applicationsubmission", + name="workflow_name", + field=models.CharField( + choices=[ + ("single", "Request"), + ("single_same", "Request with same time review"), + ("single_ext", "Request with external review"), + ("single_com", "Request with community review"), + ("single_ext_int", "Request external then internal review"), + ("double", "Concept & Proposal"), + ], + default="single", + max_length=100, + verbose_name="workflow", + ), + ), + migrations.AlterField( + model_name="labbase", + name="workflow_name", + field=models.CharField( + choices=[ + ("single", "Request"), + ("single_same", "Request with same time review"), + ("single_ext", "Request with external review"), + ("single_com", "Request with community review"), + ("single_ext_int", "Request external then internal review"), + ("double", "Concept & Proposal"), + ], + default="single", + max_length=100, + verbose_name="workflow", + ), + ), + migrations.AlterField( + model_name="roundbase", + name="workflow_name", + field=models.CharField( + choices=[ + ("single", "Request"), + ("single_same", "Request with same time review"), + ("single_ext", "Request with external review"), + ("single_com", "Request with community review"), + ("single_ext_int", "Request external then internal review"), + ("double", "Concept & Proposal"), + ], + default="single", + max_length=100, + verbose_name="workflow", + ), + ), + ] diff --git a/hypha/apply/funds/tests/test_workflows.py b/hypha/apply/funds/tests/test_workflows.py new file mode 100644 index 0000000000..730071798b --- /dev/null +++ b/hypha/apply/funds/tests/test_workflows.py @@ -0,0 +1,113 @@ +"""Tests for the workflow registry, focused on the external-then-internal workflow.""" + +import pytest +from django.test import SimpleTestCase + +from hypha.apply.users.tests.factories import StaffFactory + +from ..workflows import ( + DETERMINATION_OUTCOMES, + WORKFLOWS, + ext_or_higher_statuses, + ext_review_statuses, +) +from ..workflows.constants import DETERMINATION_RESPONSE_PHASES +from .factories import ApplicationSubmissionFactory + +WORKFLOW_NAME = "single_ext_int" + + +class TestRequestExternalInternalWorkflow(SimpleTestCase): + @property + def workflow(self): + return WORKFLOWS[WORKFLOW_NAME] + + def test_workflow_is_registered(self): + self.assertIn(WORKFLOW_NAME, WORKFLOWS) + self.assertEqual( + str(self.workflow.name), "Request external then internal review" + ) + + def test_phases_and_steps(self): + expected = { + "draft": 0, + "in_discussion": 1, + "ext_int_more_info": 1, + "ext_int_external_review": 2, + "ext_int_post_external_review_discussion": 3, + "ext_int_post_external_review_more_info": 3, + "ext_int_internal_review": 4, + "ext_int_post_review_discussion": 5, + "ext_int_post_review_more_info": 5, + "ext_int_determination": 6, + "ext_int_accepted": 7, + "ext_int_waitlisted": 7, + "ext_int_rejected": 7, + } + self.assertEqual( + {name: phase.step for name, phase in self.workflow.items()}, expected + ) + + def test_external_review_comes_before_internal_review(self): + self.assertLess( + self.workflow["ext_int_external_review"].step, + self.workflow["ext_int_internal_review"].step, + ) + + def test_single_stage_with_external_review(self): + (stage,) = self.workflow.stages + self.assertEqual(stage.name, "RequestExtInt") + self.assertTrue(stage.has_external_review) + + def test_waitlisted_is_not_a_determination_outcome(self): + self.assertNotIn("ext_int_waitlisted", DETERMINATION_OUTCOMES) + + def test_waitlisted_transitions(self): + self.assertEqual( + set(self.workflow["ext_int_waitlisted"].transitions), + {"ext_int_accepted", "ext_int_rejected", "ext_int_determination"}, + ) + + def test_waitlist_offered_from_discussion_and_determination(self): + for phase_name in [ + "ext_int_post_review_discussion", + "ext_int_determination", + ]: + with self.subTest(phase=phase_name): + self.assertIn( + "ext_int_waitlisted", self.workflow[phase_name].transitions + ) + + def test_outcome_phases_are_terminal(self): + for phase_name in ["ext_int_accepted", "ext_int_rejected"]: + with self.subTest(phase=phase_name): + self.assertEqual(self.workflow[phase_name].transitions, {}) + + def test_picked_up_by_derived_status_sets(self): + self.assertIn("ext_int_external_review", ext_review_statuses) + self.assertIn("ext_int_internal_review", ext_or_higher_statuses) + self.assertIn("ext_int_post_review_discussion", DETERMINATION_RESPONSE_PHASES) + + +@pytest.mark.django_db +def test_walk_the_whole_workflow(): + """The generated FSM transitions actually fire, in the intended order.""" + staff = StaffFactory() + submission = ApplicationSubmissionFactory(workflow_name=WORKFLOW_NAME) + assert submission.status == "in_discussion" + + chain = [ + "ext_int_external_review", + "ext_int_post_external_review_discussion", + "ext_int_internal_review", + "ext_int_post_review_discussion", + "ext_int_determination", + "ext_int_waitlisted", + "ext_int_accepted", + ] + for target in chain: + submission.perform_transition(target, staff) + submission.save() + assert submission.status == target + + assert submission.phase.display_name == "Accepted" diff --git a/hypha/apply/funds/workflows/constants.py b/hypha/apply/funds/workflows/constants.py index 3785666edc..cc604c1d0b 100644 --- a/hypha/apply/funds/workflows/constants.py +++ b/hypha/apply/funds/workflows/constants.py @@ -22,6 +22,7 @@ "External Review": "bg-yellow-200", "More information required": "bg-yellow-200", "Accepted but additional info required": "bg-green-100", + "Waitlisted": "bg-amber-200", "Dismissed": "bg-rose-200", } @@ -42,6 +43,7 @@ class UserPermissions(Enum): "post_external_review_discussion", "ext_post_external_review_discussion", "com_post_external_review_discussion", + "ext_int_post_review_discussion", ] DETERMINATION_OUTCOMES = get_determination_transitions() diff --git a/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py new file mode 100644 index 0000000000..4c14d1ad11 --- /dev/null +++ b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py @@ -0,0 +1,191 @@ +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from ..constants import DRAFT_STATE, INITIAL_STATE, UserPermissions +from ..models.stage import RequestExtInt +from ..permissions import ( + applicant_edit_permissions, + default_permissions, + hidden_from_applicant_permissions, + no_permissions, + reviewer_review_permissions, + staff_edit_permissions, +) + +SingleStageExtIntDefinition = [ + { + DRAFT_STATE: { + "transitions": { + INITIAL_STATE: { + "display": _("Submit"), + "permissions": {UserPermissions.APPLICANT}, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + }, + "display": _("Draft"), + "stage": RequestExtInt, + "permissions": applicant_edit_permissions, + } + }, + { + INITIAL_STATE: { + "transitions": { + "ext_int_more_info": _("Request More Information"), + "ext_int_external_review": _("Open External Review"), + "ext_int_determination": _("Ready For Determination"), + "ext_int_rejected": _("Dismiss"), + }, + "display": _("Need screening"), + "public": _("Application Received"), + "stage": RequestExtInt, + "permissions": default_permissions, + }, + "ext_int_more_info": { + "transitions": { + INITIAL_STATE: { + "display": _("Submit"), + "permissions": { + UserPermissions.APPLICANT, + UserPermissions.STAFF, + UserPermissions.LEAD, + UserPermissions.ADMIN, + }, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + }, + "display": _("More information required"), + "stage": RequestExtInt, + "permissions": applicant_edit_permissions, + }, + }, + { + "ext_int_external_review": { + "transitions": { + "ext_int_post_external_review_discussion": _("Close Review"), + INITIAL_STATE: _("Need screening (revert)"), + }, + "display": _("External Review"), + "stage": RequestExtInt, + "permissions": reviewer_review_permissions, + }, + }, + { + "ext_int_post_external_review_discussion": { + "transitions": { + "ext_int_post_external_review_more_info": _("Request More Information"), + "ext_int_internal_review": _("Open Internal Review"), + "ext_int_determination": _("Ready For Determination"), + "ext_int_external_review": _("Open External Review (revert)"), + "ext_int_rejected": _("Dismiss"), + }, + "display": _("Ready For Discussion"), + "stage": RequestExtInt, + "permissions": hidden_from_applicant_permissions, + }, + "ext_int_post_external_review_more_info": { + "transitions": { + "ext_int_post_external_review_discussion": { + "display": _("Submit"), + "permissions": { + UserPermissions.APPLICANT, + UserPermissions.STAFF, + UserPermissions.LEAD, + UserPermissions.ADMIN, + }, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + }, + "display": _("More information required"), + "stage": RequestExtInt, + "permissions": applicant_edit_permissions, + }, + }, + { + "ext_int_internal_review": { + "transitions": { + "ext_int_post_review_discussion": _("Close Review"), + "ext_int_post_external_review_discussion": _( + "Ready For Discussion (revert)" + ), + }, + "display": _("Internal Review"), + "public": _("{ORG_SHORT_NAME} Review").format( + ORG_SHORT_NAME=settings.ORG_SHORT_NAME + ), + "stage": RequestExtInt, + "permissions": default_permissions, + }, + }, + { + "ext_int_post_review_discussion": { + "transitions": { + "ext_int_post_review_more_info": _("Request More Information"), + "ext_int_determination": _("Ready For Determination"), + "ext_int_internal_review": _("Open Internal Review (revert)"), + "ext_int_accepted": _("Accept"), + "ext_int_waitlisted": _("Waitlist"), + "ext_int_rejected": _("Dismiss"), + }, + "display": _("Ready For Discussion"), + "stage": RequestExtInt, + "permissions": hidden_from_applicant_permissions, + }, + "ext_int_post_review_more_info": { + "transitions": { + "ext_int_post_review_discussion": { + "display": _("Submit"), + "permissions": { + UserPermissions.APPLICANT, + UserPermissions.STAFF, + UserPermissions.LEAD, + UserPermissions.ADMIN, + }, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + }, + "display": _("More information required"), + "stage": RequestExtInt, + "permissions": applicant_edit_permissions, + }, + }, + { + "ext_int_determination": { + "transitions": { + "ext_int_post_review_discussion": _("Ready For Discussion (revert)"), + "ext_int_accepted": _("Accept"), + "ext_int_waitlisted": _("Waitlist"), + "ext_int_rejected": _("Dismiss"), + }, + "display": _("Ready for Determination"), + "permissions": hidden_from_applicant_permissions, + "stage": RequestExtInt, + }, + }, + { + "ext_int_accepted": { + "display": _("Accepted"), + "future": _("Application Outcome"), + "stage": RequestExtInt, + "permissions": staff_edit_permissions, + }, + "ext_int_waitlisted": { + "transitions": { + "ext_int_accepted": _("Accept"), + "ext_int_rejected": _("Dismiss"), + "ext_int_determination": _("Ready for Determination (revert)"), + }, + "display": _("Waitlisted"), + "stage": RequestExtInt, + "permissions": staff_edit_permissions, + }, + "ext_int_rejected": { + "display": _("Dismissed"), + "stage": RequestExtInt, + "permissions": no_permissions, + }, + }, +] diff --git a/hypha/apply/funds/workflows/models/stage.py b/hypha/apply/funds/workflows/models/stage.py index aef13548a5..72e3d93eb1 100644 --- a/hypha/apply/funds/workflows/models/stage.py +++ b/hypha/apply/funds/workflows/models/stage.py @@ -17,5 +17,6 @@ def __repr__(self) -> str: RequestSame = Stage("RequestSame", True) RequestExt = Stage("RequestExt", True) RequestCom = Stage("RequestCom", True) +RequestExtInt = Stage("RequestExtInt", True) Concept = Stage("Concept") Proposal = Stage("Proposal", True) diff --git a/hypha/apply/funds/workflows/registry.py b/hypha/apply/funds/workflows/registry.py index 57026dcb75..8b597b25a0 100644 --- a/hypha/apply/funds/workflows/registry.py +++ b/hypha/apply/funds/workflows/registry.py @@ -19,6 +19,7 @@ from .definitions.double_stage import DoubleStageDefinition from .definitions.single_stage import SingleStageDefinition from .definitions.single_stage_community import SingleStageCommunityDefinition +from .definitions.single_stage_ext_int import SingleStageExtIntDefinition from .definitions.single_stage_external import SingleStageExternalDefinition from .definitions.single_stage_same import SingleStageSameDefinition from .models.phase import Phase @@ -83,6 +84,12 @@ def unpack_phases(phases): **phase_data(SingleStageCommunityDefinition), ) +RequestExternalInternal = Workflow( + _("Request external then internal review"), + "single_ext_int", + **phase_data(SingleStageExtIntDefinition), +) + ConceptProposal = Workflow( _("Concept & Proposal"), "double", **phase_data(DoubleStageDefinition) ) @@ -92,6 +99,7 @@ def unpack_phases(phases): RequestSameTime.admin_name: RequestSameTime, RequestExternal.admin_name: RequestExternal, RequestCommunity.admin_name: RequestCommunity, + RequestExternalInternal.admin_name: RequestExternalInternal, ConceptProposal.admin_name: ConceptProposal, } diff --git a/hypha/apply/review/views.py b/hypha/apply/review/views.py index d75c1e33d0..c1983c03ec 100644 --- a/hypha/apply/review/views.py +++ b/hypha/apply/review/views.py @@ -215,6 +215,13 @@ def review_workflow_actions(request, submission): elif transition_after and submission.status == "proposal_discussion": # Automatically transition the proposal to "Internal review". action = "proposal_internal_review" + elif ( + transition_after + and submission.status == "ext_int_external_review" + and submission.reviews.by_reviewers().count() >= transition_after + ): + # Automatically transition the application to "Ready for discussion". + action = "ext_int_post_external_review_discussion" elif ( transition_after and submission.status == submission_stepped_phases[2][0].name @@ -243,6 +250,13 @@ def review_workflow_actions(request, submission): ): # Automatically transition the proposal to "Ready for discussion". action = "post_external_review_discussion" + elif ( + transition_after + and submission.status == "ext_int_internal_review" + and submission.reviews.count() >= transition_after + ): + # Automatically transition the application to "Ready for discussion". + action = "ext_int_post_review_discussion" # If action is set run perform_transition(). if action: From 6a846e9e5199d46a248a5fa2d2b0e383a96dd1d2 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Tue, 25 Aug 2026 14:15:59 +0200 Subject: [PATCH 02/11] Make it possible to accepts after only external review. --- hypha/apply/funds/workflows/definitions/single_stage_ext_int.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py index 4c14d1ad11..d45e6f5d03 100644 --- a/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py +++ b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py @@ -78,6 +78,8 @@ "ext_int_internal_review": _("Open Internal Review"), "ext_int_determination": _("Ready For Determination"), "ext_int_external_review": _("Open External Review (revert)"), + "ext_int_accepted": _("Accept"), + "ext_int_waitlisted": _("Waitlist"), "ext_int_rejected": _("Dismiss"), }, "display": _("Ready For Discussion"), From 72d0dbf761180a3002baa23d0a85e64825b3f72e Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Wed, 26 Aug 2026 08:46:41 +0200 Subject: [PATCH 03/11] Public names and permission adjustmnets. --- .../funds/workflows/definitions/single_stage_ext_int.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py index d45e6f5d03..51935d21ca 100644 --- a/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py +++ b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.utils.translation import gettext_lazy as _ from ..constants import DRAFT_STATE, INITIAL_STATE, UserPermissions @@ -67,6 +66,7 @@ INITIAL_STATE: _("Need screening (revert)"), }, "display": _("External Review"), + "public": _("Application Review"), "stage": RequestExtInt, "permissions": reviewer_review_permissions, }, @@ -114,11 +114,8 @@ ), }, "display": _("Internal Review"), - "public": _("{ORG_SHORT_NAME} Review").format( - ORG_SHORT_NAME=settings.ORG_SHORT_NAME - ), "stage": RequestExtInt, - "permissions": default_permissions, + "permissions": hidden_from_applicant_permissions, }, }, { From e7ed1e0a350e05e3d7bc17adc99de2f708611449 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Wed, 26 Aug 2026 10:29:23 +0200 Subject: [PATCH 04/11] Update documentation for workflows. --- docs/references/workflows.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/references/workflows.md b/docs/references/workflows.md index 5f666e07dd..5da382c273 100644 --- a/docs/references/workflows.md +++ b/docs/references/workflows.md @@ -117,6 +117,8 @@ Once an application is submitted (`INITIAL_STATE`) β€” it can transition into th This workflow is a single stage process with both an external review and an internal review stage. It is very similar to the "Request with external review" workflow, see above, but the order of the two review steps is reversed: the external reviewers see the application first, and the internal review only opens once the external review has been closed. +The internal review is optional β€” an application can be accepted, waitlisted or dismissed straight after the external review has been closed. + It is also the only workflow that offers **Waitlisted** as an outcome, alongside Accepted and Dismissed. **Proposal Persona:** @@ -127,7 +129,7 @@ Once an application is submitted (`INITIAL_STATE`) β€” it can transition into th - A request for more information (`ext_int_more_info`) β€” opens editing permissions to applicant again to revise their application to provide the information requested by the screeners. - Open external review (`ext_int_external_review`) β€” can transition only between closing the review period (`ext_int_post_external_review_discussion`) and reverting back to the internal screening phase. - - `ext_int_post_external_review_discussion` β€” after the external review is closed, you can request more information (`ext_int_post_external_review_more_info`), open the internal review (`ext_int_internal_review`), mark ready for determination (`ext_int_determination`), revert back to opening the external review (`ext_int_external_review`), or dismiss (`ext_int_rejected`) + - `ext_int_post_external_review_discussion` β€” after the external review is closed, you can request more information (`ext_int_post_external_review_more_info`), open the internal review (`ext_int_internal_review`), mark ready for determination (`ext_int_determination`), revert back to opening the external review (`ext_int_external_review`), accept (`ext_int_accepted`), waitlist (`ext_int_waitlisted`) or dismiss (`ext_int_rejected`) - `ext_int_post_external_review_more_info` β€” opens editing permissions to applicant again to revise their application to provide the information requested by the external reviewers. - Open internal review (`ext_int_internal_review`) β€” can transition only between closing the review period (`ext_int_post_review_discussion`) and reverting back to discussion (`ext_int_post_external_review_discussion`) - `ext_int_post_review_discussion` β€” after the internal review is closed, you can request more information (`ext_int_post_review_more_info`), mark ready for determination (`ext_int_determination`), revert back to opening the internal review (`ext_int_internal_review`), accept (`ext_int_accepted`), waitlist (`ext_int_waitlisted`) or dismiss (`ext_int_rejected`) @@ -135,10 +137,29 @@ Once an application is submitted (`INITIAL_STATE`) β€” it can transition into th - Ready for determination (`ext_int_determination`) β€” can revert back to discussion (β€œReady For Discussion (revert)” β€” `ext_int_post_review_discussion`), accept (`ext_int_accepted`), waitlist (`ext_int_waitlisted`) or dismiss (`ext_int_rejected`) - Waitlisted (`ext_int_waitlisted`) β€” the application is kept in play without a decision. Can transition to accept (`ext_int_accepted`), dismiss (`ext_int_rejected`), or revert back to ready for determination (`ext_int_determination`). Staff can still edit this submission. Waitlisting is a plain status change β€” unlike Accept and Dismiss it does not open the determination form and does not create a determination record. - Accepted (`ext_int_accepted`) β€” application accepted. Staff can still edit this submission. -- Dismissed (`ext_int_rejected`) β€” application rejected. Permissions removed from all roles. +- Dismissed (`ext_int_rejected`) β€” application rejected. Editing and reviewing permissions removed from all roles, the applicant can still view the submission. There is no "Accepted but additional info required" state in this workflow. +If the `TRANSITION_AFTER_REVIEWS` setting is set to a number, submitting a review can move the application on automatically: + +- Need screening β†’ External Review, as soon as the first review is submitted. The configured number is not taken into account for this step, and only staff can review during screening. +- External Review β†’ Ready For Discussion, once that many reviews have been submitted by users in the Reviewer group. +- Internal Review β†’ Ready For Discussion, once that many reviews have been submitted in total. + +The automatic transition is skipped if the user submitting the review is not allowed to make it. + +**What the applicant sees** + +Everything from the first discussion onwards is hidden from the applicant. The status bar shows four steps: + +1. **Draft** (`draft`) +2. **Application Received** (`INITIAL_STATE`, "Need screening" to staff) +3. **Application Review** (`ext_int_external_review`, "External Review" to staff) +4. **Application Outcome** β€” shown as Accepted (`ext_int_accepted`), Waitlisted (`ext_int_waitlisted`) or Dismissed (`ext_int_rejected`) once the application reaches that step + +The hidden phases (`ext_int_post_external_review_discussion`, `ext_int_internal_review`, `ext_int_post_review_discussion` and `ext_int_determination`) do not add a step of their own β€” to the applicant the application stays on "Application Review" until an outcome is reached. The three "More information required" states are visible to the applicant, since they need to edit and resubmit their application. + ### πŸ’‘ Concept and Proposal This workflow is a two-stage process: the first stage is the request and the second stage includes an advisory council review or external review stage. From f4ff4d8e9d96d18b1c861c0eaf107b9b423b1735 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Wed, 26 Aug 2026 14:05:24 +0200 Subject: [PATCH 05/11] Fix so PHASE_BG_COLORS works for languages other than English. --- hypha/apply/activity/adapters/activity_feed.py | 14 ++++++-------- hypha/apply/funds/workflows/models/phase.py | 6 +++++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/hypha/apply/activity/adapters/activity_feed.py b/hypha/apply/activity/adapters/activity_feed.py index 0c0a92817a..9101e81e15 100644 --- a/hypha/apply/activity/adapters/activity_feed.py +++ b/hypha/apply/activity/adapters/activity_feed.py @@ -6,7 +6,6 @@ from hypha.apply.activity.models import ALL, APPLICANT, TEAM from hypha.apply.activity.options import MESSAGES -from hypha.apply.funds.workflows.constants import PHASE_BG_COLORS from hypha.apply.projects.utils import ( get_invoice_public_status, get_invoice_status_display_value, @@ -227,9 +226,8 @@ def handle_task_removal(self, source, task, **kwargs): ) def handle_transition(self, old_phase, source, **kwargs): - def wrap_in_color_class(text): - color_class = PHASE_BG_COLORS.get(text, "") - return f'{text}' + def wrap_in_color_class(phase, text): + return f'{text}' submission = source base_message = _("Progressed from {old_display} to {new_display}") @@ -237,8 +235,8 @@ def wrap_in_color_class(text): new_phase = submission.phase staff_message = base_message.format( - old_display=wrap_in_color_class(old_phase.display_name), - new_display=wrap_in_color_class(new_phase.display_name), + old_display=wrap_in_color_class(old_phase, old_phase.display_name), + new_display=wrap_in_color_class(new_phase, new_phase.display_name), ) if new_phase.permissions.can_view(submission.user): @@ -249,8 +247,8 @@ def wrap_in_color_class(text): ) applicant_message = base_message.format( - old_display=wrap_in_color_class(old_phase.public_name), - new_display=wrap_in_color_class(new_phase.public_name), + old_display=wrap_in_color_class(old_phase, old_phase.public_name), + new_display=wrap_in_color_class(new_phase, new_phase.public_name), ) return json.dumps( diff --git a/hypha/apply/funds/workflows/models/phase.py b/hypha/apply/funds/workflows/models/phase.py index be390069ae..9b585cfe30 100644 --- a/hypha/apply/funds/workflows/models/phase.py +++ b/hypha/apply/funds/workflows/models/phase.py @@ -1,4 +1,5 @@ from django.utils.text import slugify +from django.utils.translation import override from ..constants import PHASE_BG_COLORS, UserPermissions from ..permissions import Permissions @@ -33,7 +34,10 @@ def __init__( self.public_name = str(public) if public else self.display_name self.future_name_staff = str(future) if future else self.display_name - self.bg_color = PHASE_BG_COLORS.get(self.display_name, "bg-gray-200") + # Look the colour up against the untranslated source string, since + # PHASE_BG_COLORS is keyed by the English msgids. + with override(None): + self.bg_color = PHASE_BG_COLORS.get(str(display), "bg-gray-200") self.future_name_public = str(future) if future else self.public_name self.stage = stage self.permissions = Permissions(permissions) From cb762f34beebabdd169d888808e029c8b5545ed2 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Wed, 26 Aug 2026 14:12:21 +0200 Subject: [PATCH 06/11] Fix so display_slug works for languages other than English. --- hypha/apply/funds/tables.py | 6 +++--- hypha/apply/funds/workflows/__init__.py | 2 ++ hypha/apply/funds/workflows/models/phase.py | 11 +++++++---- hypha/apply/funds/workflows/registry.py | 4 ++++ 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/hypha/apply/funds/tables.py b/hypha/apply/funds/tables.py index 1118a22e1a..d189e3d58c 100644 --- a/hypha/apply/funds/tables.py +++ b/hypha/apply/funds/tables.py @@ -22,7 +22,7 @@ from .models import ApplicationSubmission, Round, ScreeningStatus from .widgets import MultiCheckboxesWidget -from .workflows import STATUSES +from .workflows import STATUS_SLUGS, STATUSES User = get_user_model() @@ -210,12 +210,12 @@ class ModelMultipleChoiceFilter( class StatusMultipleChoiceFilter(MultipleChoiceFilter): def __init__(self, limit_to, *args, **kwargs): choices = [ - (slugify(name), name) + (STATUS_SLUGS[name], name) for name, statuses in STATUSES.items() if not limit_to or self.has_any(statuses, limit_to) ] self.status_map = { - slugify(name): list(status) for name, status in STATUSES.items() + STATUS_SLUGS[name]: list(status) for name, status in STATUSES.items() } super().__init__( *args, diff --git a/hypha/apply/funds/workflows/__init__.py b/hypha/apply/funds/workflows/__init__.py index 47d97de00c..1700a53507 100644 --- a/hypha/apply/funds/workflows/__init__.py +++ b/hypha/apply/funds/workflows/__init__.py @@ -26,6 +26,7 @@ from .models.stage import Stage from .registry import ( PHASES, + STATUS_SLUGS, STATUSES, WORKFLOWS, accepted_statuses, @@ -46,6 +47,7 @@ "INITIAL_STATE", "PHASES", "STAGE_CHANGE_ACTIONS", + "STATUS_SLUGS", "STATUSES", "Stage", "UserPermissions", diff --git a/hypha/apply/funds/workflows/models/phase.py b/hypha/apply/funds/workflows/models/phase.py index 9b585cfe30..a8dcae257f 100644 --- a/hypha/apply/funds/workflows/models/phase.py +++ b/hypha/apply/funds/workflows/models/phase.py @@ -28,16 +28,19 @@ def __init__( transitions = {} self.name = name self.display_name = str(display) - self.display_slug = slugify(display) if public and future: raise ValueError("Cant provide both a future and a public name") self.public_name = str(public) if public else self.display_name self.future_name_staff = str(future) if future else self.display_name - # Look the colour up against the untranslated source string, since - # PHASE_BG_COLORS is keyed by the English msgids. + # Derive the slug and the colour from the untranslated source string: + # PHASE_BG_COLORS is keyed by the English msgids, and the slug is used + # as a filter value in URLs, so it must not vary with the active + # language. with override(None): - self.bg_color = PHASE_BG_COLORS.get(str(display), "bg-gray-200") + source_display = str(display) + self.display_slug = slugify(source_display) + self.bg_color = PHASE_BG_COLORS.get(source_display, "bg-gray-200") self.future_name_public = str(future) if future else self.public_name self.stage = stage self.permissions = Permissions(permissions) diff --git a/hypha/apply/funds/workflows/registry.py b/hypha/apply/funds/workflows/registry.py index 8b597b25a0..429b27f67c 100644 --- a/hypha/apply/funds/workflows/registry.py +++ b/hypha/apply/funds/workflows/registry.py @@ -112,6 +112,10 @@ def unpack_phases(phases): for key, value in PHASES: STATUSES[value.display_name].add(key) +# Stable, language-independent slug for each display name. Filter values are +# built from these so that a filtered URL keeps working across languages. +STATUS_SLUGS = {value.display_name: value.display_slug for _, value in PHASES} + active_statuses = [ status for status, _ in PHASES From 597cbff1b2086b639ce69d14ac1d63fba552e650 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Wed, 26 Aug 2026 14:31:03 +0200 Subject: [PATCH 07/11] =?UTF-8?q?Fix=20inconsistent=20for/For.=20Now=20all?= =?UTF-8?q?=20states=20are=20Ready=20for=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/references/workflows.md | 12 +- ...er_anonymizedsubmission_status_and_more.py | 185 ++++++++++++++++++ hypha/apply/funds/workflows/constants.py | 2 +- .../workflows/definitions/double_stage.py | 30 +-- .../workflows/definitions/single_stage.py | 14 +- .../definitions/single_stage_community.py | 16 +- .../definitions/single_stage_ext_int.py | 14 +- .../definitions/single_stage_external.py | 16 +- .../definitions/single_stage_same.py | 10 +- 9 files changed, 242 insertions(+), 57 deletions(-) create mode 100644 hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py diff --git a/docs/references/workflows.md b/docs/references/workflows.md index 5da382c273..31829ef08d 100644 --- a/docs/references/workflows.md +++ b/docs/references/workflows.md @@ -37,7 +37,7 @@ Once an application is submitted (`INITIAL_STATE`) β€” it can transition into th - Open review (`internal_review`) β€” can transition only between closing review period (`post_review_discussion`) and reverting back to the internal screening phase. - `post_review_discussion` β€” after review is closed, you can request more information (`post_review_more_info`), ready for determination (`determination`), revert back to opening the review (`internal_review`), accept but additional info is needed (`almost`), accept (`accepted`) or reject (`rejected`) - `post_review_more_info` β€” opens editing permissions to applicant again to revise their application to provide the information requested by the reviewers. -- Ready for determination (`determination`) β€” can revert back to discussion (β€œReady For Discussion (revert)” β€” `post_review_discussion`) , or accept with additional info needed (`almost`), accept (`accepted`) or reject (`rejected`) +- Ready for determination (`determination`) β€” can revert back to discussion (β€œReady for Discussion (revert)” β€” `post_review_discussion`) , or accept with additional info needed (`almost`), accept (`accepted`) or reject (`rejected`) - Accepted, but additional info is needed (`almost`) β€” opens editing permissions to applicant again to revise their application to provide the information requested by the reviewers, admin, or staff positions. - Accepted (`accepted`) β€” application accepted. Staff can still edit this submission. - Rejected (`rejected`) β€” application rejected. Permissions removed from all roles. @@ -56,7 +56,7 @@ Once an application is submitted (`INITIAL_STATE`) β€” it can transition into th - Open review (`same_internal_review`) β€” can transition only between closing review period (`same_post_review_discussion`) and reverting back to the internal screening phase. - `same_post_review_discussion` β€” after review is closed, you can request more information (`same_post_review_more_info`), ready for determination (`same_determination`), revert back to opening the review (`same_internal_review`), or reject (`same_rejected`) - `same_post_review_more_info` β€” opens editing permissions to applicant again to revise their application to provide the information requested by the screeners. -- Ready for determination (`same_determination`) β€” can revert back to discussion (β€œReady For Discussion (revert)” β€” `same_post_review_discussion`) , or accept with additional info needed (`same_almost`), accept (`same_accepted`) or reject (`same_rejected`) +- Ready for determination (`same_determination`) β€” can revert back to discussion (β€œReady for Discussion (revert)” β€” `same_post_review_discussion`) , or accept with additional info needed (`same_almost`), accept (`same_accepted`) or reject (`same_rejected`) - Accepted, but additional info is needed (`same_almost`) β€” opens editing permissions to applicant again to revise their application to provide the information requested by the reviewers, admin, or staff positions. - Accepted (`same_accepted`) β€” application accepted. Staff can still edit this submission. - Rejected (`same_rejected`) β€” application rejected. Permissions removed from all roles. @@ -80,7 +80,7 @@ Once an application is submitted (`INITIAL_STATE`) β€” it can transition into th - External review (`ext_external_review`) β€” can only transition between closing the review (`ext_post_external_review_discussion`) and reverting back to discussion (`ext_post_review_discussion`) - `ext_post_external_review_discussion` β€” can transition to a request more information (`ext_post_external_review_more_info`), ready for determination (`ext_determination`), revert back to opening the external review (`ext_external_review`), accept but additional info is needed (`ext_almost`), accept (`ext_accepted`) or reject (`ext_rejected`) - `ext_post_external_review_more_info` β€” opens editing permissions to applicant again to revise their application to provide the information requested by the reviewers. -- Ready for determination (`ext_determination`) β€” can revert back to discussion (β€œReady For Discussion (revert)” β€” `ext_post_external_review_discussion`) , or accept with additional info needed (`ext_almost`), accept (`ext_accepted`) or reject (`ext_rejected`) +- Ready for determination (`ext_determination`) β€” can revert back to discussion (β€œReady for Discussion (revert)” β€” `ext_post_external_review_discussion`) , or accept with additional info needed (`ext_almost`), accept (`ext_accepted`) or reject (`ext_rejected`) - Accepted, but additional info is needed (`ext_almost`) β€” opens editing permissions to applicant again to revise their application to provide the information requested by the reviewers, admin, or staff positions. - Accepted (`ext_accepted`) β€” application accepted. Staff can still edit this submission. - Rejected (`ext_rejected`) β€” application rejected. Permissions removed from all roles. @@ -134,7 +134,7 @@ Once an application is submitted (`INITIAL_STATE`) β€” it can transition into th - Open internal review (`ext_int_internal_review`) β€” can transition only between closing the review period (`ext_int_post_review_discussion`) and reverting back to discussion (`ext_int_post_external_review_discussion`) - `ext_int_post_review_discussion` β€” after the internal review is closed, you can request more information (`ext_int_post_review_more_info`), mark ready for determination (`ext_int_determination`), revert back to opening the internal review (`ext_int_internal_review`), accept (`ext_int_accepted`), waitlist (`ext_int_waitlisted`) or dismiss (`ext_int_rejected`) - `ext_int_post_review_more_info` β€” opens editing permissions to applicant again to revise their application to provide the information requested by the reviewers. -- Ready for determination (`ext_int_determination`) β€” can revert back to discussion (β€œReady For Discussion (revert)” β€” `ext_int_post_review_discussion`), accept (`ext_int_accepted`), waitlist (`ext_int_waitlisted`) or dismiss (`ext_int_rejected`) +- Ready for determination (`ext_int_determination`) β€” can revert back to discussion (β€œReady for Discussion (revert)” β€” `ext_int_post_review_discussion`), accept (`ext_int_accepted`), waitlist (`ext_int_waitlisted`) or dismiss (`ext_int_rejected`) - Waitlisted (`ext_int_waitlisted`) β€” the application is kept in play without a decision. Can transition to accept (`ext_int_accepted`), dismiss (`ext_int_rejected`), or revert back to ready for determination (`ext_int_determination`). Staff can still edit this submission. Waitlisting is a plain status change β€” unlike Accept and Dismiss it does not open the determination form and does not create a determination record. - Accepted (`ext_int_accepted`) β€” application accepted. Staff can still edit this submission. - Dismissed (`ext_int_rejected`) β€” application rejected. Editing and reviewing permissions removed from all roles, the applicant can still view the submission. @@ -144,8 +144,8 @@ There is no "Accepted but additional info required" state in this workflow. If the `TRANSITION_AFTER_REVIEWS` setting is set to a number, submitting a review can move the application on automatically: - Need screening β†’ External Review, as soon as the first review is submitted. The configured number is not taken into account for this step, and only staff can review during screening. -- External Review β†’ Ready For Discussion, once that many reviews have been submitted by users in the Reviewer group. -- Internal Review β†’ Ready For Discussion, once that many reviews have been submitted in total. +- External Review β†’ Ready for Discussion, once that many reviews have been submitted by users in the Reviewer group. +- Internal Review β†’ Ready for Discussion, once that many reviews have been submitted in total. The automatic transition is skipped if the user submitting the review is not allowed to make it. diff --git a/hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py b/hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py new file mode 100644 index 0000000000..cb63f91eea --- /dev/null +++ b/hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py @@ -0,0 +1,185 @@ +# Generated by Django 5.2.17 on 2026-08-26 12:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("funds", "0136_add_external_then_internal_review_workflow"), + ] + + operations = [ + migrations.AlterField( + model_name="anonymizedsubmission", + name="status", + field=models.CharField( + choices=[ + ("accepted", "Accepted"), + ("almost", "Accepted but additional info required"), + ("com_accepted", "Accepted"), + ("com_almost", "Accepted but additional info required"), + ("com_community_review", "Community Review"), + ("com_determination", "Ready for Determination"), + ("com_external_review", "External Review"), + ("com_internal_review", "Internal Review"), + ("com_more_info", "More information required"), + ("com_open_call", "Open Call (public)"), + ("com_post_external_review_discussion", "Ready for Discussion"), + ("com_post_external_review_more_info", "More information required"), + ("com_post_review_discussion", "Ready for Discussion"), + ("com_post_review_more_info", "More information required"), + ("com_rejected", "Dismissed"), + ("concept_determination", "Ready for Preliminary Determination"), + ("concept_internal_review", "Internal Review"), + ("concept_more_info", "More information required"), + ("concept_rejected", "Dismissed"), + ("concept_review_discussion", "Ready for Discussion"), + ("concept_review_more_info", "More information required"), + ("determination", "Ready for Determination"), + ("draft", "Draft"), + ("draft_proposal", "Invited for Proposal"), + ("ext_accepted", "Accepted"), + ("ext_almost", "Accepted but additional info required"), + ("ext_determination", "Ready for Determination"), + ("ext_external_review", "External Review"), + ("ext_int_accepted", "Accepted"), + ("ext_int_determination", "Ready for Determination"), + ("ext_int_external_review", "External Review"), + ("ext_int_internal_review", "Internal Review"), + ("ext_int_more_info", "More information required"), + ("ext_int_post_external_review_discussion", "Ready for Discussion"), + ( + "ext_int_post_external_review_more_info", + "More information required", + ), + ("ext_int_post_review_discussion", "Ready for Discussion"), + ("ext_int_post_review_more_info", "More information required"), + ("ext_int_rejected", "Dismissed"), + ("ext_int_waitlisted", "Waitlisted"), + ("ext_internal_review", "Internal Review"), + ("ext_more_info", "More information required"), + ("ext_post_external_review_discussion", "Ready for Discussion"), + ("ext_post_external_review_more_info", "More information required"), + ("ext_post_review_discussion", "Ready for Discussion"), + ("ext_post_review_more_info", "More information required"), + ("ext_rejected", "Dismissed"), + ("external_review", "External Review"), + ("in_discussion", "Need screening"), + ("internal_review", "Internal Review"), + ("invited_to_proposal", "Concept Accepted"), + ("more_info", "More information required"), + ("post_external_review_discussion", "Ready for Discussion"), + ("post_external_review_more_info", "More information required"), + ("post_proposal_review_discussion", "Ready for Discussion"), + ("post_proposal_review_more_info", "More information required"), + ("post_review_discussion", "Ready for Discussion"), + ("post_review_more_info", "More information required"), + ("proposal_accepted", "Accepted"), + ("proposal_almost", "Accepted but additional info required"), + ("proposal_determination", "Ready for Final Determination"), + ("proposal_discussion", "Proposal Received"), + ("proposal_internal_review", "Internal Review"), + ("proposal_more_info", "More information required"), + ("proposal_rejected", "Dismissed"), + ("rejected", "Dismissed"), + ("same_accepted", "Accepted"), + ("same_almost", "Accepted but additional info required"), + ("same_determination", "Ready for Determination"), + ("same_internal_review", "Review"), + ("same_more_info", "More information required"), + ("same_post_review_discussion", "Ready for Discussion"), + ("same_post_review_more_info", "More information required"), + ("same_rejected", "Dismissed"), + ], + default="in_discussion", + max_length=100, + ), + ), + migrations.AlterField( + model_name="applicationsubmission", + name="status", + field=models.CharField( + choices=[ + ("accepted", "Accepted"), + ("almost", "Accepted but additional info required"), + ("com_accepted", "Accepted"), + ("com_almost", "Accepted but additional info required"), + ("com_community_review", "Community Review"), + ("com_determination", "Ready for Determination"), + ("com_external_review", "External Review"), + ("com_internal_review", "Internal Review"), + ("com_more_info", "More information required"), + ("com_open_call", "Open Call (public)"), + ("com_post_external_review_discussion", "Ready for Discussion"), + ("com_post_external_review_more_info", "More information required"), + ("com_post_review_discussion", "Ready for Discussion"), + ("com_post_review_more_info", "More information required"), + ("com_rejected", "Dismissed"), + ("concept_determination", "Ready for Preliminary Determination"), + ("concept_internal_review", "Internal Review"), + ("concept_more_info", "More information required"), + ("concept_rejected", "Dismissed"), + ("concept_review_discussion", "Ready for Discussion"), + ("concept_review_more_info", "More information required"), + ("determination", "Ready for Determination"), + ("draft", "Draft"), + ("draft_proposal", "Invited for Proposal"), + ("ext_accepted", "Accepted"), + ("ext_almost", "Accepted but additional info required"), + ("ext_determination", "Ready for Determination"), + ("ext_external_review", "External Review"), + ("ext_int_accepted", "Accepted"), + ("ext_int_determination", "Ready for Determination"), + ("ext_int_external_review", "External Review"), + ("ext_int_internal_review", "Internal Review"), + ("ext_int_more_info", "More information required"), + ("ext_int_post_external_review_discussion", "Ready for Discussion"), + ( + "ext_int_post_external_review_more_info", + "More information required", + ), + ("ext_int_post_review_discussion", "Ready for Discussion"), + ("ext_int_post_review_more_info", "More information required"), + ("ext_int_rejected", "Dismissed"), + ("ext_int_waitlisted", "Waitlisted"), + ("ext_internal_review", "Internal Review"), + ("ext_more_info", "More information required"), + ("ext_post_external_review_discussion", "Ready for Discussion"), + ("ext_post_external_review_more_info", "More information required"), + ("ext_post_review_discussion", "Ready for Discussion"), + ("ext_post_review_more_info", "More information required"), + ("ext_rejected", "Dismissed"), + ("external_review", "External Review"), + ("in_discussion", "Need screening"), + ("internal_review", "Internal Review"), + ("invited_to_proposal", "Concept Accepted"), + ("more_info", "More information required"), + ("post_external_review_discussion", "Ready for Discussion"), + ("post_external_review_more_info", "More information required"), + ("post_proposal_review_discussion", "Ready for Discussion"), + ("post_proposal_review_more_info", "More information required"), + ("post_review_discussion", "Ready for Discussion"), + ("post_review_more_info", "More information required"), + ("proposal_accepted", "Accepted"), + ("proposal_almost", "Accepted but additional info required"), + ("proposal_determination", "Ready for Final Determination"), + ("proposal_discussion", "Proposal Received"), + ("proposal_internal_review", "Internal Review"), + ("proposal_more_info", "More information required"), + ("proposal_rejected", "Dismissed"), + ("rejected", "Dismissed"), + ("same_accepted", "Accepted"), + ("same_almost", "Accepted but additional info required"), + ("same_determination", "Ready for Determination"), + ("same_internal_review", "Review"), + ("same_more_info", "More information required"), + ("same_post_review_discussion", "Ready for Discussion"), + ("same_post_review_more_info", "More information required"), + ("same_rejected", "Dismissed"), + ], + default="in_discussion", + max_length=100, + verbose_name="status", + ), + ), + ] diff --git a/hypha/apply/funds/workflows/constants.py b/hypha/apply/funds/workflows/constants.py index cc604c1d0b..0c6d20ac17 100644 --- a/hypha/apply/funds/workflows/constants.py +++ b/hypha/apply/funds/workflows/constants.py @@ -16,7 +16,7 @@ "Accepted": "bg-green-200", "Need screening": "bg-cyan-200", "Ready for Determination": "bg-blue-200", - "Ready For Discussion": "bg-blue-100", + "Ready for Discussion": "bg-blue-100", "Invited for Proposal": "bg-green-100", "Internal Review": "bg-yellow-200", "External Review": "bg-yellow-200", diff --git a/hypha/apply/funds/workflows/definitions/double_stage.py b/hypha/apply/funds/workflows/definitions/double_stage.py index 2c96015d45..22d20c96b4 100644 --- a/hypha/apply/funds/workflows/definitions/double_stage.py +++ b/hypha/apply/funds/workflows/definitions/double_stage.py @@ -33,7 +33,7 @@ "transitions": { "concept_more_info": _("Request More Information"), "concept_internal_review": _("Open Review"), - "concept_determination": _("Ready For Preliminary Determination"), + "concept_determination": _("Ready for Preliminary Determination"), "invited_to_proposal": _("Invite to Proposal"), "concept_rejected": _("Dismiss"), }, @@ -57,7 +57,7 @@ }, "concept_rejected": _("Dismiss"), "invited_to_proposal": _("Invite to Proposal"), - "concept_determination": _("Ready For Preliminary Determination"), + "concept_determination": _("Ready for Preliminary Determination"), }, "display": _("More information required"), "stage": Concept, @@ -83,12 +83,12 @@ "concept_review_discussion": { "transitions": { "concept_review_more_info": _("Request More Information"), - "concept_determination": _("Ready For Preliminary Determination"), + "concept_determination": _("Ready for Preliminary Determination"), "concept_internal_review": _("Open Review (revert)"), "invited_to_proposal": _("Invite to Proposal"), "concept_rejected": _("Dismiss"), }, - "display": _("Ready For Discussion"), + "display": _("Ready for Discussion"), "stage": Concept, "permissions": hidden_from_applicant_permissions, }, @@ -115,7 +115,7 @@ { "concept_determination": { "transitions": { - "concept_review_discussion": _("Ready For Discussion (revert)"), + "concept_review_discussion": _("Ready for Discussion (revert)"), "invited_to_proposal": _("Invite to Proposal"), "concept_rejected": _("Dismiss"), }, @@ -159,7 +159,7 @@ "custom": {"trigger_on_submit": True}, }, "external_review": _("Open External Review"), - "proposal_determination": _("Ready For Final Determination"), + "proposal_determination": _("Ready for Final Determination"), "proposal_rejected": _("Dismiss"), }, "display": _("Invited for Proposal"), @@ -173,7 +173,7 @@ "proposal_more_info": _("Request More Information"), "proposal_internal_review": _("Open Review"), "external_review": _("Open External Review"), - "proposal_determination": _("Ready For Final Determination"), + "proposal_determination": _("Ready for Final Determination"), "proposal_rejected": _("Dismiss"), }, "display": _("Proposal Received"), @@ -194,7 +194,7 @@ "custom": {"trigger_on_submit": True}, }, "external_review": _("Open External Review"), - "proposal_determination": _("Ready For Final Determination"), + "proposal_determination": _("Ready for Final Determination"), "proposal_rejected": _("Dismiss"), }, "display": _("More information required"), @@ -221,11 +221,11 @@ "transitions": { "post_proposal_review_more_info": _("Request More Information"), "external_review": _("Open External Review"), - "proposal_determination": _("Ready For Final Determination"), + "proposal_determination": _("Ready for Final Determination"), "proposal_internal_review": _("Open Internal Review (revert)"), "proposal_rejected": _("Dismiss"), }, - "display": _("Ready For Discussion"), + "display": _("Ready for Discussion"), "stage": Proposal, "permissions": hidden_from_applicant_permissions, }, @@ -253,7 +253,7 @@ "external_review": { "transitions": { "post_external_review_discussion": _("Close Review"), - "post_proposal_review_discussion": _("Ready For Discussion (revert)"), + "post_proposal_review_discussion": _("Ready for Discussion (revert)"), }, "display": _("External Review"), "stage": Proposal, @@ -264,13 +264,13 @@ "post_external_review_discussion": { "transitions": { "post_external_review_more_info": _("Request More Information"), - "proposal_determination": _("Ready For Final Determination"), + "proposal_determination": _("Ready for Final Determination"), "external_review": _("Open External Review (revert)"), "proposal_almost": _("Accept but additional info required"), "proposal_accepted": _("Accept"), "proposal_rejected": _("Dismiss"), }, - "display": _("Ready For Discussion"), + "display": _("Ready for Discussion"), "stage": Proposal, "permissions": hidden_from_applicant_permissions, }, @@ -296,7 +296,7 @@ { "proposal_determination": { "transitions": { - "post_external_review_discussion": _("Ready For Discussion (revert)"), + "post_external_review_discussion": _("Ready for Discussion (revert)"), "proposal_almost": _("Accept but additional info required"), "proposal_accepted": _("Accept"), "proposal_rejected": _("Dismiss"), @@ -316,7 +316,7 @@ "proposal_almost": { "transitions": { "proposal_accepted": _("Accept"), - "post_external_review_discussion": _("Ready For Discussion (revert)"), + "post_external_review_discussion": _("Ready for Discussion (revert)"), }, "display": _("Accepted but additional info required"), "stage": Proposal, diff --git a/hypha/apply/funds/workflows/definitions/single_stage.py b/hypha/apply/funds/workflows/definitions/single_stage.py index bf4a33d881..b9b11c1479 100644 --- a/hypha/apply/funds/workflows/definitions/single_stage.py +++ b/hypha/apply/funds/workflows/definitions/single_stage.py @@ -32,7 +32,7 @@ "transitions": { "more_info": _("Request More Information"), "internal_review": _("Open Review"), - "determination": _("Ready For Determination"), + "determination": _("Ready for Determination"), "almost": _("Accept but additional info required"), "accepted": _("Accept"), "rejected": _("Dismiss"), @@ -55,7 +55,7 @@ "method": "create_revision", "custom": {"trigger_on_submit": True}, }, - "determination": _("Ready For Determination"), + "determination": _("Ready for Determination"), "almost": _("Accept but additional info required"), "accepted": _("Accept"), "rejected": _("Dismiss"), @@ -83,13 +83,13 @@ "post_review_discussion": { "transitions": { "post_review_more_info": _("Request More Information"), - "determination": _("Ready For Determination"), + "determination": _("Ready for Determination"), "internal_review": _("Open Review (revert)"), "almost": _("Accept but additional info required"), "accepted": _("Accept"), "rejected": _("Dismiss"), }, - "display": _("Ready For Discussion"), + "display": _("Ready for Discussion"), "stage": Request, "permissions": hidden_from_applicant_permissions, }, @@ -106,7 +106,7 @@ "method": "create_revision", "custom": {"trigger_on_submit": True}, }, - "determination": _("Ready For Determination"), + "determination": _("Ready for Determination"), "almost": _("Accept but additional info required"), "accepted": _("Accept"), "rejected": _("Dismiss"), @@ -119,7 +119,7 @@ { "determination": { "transitions": { - "post_review_discussion": _("Ready For Discussion (revert)"), + "post_review_discussion": _("Ready for Discussion (revert)"), "almost": _("Accept but additional info required"), "accepted": _("Accept"), "rejected": _("Dismiss"), @@ -139,7 +139,7 @@ "almost": { "transitions": { "accepted": _("Accept"), - "post_review_discussion": _("Ready For Discussion (revert)"), + "post_review_discussion": _("Ready for Discussion (revert)"), }, "display": _("Accepted but additional info required"), "stage": Request, diff --git a/hypha/apply/funds/workflows/definitions/single_stage_community.py b/hypha/apply/funds/workflows/definitions/single_stage_community.py index 655c5c86d0..df7310d2c6 100644 --- a/hypha/apply/funds/workflows/definitions/single_stage_community.py +++ b/hypha/apply/funds/workflows/definitions/single_stage_community.py @@ -36,7 +36,7 @@ "com_open_call": "Open Call (public)", "com_internal_review": _("Open Review"), "com_community_review": _("Open Community Review"), - "com_determination": _("Ready For Determination"), + "com_determination": _("Ready for Determination"), "com_rejected": _("Dismiss"), }, "display": _("Need screening"), @@ -106,11 +106,11 @@ "transitions": { "com_post_review_more_info": _("Request More Information"), "com_external_review": _("Open External Review"), - "com_determination": _("Ready For Determination"), + "com_determination": _("Ready for Determination"), "com_internal_review": _("Open Internal Review (revert)"), "com_rejected": _("Dismiss"), }, - "display": _("Ready For Discussion"), + "display": _("Ready for Discussion"), "stage": RequestCom, "permissions": hidden_from_applicant_permissions, }, @@ -137,7 +137,7 @@ "com_external_review": { "transitions": { "com_post_external_review_discussion": _("Close Review"), - "com_post_review_discussion": _("Ready For Discussion (revert)"), + "com_post_review_discussion": _("Ready for Discussion (revert)"), }, "display": _("External Review"), "stage": RequestCom, @@ -148,13 +148,13 @@ "com_post_external_review_discussion": { "transitions": { "com_post_external_review_more_info": _("Request More Information"), - "com_determination": _("Ready For Determination"), + "com_determination": _("Ready for Determination"), "com_external_review": _("Open External Review (revert)"), "com_almost": _("Accept but additional info required"), "com_accepted": _("Accept"), "com_rejected": _("Dismiss"), }, - "display": _("Ready For Discussion"), + "display": _("Ready for Discussion"), "stage": RequestCom, "permissions": hidden_from_applicant_permissions, }, @@ -181,7 +181,7 @@ "com_determination": { "transitions": { "com_post_external_review_discussion": _( - "Ready For Discussion (revert)" + "Ready for Discussion (revert)" ), "com_almost": _("Accept but additional info required"), "com_accepted": _("Accept"), @@ -203,7 +203,7 @@ "transitions": { "com_accepted": _("Accept"), "com_post_external_review_discussion": _( - "Ready For Discussion (revert)" + "Ready for Discussion (revert)" ), }, "display": _("Accepted but additional info required"), diff --git a/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py index 51935d21ca..94f7c67a1b 100644 --- a/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py +++ b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py @@ -32,7 +32,7 @@ "transitions": { "ext_int_more_info": _("Request More Information"), "ext_int_external_review": _("Open External Review"), - "ext_int_determination": _("Ready For Determination"), + "ext_int_determination": _("Ready for Determination"), "ext_int_rejected": _("Dismiss"), }, "display": _("Need screening"), @@ -76,13 +76,13 @@ "transitions": { "ext_int_post_external_review_more_info": _("Request More Information"), "ext_int_internal_review": _("Open Internal Review"), - "ext_int_determination": _("Ready For Determination"), + "ext_int_determination": _("Ready for Determination"), "ext_int_external_review": _("Open External Review (revert)"), "ext_int_accepted": _("Accept"), "ext_int_waitlisted": _("Waitlist"), "ext_int_rejected": _("Dismiss"), }, - "display": _("Ready For Discussion"), + "display": _("Ready for Discussion"), "stage": RequestExtInt, "permissions": hidden_from_applicant_permissions, }, @@ -110,7 +110,7 @@ "transitions": { "ext_int_post_review_discussion": _("Close Review"), "ext_int_post_external_review_discussion": _( - "Ready For Discussion (revert)" + "Ready for Discussion (revert)" ), }, "display": _("Internal Review"), @@ -122,13 +122,13 @@ "ext_int_post_review_discussion": { "transitions": { "ext_int_post_review_more_info": _("Request More Information"), - "ext_int_determination": _("Ready For Determination"), + "ext_int_determination": _("Ready for Determination"), "ext_int_internal_review": _("Open Internal Review (revert)"), "ext_int_accepted": _("Accept"), "ext_int_waitlisted": _("Waitlist"), "ext_int_rejected": _("Dismiss"), }, - "display": _("Ready For Discussion"), + "display": _("Ready for Discussion"), "stage": RequestExtInt, "permissions": hidden_from_applicant_permissions, }, @@ -154,7 +154,7 @@ { "ext_int_determination": { "transitions": { - "ext_int_post_review_discussion": _("Ready For Discussion (revert)"), + "ext_int_post_review_discussion": _("Ready for Discussion (revert)"), "ext_int_accepted": _("Accept"), "ext_int_waitlisted": _("Waitlist"), "ext_int_rejected": _("Dismiss"), diff --git a/hypha/apply/funds/workflows/definitions/single_stage_external.py b/hypha/apply/funds/workflows/definitions/single_stage_external.py index 77fc171b94..0296a17c3c 100644 --- a/hypha/apply/funds/workflows/definitions/single_stage_external.py +++ b/hypha/apply/funds/workflows/definitions/single_stage_external.py @@ -33,7 +33,7 @@ "transitions": { "ext_more_info": _("Request More Information"), "ext_internal_review": _("Open Review"), - "ext_determination": _("Ready For Determination"), + "ext_determination": _("Ready for Determination"), "ext_rejected": _("Dismiss"), }, "display": _("Need screening"), @@ -79,11 +79,11 @@ "transitions": { "ext_post_review_more_info": _("Request More Information"), "ext_external_review": _("Open External Review"), - "ext_determination": _("Ready For Determination"), + "ext_determination": _("Ready for Determination"), "ext_internal_review": _("Open Internal Review (revert)"), "ext_rejected": _("Dismiss"), }, - "display": _("Ready For Discussion"), + "display": _("Ready for Discussion"), "stage": RequestExt, "permissions": hidden_from_applicant_permissions, }, @@ -110,7 +110,7 @@ "ext_external_review": { "transitions": { "ext_post_external_review_discussion": _("Close Review"), - "ext_post_review_discussion": _("Ready For Discussion (revert)"), + "ext_post_review_discussion": _("Ready for Discussion (revert)"), }, "display": _("External Review"), "stage": RequestExt, @@ -121,13 +121,13 @@ "ext_post_external_review_discussion": { "transitions": { "ext_post_external_review_more_info": _("Request More Information"), - "ext_determination": _("Ready For Determination"), + "ext_determination": _("Ready for Determination"), "ext_external_review": _("Open External Review (revert)"), "ext_almost": _("Accept but additional info required"), "ext_accepted": _("Accept"), "ext_rejected": _("Dismiss"), }, - "display": _("Ready For Discussion"), + "display": _("Ready for Discussion"), "stage": RequestExt, "permissions": hidden_from_applicant_permissions, }, @@ -154,7 +154,7 @@ "ext_determination": { "transitions": { "ext_post_external_review_discussion": _( - "Ready For Discussion (revert)" + "Ready for Discussion (revert)" ), "ext_almost": _("Accept but additional info required"), "ext_accepted": _("Accept"), @@ -176,7 +176,7 @@ "transitions": { "ext_accepted": _("Accept"), "ext_post_external_review_discussion": _( - "Ready For Discussion (revert)" + "Ready for Discussion (revert)" ), }, "display": _("Accepted but additional info required"), diff --git a/hypha/apply/funds/workflows/definitions/single_stage_same.py b/hypha/apply/funds/workflows/definitions/single_stage_same.py index dcefa25a88..85b0b9111c 100644 --- a/hypha/apply/funds/workflows/definitions/single_stage_same.py +++ b/hypha/apply/funds/workflows/definitions/single_stage_same.py @@ -33,7 +33,7 @@ "transitions": { "same_more_info": _("Request More Information"), "same_internal_review": _("Open Review"), - "same_determination": _("Ready For Determination"), + "same_determination": _("Ready for Determination"), "same_rejected": _("Dismiss"), }, "display": _("Need screening"), @@ -78,11 +78,11 @@ "same_post_review_discussion": { "transitions": { "same_post_review_more_info": _("Request More Information"), - "same_determination": _("Ready For Determination"), + "same_determination": _("Ready for Determination"), "same_internal_review": _("Open Review (revert)"), "same_rejected": _("Dismiss"), }, - "display": _("Ready For Discussion"), + "display": _("Ready for Discussion"), "stage": RequestSame, "permissions": hidden_from_applicant_permissions, }, @@ -108,7 +108,7 @@ { "same_determination": { "transitions": { - "same_post_review_discussion": _("Ready For Discussion (revert)"), + "same_post_review_discussion": _("Ready for Discussion (revert)"), "same_almost": _("Accept but additional info required"), "same_accepted": _("Accept"), "same_rejected": _("Dismiss"), @@ -128,7 +128,7 @@ "same_almost": { "transitions": { "same_accepted": _("Accept"), - "same_post_review_discussion": _("Ready For Discussion (revert)"), + "same_post_review_discussion": _("Ready for Discussion (revert)"), }, "display": _("Accepted but additional info required"), "stage": RequestSame, From 58bb1b2bc8db63275f43c058a2db02b1b7ae4f2c Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Thu, 27 Aug 2026 14:15:54 +0200 Subject: [PATCH 08/11] Add Ready for Review state. --- docs/references/workflows.md | 12 ++-- ...er_anonymizedsubmission_status_and_more.py | 4 +- hypha/apply/funds/tests/test_workflows.py | 59 +++++++++++++++---- hypha/apply/funds/workflows/constants.py | 1 + .../definitions/single_stage_ext_int.py | 18 +++++- 5 files changed, 76 insertions(+), 18 deletions(-) diff --git a/docs/references/workflows.md b/docs/references/workflows.md index 31829ef08d..3b5d523528 100644 --- a/docs/references/workflows.md +++ b/docs/references/workflows.md @@ -128,7 +128,8 @@ Funding organization wants an advisory board or external partners to give their Once an application is submitted (`INITIAL_STATE`) β€” it can transition into the following: - A request for more information (`ext_int_more_info`) β€” opens editing permissions to applicant again to revise their application to provide the information requested by the screeners. -- Open external review (`ext_int_external_review`) β€” can transition only between closing the review period (`ext_int_post_external_review_discussion`) and reverting back to the internal screening phase. +- Ready for review (`ext_int_ready_for_review`) β€” a staff only holding phase between screening and the external review, where reviewers can be assigned before they are given access to the application. Can transition to opening the external review (`ext_int_external_review`), mark ready for determination (`ext_int_determination`), dismiss (`ext_int_rejected`) or revert back to screening (`INITIAL_STATE`). Reviewers can see the application in this phase but can not review it yet. +- Open external review (`ext_int_external_review`) β€” can transition only between closing the review period (`ext_int_post_external_review_discussion`) and reverting back to ready for review (`ext_int_ready_for_review`). - `ext_int_post_external_review_discussion` β€” after the external review is closed, you can request more information (`ext_int_post_external_review_more_info`), open the internal review (`ext_int_internal_review`), mark ready for determination (`ext_int_determination`), revert back to opening the external review (`ext_int_external_review`), accept (`ext_int_accepted`), waitlist (`ext_int_waitlisted`) or dismiss (`ext_int_rejected`) - `ext_int_post_external_review_more_info` β€” opens editing permissions to applicant again to revise their application to provide the information requested by the external reviewers. - Open internal review (`ext_int_internal_review`) β€” can transition only between closing the review period (`ext_int_post_review_discussion`) and reverting back to discussion (`ext_int_post_external_review_discussion`) @@ -137,13 +138,14 @@ Once an application is submitted (`INITIAL_STATE`) β€” it can transition into th - Ready for determination (`ext_int_determination`) β€” can revert back to discussion (β€œReady for Discussion (revert)” β€” `ext_int_post_review_discussion`), accept (`ext_int_accepted`), waitlist (`ext_int_waitlisted`) or dismiss (`ext_int_rejected`) - Waitlisted (`ext_int_waitlisted`) β€” the application is kept in play without a decision. Can transition to accept (`ext_int_accepted`), dismiss (`ext_int_rejected`), or revert back to ready for determination (`ext_int_determination`). Staff can still edit this submission. Waitlisting is a plain status change β€” unlike Accept and Dismiss it does not open the determination form and does not create a determination record. - Accepted (`ext_int_accepted`) β€” application accepted. Staff can still edit this submission. -- Dismissed (`ext_int_rejected`) β€” application rejected. Editing and reviewing permissions removed from all roles, the applicant can still view the submission. +- Dismissed (`ext_int_rejected`) β€” application rejected, shown as "Not Accepted" to the applicant. Editing and reviewing permissions removed from all roles, the applicant can still view the submission. There is no "Accepted but additional info required" state in this workflow. If the `TRANSITION_AFTER_REVIEWS` setting is set to a number, submitting a review can move the application on automatically: -- Need screening β†’ External Review, as soon as the first review is submitted. The configured number is not taken into account for this step, and only staff can review during screening. +- Need screening β†’ Ready for Review, as soon as the first review is submitted. The configured number is not taken into account for this step, and only staff can review during screening. +- Ready for Review β†’ External Review, once that many reviews have been submitted in total. Only staff can review in this phase. - External Review β†’ Ready for Discussion, once that many reviews have been submitted by users in the Reviewer group. - Internal Review β†’ Ready for Discussion, once that many reviews have been submitted in total. @@ -156,9 +158,9 @@ Everything from the first discussion onwards is hidden from the applicant. The s 1. **Draft** (`draft`) 2. **Application Received** (`INITIAL_STATE`, "Need screening" to staff) 3. **Application Review** (`ext_int_external_review`, "External Review" to staff) -4. **Application Outcome** β€” shown as Accepted (`ext_int_accepted`), Waitlisted (`ext_int_waitlisted`) or Dismissed (`ext_int_rejected`) once the application reaches that step +4. **Application Outcome** β€” shown as Accepted (`ext_int_accepted`), Waitlisted (`ext_int_waitlisted`) or Not Accepted (`ext_int_rejected`, "Dismissed" to staff) once the application reaches that step -The hidden phases (`ext_int_post_external_review_discussion`, `ext_int_internal_review`, `ext_int_post_review_discussion` and `ext_int_determination`) do not add a step of their own β€” to the applicant the application stays on "Application Review" until an outcome is reached. The three "More information required" states are visible to the applicant, since they need to edit and resubmit their application. +The hidden phases (`ext_int_ready_for_review`, `ext_int_post_external_review_discussion`, `ext_int_internal_review`, `ext_int_post_review_discussion` and `ext_int_determination`) do not add a step of their own β€” to the applicant the application stays on "Application Review" until an outcome is reached. The three "More information required" states are visible to the applicant, since they need to edit and resubmit their application. ### πŸ’‘ Concept and Proposal diff --git a/hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py b/hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py index cb63f91eea..eeb64bf888 100644 --- a/hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py +++ b/hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.17 on 2026-08-26 12:22 +# Generated by Django 5.2.17 on 2026-08-27 12:07 from django.db import migrations, models @@ -54,6 +54,7 @@ class Migration(migrations.Migration): ), ("ext_int_post_review_discussion", "Ready for Discussion"), ("ext_int_post_review_more_info", "More information required"), + ("ext_int_ready_for_review", "Ready for Review"), ("ext_int_rejected", "Dismissed"), ("ext_int_waitlisted", "Waitlisted"), ("ext_internal_review", "Internal Review"), @@ -140,6 +141,7 @@ class Migration(migrations.Migration): ), ("ext_int_post_review_discussion", "Ready for Discussion"), ("ext_int_post_review_more_info", "More information required"), + ("ext_int_ready_for_review", "Ready for Review"), ("ext_int_rejected", "Dismissed"), ("ext_int_waitlisted", "Waitlisted"), ("ext_internal_review", "Internal Review"), diff --git a/hypha/apply/funds/tests/test_workflows.py b/hypha/apply/funds/tests/test_workflows.py index 730071798b..a75027d027 100644 --- a/hypha/apply/funds/tests/test_workflows.py +++ b/hypha/apply/funds/tests/test_workflows.py @@ -1,5 +1,7 @@ """Tests for the workflow registry, focused on the external-then-internal workflow.""" +from types import SimpleNamespace + import pytest from django.test import SimpleTestCase @@ -7,6 +9,7 @@ from ..workflows import ( DETERMINATION_OUTCOMES, + INITIAL_STATE, WORKFLOWS, ext_or_higher_statuses, ext_review_statuses, @@ -33,21 +36,49 @@ def test_phases_and_steps(self): "draft": 0, "in_discussion": 1, "ext_int_more_info": 1, - "ext_int_external_review": 2, - "ext_int_post_external_review_discussion": 3, - "ext_int_post_external_review_more_info": 3, - "ext_int_internal_review": 4, - "ext_int_post_review_discussion": 5, - "ext_int_post_review_more_info": 5, - "ext_int_determination": 6, - "ext_int_accepted": 7, - "ext_int_waitlisted": 7, - "ext_int_rejected": 7, + "ext_int_ready_for_review": 2, + "ext_int_external_review": 3, + "ext_int_post_external_review_discussion": 4, + "ext_int_post_external_review_more_info": 4, + "ext_int_internal_review": 5, + "ext_int_post_review_discussion": 6, + "ext_int_post_review_more_info": 6, + "ext_int_determination": 7, + "ext_int_accepted": 8, + "ext_int_waitlisted": 8, + "ext_int_rejected": 8, } self.assertEqual( {name: phase.step for name, phase in self.workflow.items()}, expected ) + def test_ready_for_review_comes_before_external_review(self): + self.assertLess( + self.workflow["ext_int_ready_for_review"].step, + self.workflow["ext_int_external_review"].step, + ) + # Screening can only reach the external review through the new phase. + self.assertNotIn( + "ext_int_external_review", self.workflow[INITIAL_STATE].transitions + ) + self.assertIn( + "ext_int_ready_for_review", self.workflow[INITIAL_STATE].transitions + ) + + def test_ready_for_review_is_hidden_from_the_applicant(self): + applicant = SimpleNamespace( + is_apply_staff=False, is_applicant=True, is_reviewer=False + ) + reviewer = SimpleNamespace( + is_apply_staff=False, is_applicant=False, is_reviewer=True + ) + phase = self.workflow["ext_int_ready_for_review"] + self.assertEqual(phase.display_name, "Ready for Review") + self.assertFalse(phase.permissions.can_view(applicant)) + self.assertTrue(phase.permissions.can_view(reviewer)) + # Reviewers only get to review once the external review is opened. + self.assertFalse(phase.permissions.can_review(reviewer)) + def test_external_review_comes_before_internal_review(self): self.assertLess( self.workflow["ext_int_external_review"].step, @@ -78,6 +109,11 @@ def test_waitlist_offered_from_discussion_and_determination(self): "ext_int_waitlisted", self.workflow[phase_name].transitions ) + def test_dismissed_is_softened_for_the_applicant(self): + rejected = self.workflow["ext_int_rejected"] + self.assertEqual(rejected.display_name, "Dismissed") + self.assertEqual(rejected.public_name, "Not Accepted") + def test_outcome_phases_are_terminal(self): for phase_name in ["ext_int_accepted", "ext_int_rejected"]: with self.subTest(phase=phase_name): @@ -85,6 +121,8 @@ def test_outcome_phases_are_terminal(self): def test_picked_up_by_derived_status_sets(self): self.assertIn("ext_int_external_review", ext_review_statuses) + self.assertNotIn("ext_int_ready_for_review", ext_review_statuses) + self.assertNotIn("ext_int_ready_for_review", ext_or_higher_statuses) self.assertIn("ext_int_internal_review", ext_or_higher_statuses) self.assertIn("ext_int_post_review_discussion", DETERMINATION_RESPONSE_PHASES) @@ -97,6 +135,7 @@ def test_walk_the_whole_workflow(): assert submission.status == "in_discussion" chain = [ + "ext_int_ready_for_review", "ext_int_external_review", "ext_int_post_external_review_discussion", "ext_int_internal_review", diff --git a/hypha/apply/funds/workflows/constants.py b/hypha/apply/funds/workflows/constants.py index 0c6d20ac17..6e1a9a0eaa 100644 --- a/hypha/apply/funds/workflows/constants.py +++ b/hypha/apply/funds/workflows/constants.py @@ -17,6 +17,7 @@ "Need screening": "bg-cyan-200", "Ready for Determination": "bg-blue-200", "Ready for Discussion": "bg-blue-100", + "Ready for Review": "bg-yellow-100", "Invited for Proposal": "bg-green-100", "Internal Review": "bg-yellow-200", "External Review": "bg-yellow-200", diff --git a/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py index 94f7c67a1b..204f7789b3 100644 --- a/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py +++ b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py @@ -31,7 +31,7 @@ INITIAL_STATE: { "transitions": { "ext_int_more_info": _("Request More Information"), - "ext_int_external_review": _("Open External Review"), + "ext_int_ready_for_review": _("Ready for Review"), "ext_int_determination": _("Ready for Determination"), "ext_int_rejected": _("Dismiss"), }, @@ -59,11 +59,24 @@ "permissions": applicant_edit_permissions, }, }, + { + "ext_int_ready_for_review": { + "transitions": { + "ext_int_external_review": _("Open External Review"), + "ext_int_determination": _("Ready for Determination"), + "ext_int_rejected": _("Dismiss"), + INITIAL_STATE: _("Need screening (revert)"), + }, + "display": _("Ready for Review"), + "stage": RequestExtInt, + "permissions": hidden_from_applicant_permissions, + }, + }, { "ext_int_external_review": { "transitions": { "ext_int_post_external_review_discussion": _("Close Review"), - INITIAL_STATE: _("Need screening (revert)"), + "ext_int_ready_for_review": _("Ready for Review (revert)"), }, "display": _("External Review"), "public": _("Application Review"), @@ -183,6 +196,7 @@ }, "ext_int_rejected": { "display": _("Dismissed"), + "public": _("Not Accepted"), "stage": RequestExtInt, "permissions": no_permissions, }, From 776a2968513a224a2fbe76ace172187771e391c0 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Mon, 31 Aug 2026 22:55:27 +0200 Subject: [PATCH 09/11] Rename ext_int_ready_for_review -> ext_int_screened so get_review_statuses do not catch it. Other minor fixes and ajustments. --- docs/references/workflows.md | 9 +-- ...er_anonymizedsubmission_status_and_more.py | 4 +- hypha/apply/funds/tests/test_workflows.py | 66 ++++++++++++++++--- hypha/apply/funds/workflows/constants.py | 1 + .../definitions/single_stage_ext_int.py | 6 +- hypha/apply/funds/workflows/models/phase.py | 9 +-- hypha/apply/funds/workflows/registry.py | 4 +- .../review/tests/test_workflow_actions.py | 64 ++++++++++++++++++ hypha/apply/review/views.py | 7 -- 9 files changed, 139 insertions(+), 31 deletions(-) create mode 100644 hypha/apply/review/tests/test_workflow_actions.py diff --git a/docs/references/workflows.md b/docs/references/workflows.md index 3b5d523528..2fa5e3b630 100644 --- a/docs/references/workflows.md +++ b/docs/references/workflows.md @@ -128,8 +128,8 @@ Funding organization wants an advisory board or external partners to give their Once an application is submitted (`INITIAL_STATE`) β€” it can transition into the following: - A request for more information (`ext_int_more_info`) β€” opens editing permissions to applicant again to revise their application to provide the information requested by the screeners. -- Ready for review (`ext_int_ready_for_review`) β€” a staff only holding phase between screening and the external review, where reviewers can be assigned before they are given access to the application. Can transition to opening the external review (`ext_int_external_review`), mark ready for determination (`ext_int_determination`), dismiss (`ext_int_rejected`) or revert back to screening (`INITIAL_STATE`). Reviewers can see the application in this phase but can not review it yet. -- Open external review (`ext_int_external_review`) β€” can transition only between closing the review period (`ext_int_post_external_review_discussion`) and reverting back to ready for review (`ext_int_ready_for_review`). +- Ready for review (`ext_int_screened`) β€” a staff only holding phase between screening and the external review, where reviewers can be assigned before they are given access to the application. Can transition to opening the external review (`ext_int_external_review`), mark ready for determination (`ext_int_determination`), dismiss (`ext_int_rejected`) or revert back to screening (`INITIAL_STATE`). Reviewers can see the application in this phase but can not review it yet, and are only notified once the external review is opened. +- Open external review (`ext_int_external_review`) β€” can transition only between closing the review period (`ext_int_post_external_review_discussion`) and reverting back to ready for review (`ext_int_screened`). - `ext_int_post_external_review_discussion` β€” after the external review is closed, you can request more information (`ext_int_post_external_review_more_info`), open the internal review (`ext_int_internal_review`), mark ready for determination (`ext_int_determination`), revert back to opening the external review (`ext_int_external_review`), accept (`ext_int_accepted`), waitlist (`ext_int_waitlisted`) or dismiss (`ext_int_rejected`) - `ext_int_post_external_review_more_info` β€” opens editing permissions to applicant again to revise their application to provide the information requested by the external reviewers. - Open internal review (`ext_int_internal_review`) β€” can transition only between closing the review period (`ext_int_post_review_discussion`) and reverting back to discussion (`ext_int_post_external_review_discussion`) @@ -147,7 +147,8 @@ If the `TRANSITION_AFTER_REVIEWS` setting is set to a number, submitting a revie - Need screening β†’ Ready for Review, as soon as the first review is submitted. The configured number is not taken into account for this step, and only staff can review during screening. - Ready for Review β†’ External Review, once that many reviews have been submitted in total. Only staff can review in this phase. - External Review β†’ Ready for Discussion, once that many reviews have been submitted by users in the Reviewer group. -- Internal Review β†’ Ready for Discussion, once that many reviews have been submitted in total. + +The internal review has no automatic transition β€” by the time it opens the external reviews have already been counted, so staff close it themselves with "Close Review". The automatic transition is skipped if the user submitting the review is not allowed to make it. @@ -160,7 +161,7 @@ Everything from the first discussion onwards is hidden from the applicant. The s 3. **Application Review** (`ext_int_external_review`, "External Review" to staff) 4. **Application Outcome** β€” shown as Accepted (`ext_int_accepted`), Waitlisted (`ext_int_waitlisted`) or Not Accepted (`ext_int_rejected`, "Dismissed" to staff) once the application reaches that step -The hidden phases (`ext_int_ready_for_review`, `ext_int_post_external_review_discussion`, `ext_int_internal_review`, `ext_int_post_review_discussion` and `ext_int_determination`) do not add a step of their own β€” to the applicant the application stays on "Application Review" until an outcome is reached. The three "More information required" states are visible to the applicant, since they need to edit and resubmit their application. +The hidden phases (`ext_int_screened`, `ext_int_post_external_review_discussion`, `ext_int_internal_review`, `ext_int_post_review_discussion` and `ext_int_determination`) do not add a step of their own β€” to the applicant the application stays on "Application Review" until an outcome is reached. The three "More information required" states are visible to the applicant, since they need to edit and resubmit their application. ### πŸ’‘ Concept and Proposal diff --git a/hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py b/hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py index eeb64bf888..8008e76474 100644 --- a/hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py +++ b/hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py @@ -54,8 +54,8 @@ class Migration(migrations.Migration): ), ("ext_int_post_review_discussion", "Ready for Discussion"), ("ext_int_post_review_more_info", "More information required"), - ("ext_int_ready_for_review", "Ready for Review"), ("ext_int_rejected", "Dismissed"), + ("ext_int_screened", "Ready for Review"), ("ext_int_waitlisted", "Waitlisted"), ("ext_internal_review", "Internal Review"), ("ext_more_info", "More information required"), @@ -141,8 +141,8 @@ class Migration(migrations.Migration): ), ("ext_int_post_review_discussion", "Ready for Discussion"), ("ext_int_post_review_more_info", "More information required"), - ("ext_int_ready_for_review", "Ready for Review"), ("ext_int_rejected", "Dismissed"), + ("ext_int_screened", "Ready for Review"), ("ext_int_waitlisted", "Waitlisted"), ("ext_internal_review", "Internal Review"), ("ext_more_info", "More information required"), diff --git a/hypha/apply/funds/tests/test_workflows.py b/hypha/apply/funds/tests/test_workflows.py index a75027d027..739aded3a6 100644 --- a/hypha/apply/funds/tests/test_workflows.py +++ b/hypha/apply/funds/tests/test_workflows.py @@ -4,6 +4,7 @@ import pytest from django.test import SimpleTestCase +from django.utils.translation import gettext_lazy, override from hypha.apply.users.tests.factories import StaffFactory @@ -11,10 +12,15 @@ DETERMINATION_OUTCOMES, INITIAL_STATE, WORKFLOWS, + accepted_statuses, + dismissed_statuses, ext_or_higher_statuses, ext_review_statuses, + review_statuses, ) from ..workflows.constants import DETERMINATION_RESPONSE_PHASES +from ..workflows.models.phase import Phase +from ..workflows.permissions import staff_edit_permissions from .factories import ApplicationSubmissionFactory WORKFLOW_NAME = "single_ext_int" @@ -36,7 +42,7 @@ def test_phases_and_steps(self): "draft": 0, "in_discussion": 1, "ext_int_more_info": 1, - "ext_int_ready_for_review": 2, + "ext_int_screened": 2, "ext_int_external_review": 3, "ext_int_post_external_review_discussion": 4, "ext_int_post_external_review_more_info": 4, @@ -54,16 +60,14 @@ def test_phases_and_steps(self): def test_ready_for_review_comes_before_external_review(self): self.assertLess( - self.workflow["ext_int_ready_for_review"].step, + self.workflow["ext_int_screened"].step, self.workflow["ext_int_external_review"].step, ) # Screening can only reach the external review through the new phase. self.assertNotIn( "ext_int_external_review", self.workflow[INITIAL_STATE].transitions ) - self.assertIn( - "ext_int_ready_for_review", self.workflow[INITIAL_STATE].transitions - ) + self.assertIn("ext_int_screened", self.workflow[INITIAL_STATE].transitions) def test_ready_for_review_is_hidden_from_the_applicant(self): applicant = SimpleNamespace( @@ -72,7 +76,7 @@ def test_ready_for_review_is_hidden_from_the_applicant(self): reviewer = SimpleNamespace( is_apply_staff=False, is_applicant=False, is_reviewer=True ) - phase = self.workflow["ext_int_ready_for_review"] + phase = self.workflow["ext_int_screened"] self.assertEqual(phase.display_name, "Ready for Review") self.assertFalse(phase.permissions.can_view(applicant)) self.assertTrue(phase.permissions.can_view(reviewer)) @@ -121,9 +125,25 @@ def test_outcome_phases_are_terminal(self): def test_picked_up_by_derived_status_sets(self): self.assertIn("ext_int_external_review", ext_review_statuses) - self.assertNotIn("ext_int_ready_for_review", ext_review_statuses) - self.assertNotIn("ext_int_ready_for_review", ext_or_higher_statuses) + self.assertNotIn("ext_int_screened", ext_review_statuses) + self.assertNotIn("ext_int_screened", ext_or_higher_statuses) self.assertIn("ext_int_internal_review", ext_or_higher_statuses) + self.assertIn("ext_int_accepted", accepted_statuses) + self.assertIn("ext_int_rejected", dismissed_statuses) + + def test_ready_for_review_is_not_a_review_status(self): + # Reviewers are notified for everything in review_statuses, and they + # can not review while the application is only screened. + self.assertNotIn("ext_int_screened", review_statuses) + self.assertIn("ext_int_external_review", review_statuses) + self.assertIn("ext_int_internal_review", review_statuses) + + def test_both_discussions_expect_a_determination(self): + # An application can be decided straight after the external review, + # without opening the internal one. + self.assertIn( + "ext_int_post_external_review_discussion", DETERMINATION_RESPONSE_PHASES + ) self.assertIn("ext_int_post_review_discussion", DETERMINATION_RESPONSE_PHASES) @@ -135,7 +155,7 @@ def test_walk_the_whole_workflow(): assert submission.status == "in_discussion" chain = [ - "ext_int_ready_for_review", + "ext_int_screened", "ext_int_external_review", "ext_int_post_external_review_discussion", "ext_int_internal_review", @@ -150,3 +170,31 @@ def test_walk_the_whole_workflow(): assert submission.status == target assert submission.phase.display_name == "Accepted" + + +class TestPhaseSourceNames(SimpleTestCase): + """The names the code matches on must not follow the active language.""" + + def phase(self, display): + return Phase( + "a_phase", + display, + stage=None, + permissions=staff_edit_permissions, + step=0, + ) + + def test_source_name_slug_and_colour_ignore_the_active_language(self): + with override("cs"): + accepted = self.phase(gettext_lazy("Accepted")) + discussion = self.phase(gettext_lazy("Ready for Discussion")) + + # Sanity check that "cs" really does translate these. + self.assertNotEqual(accepted.display_name, "Accepted") + + self.assertEqual(accepted.display_name_source, "Accepted") + self.assertEqual(accepted.display_slug, "accepted") + self.assertEqual(accepted.bg_color, "bg-green-200") + self.assertEqual(discussion.display_name_source, "Ready for Discussion") + self.assertEqual(discussion.display_slug, "ready-for-discussion") + self.assertEqual(discussion.bg_color, "bg-blue-100") diff --git a/hypha/apply/funds/workflows/constants.py b/hypha/apply/funds/workflows/constants.py index 6e1a9a0eaa..d6045458dd 100644 --- a/hypha/apply/funds/workflows/constants.py +++ b/hypha/apply/funds/workflows/constants.py @@ -44,6 +44,7 @@ class UserPermissions(Enum): "post_external_review_discussion", "ext_post_external_review_discussion", "com_post_external_review_discussion", + "ext_int_post_external_review_discussion", "ext_int_post_review_discussion", ] diff --git a/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py index 204f7789b3..d26c4178c1 100644 --- a/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py +++ b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py @@ -31,7 +31,7 @@ INITIAL_STATE: { "transitions": { "ext_int_more_info": _("Request More Information"), - "ext_int_ready_for_review": _("Ready for Review"), + "ext_int_screened": _("Ready for Review"), "ext_int_determination": _("Ready for Determination"), "ext_int_rejected": _("Dismiss"), }, @@ -60,7 +60,7 @@ }, }, { - "ext_int_ready_for_review": { + "ext_int_screened": { "transitions": { "ext_int_external_review": _("Open External Review"), "ext_int_determination": _("Ready for Determination"), @@ -76,7 +76,7 @@ "ext_int_external_review": { "transitions": { "ext_int_post_external_review_discussion": _("Close Review"), - "ext_int_ready_for_review": _("Ready for Review (revert)"), + "ext_int_screened": _("Ready for Review (revert)"), }, "display": _("External Review"), "public": _("Application Review"), diff --git a/hypha/apply/funds/workflows/models/phase.py b/hypha/apply/funds/workflows/models/phase.py index a8dcae257f..663c78c33c 100644 --- a/hypha/apply/funds/workflows/models/phase.py +++ b/hypha/apply/funds/workflows/models/phase.py @@ -33,12 +33,13 @@ def __init__( self.public_name = str(public) if public else self.display_name self.future_name_staff = str(future) if future else self.display_name - # Derive the slug and the colour from the untranslated source string: - # PHASE_BG_COLORS is keyed by the English msgids, and the slug is used - # as a filter value in URLs, so it must not vary with the active - # language. + # Derive the slug, the colour and the source name from the untranslated + # string: PHASE_BG_COLORS is keyed by the English msgids, the slug is + # used as a filter value in URLs, and the registry matches phases by + # their source name, so none of them must vary with the active language. with override(None): source_display = str(display) + self.display_name_source = source_display self.display_slug = slugify(source_display) self.bg_color = PHASE_BG_COLORS.get(source_display, "bg-gray-200") self.future_name_public = str(future) if future else self.public_name diff --git a/hypha/apply/funds/workflows/registry.py b/hypha/apply/funds/workflows/registry.py index 429b27f67c..97a28a1894 100644 --- a/hypha/apply/funds/workflows/registry.py +++ b/hypha/apply/funds/workflows/registry.py @@ -180,7 +180,7 @@ def get_ext_or_higher_statuses(): def get_accepted_statuses(): accepted_statuses = set() for phase_name, phase in PHASES: - if phase.display_name == "Accepted": + if phase.display_name_source == "Accepted": accepted_statuses.add(phase_name) return accepted_statuses @@ -188,7 +188,7 @@ def get_accepted_statuses(): def get_dismissed_statuses(): dismissed_statuses = set() for phase_name, phase in PHASES: - if phase.display_name == "Dismissed": + if phase.display_name_source == "Dismissed": dismissed_statuses.add(phase_name) return dismissed_statuses diff --git a/hypha/apply/review/tests/test_workflow_actions.py b/hypha/apply/review/tests/test_workflow_actions.py new file mode 100644 index 0000000000..1a1d177c7e --- /dev/null +++ b/hypha/apply/review/tests/test_workflow_actions.py @@ -0,0 +1,64 @@ +"""Tests for the automatic transitions triggered by submitting a review.""" + +import pytest +from django.test import RequestFactory + +from hypha.apply.funds.tests.factories import ApplicationSubmissionFactory +from hypha.apply.users.tests.factories import StaffFactory + +from ..views import review_workflow_actions +from .factories import ReviewFactory + +WORKFLOW_NAME = "single_ext_int" + + +def submission_in(status, staff): + """Walk a new external-then-internal submission up to `status`.""" + submission = ApplicationSubmissionFactory(workflow_name=WORKFLOW_NAME) + chain = [ + "ext_int_screened", + "ext_int_external_review", + "ext_int_post_external_review_discussion", + "ext_int_internal_review", + ] + for target in chain: + submission.perform_transition(target, staff) + submission.save() + if target == status: + break + return submission + + +def request_for(user): + request = RequestFactory().get("/") + request.user = user + return request + + +@pytest.mark.django_db +def test_external_review_closes_after_enough_reviewer_reviews(settings): + settings.TRANSITION_AFTER_REVIEWS = 2 + staff = StaffFactory() + submission = submission_in("ext_int_external_review", staff) + + ReviewFactory.create_batch(2, submission=submission) + + review_workflow_actions(request_for(staff), submission) + + assert submission.status == "ext_int_post_external_review_discussion" + + +@pytest.mark.django_db +def test_internal_review_is_not_closed_by_the_earlier_reviews(settings): + """The external reviews must not count towards closing the internal one.""" + settings.TRANSITION_AFTER_REVIEWS = 2 + staff = StaffFactory() + submission = submission_in("ext_int_internal_review", staff) + + # Two reviews from the external round plus a first staff review. + ReviewFactory.create_batch(2, submission=submission) + ReviewFactory(submission=submission, author__staff=True) + + review_workflow_actions(request_for(staff), submission) + + assert submission.status == "ext_int_internal_review" diff --git a/hypha/apply/review/views.py b/hypha/apply/review/views.py index c1983c03ec..125d209e35 100644 --- a/hypha/apply/review/views.py +++ b/hypha/apply/review/views.py @@ -250,13 +250,6 @@ def review_workflow_actions(request, submission): ): # Automatically transition the proposal to "Ready for discussion". action = "post_external_review_discussion" - elif ( - transition_after - and submission.status == "ext_int_internal_review" - and submission.reviews.count() >= transition_after - ): - # Automatically transition the application to "Ready for discussion". - action = "ext_int_post_review_discussion" # If action is set run perform_transition(). if action: From 9e307369d07aed140734bc0aab20a3d758216328 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Mon, 31 Aug 2026 23:02:27 +0200 Subject: [PATCH 10/11] Make get_all_possible_states not generate false migrations when language not english. --- hypha/apply/funds/models/submissions.py | 9 ++++++++- hypha/apply/funds/tests/test_workflows.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/hypha/apply/funds/models/submissions.py b/hypha/apply/funds/models/submissions.py index b95cdbff23..8cf67a6a65 100644 --- a/hypha/apply/funds/models/submissions.py +++ b/hypha/apply/funds/models/submissions.py @@ -283,10 +283,17 @@ def transition_id(target, phase): def get_all_possible_states(): + """The (status, label) choices for the status fields. + + The label is the untranslated source name: these choices end up in + migrations, so they must not change with the active language. Nothing + displays them β€” the status is shown to users through the phase, see + `ApplicationSubmission.phase`. + """ all_states = set() for workflow in WORKFLOWS.values(): for phase_name, data in workflow.items(): - all_states.add((phase_name, data.display_name)) + all_states.add((phase_name, data.display_name_source)) return sorted(all_states, key=lambda x: x[0]) diff --git a/hypha/apply/funds/tests/test_workflows.py b/hypha/apply/funds/tests/test_workflows.py index 739aded3a6..8204ea3f0c 100644 --- a/hypha/apply/funds/tests/test_workflows.py +++ b/hypha/apply/funds/tests/test_workflows.py @@ -1,6 +1,7 @@ """Tests for the workflow registry, focused on the external-then-internal workflow.""" from types import SimpleNamespace +from unittest import mock import pytest from django.test import SimpleTestCase @@ -8,6 +9,7 @@ from hypha.apply.users.tests.factories import StaffFactory +from ..models.submissions import get_all_possible_states from ..workflows import ( DETERMINATION_OUTCOMES, INITIAL_STATE, @@ -198,3 +200,12 @@ def test_source_name_slug_and_colour_ignore_the_active_language(self): self.assertEqual(discussion.display_name_source, "Ready for Discussion") self.assertEqual(discussion.display_slug, "ready-for-discussion") self.assertEqual(discussion.bg_color, "bg-blue-100") + + def test_status_choices_ignore_the_active_language(self): + # These choices are written into migrations, so phases built on a + # non-English install must not label them in that language. + with override("cs"): + phase = self.phase(gettext_lazy("Accepted")) + + with mock.patch.dict(WORKFLOWS, {"fake": {"a_phase": phase}}, clear=True): + self.assertEqual(get_all_possible_states(), [("a_phase", "Accepted")]) From 48586a46224b91170261ce4f3fb16f3f9382e84e Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Mon, 31 Aug 2026 23:12:38 +0200 Subject: [PATCH 11/11] Fix test. --- hypha/apply/funds/tests/test_workflows.py | 26 +++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/hypha/apply/funds/tests/test_workflows.py b/hypha/apply/funds/tests/test_workflows.py index 8204ea3f0c..b8c9978f66 100644 --- a/hypha/apply/funds/tests/test_workflows.py +++ b/hypha/apply/funds/tests/test_workflows.py @@ -5,7 +5,8 @@ import pytest from django.test import SimpleTestCase -from django.utils.translation import gettext_lazy, override +from django.utils.functional import lazy +from django.utils.translation import get_language, override from hypha.apply.users.tests.factories import StaffFactory @@ -174,6 +175,15 @@ def test_walk_the_whole_workflow(): assert submission.phase.display_name == "Accepted" +def fake_translation(source, translated): + """A lazy string that translates while a language is active. + + Stands in for gettext_lazy: the compiled .mo files are gitignored, so CI + has no catalogs and the real thing would hand back the msgid there. + """ + return lazy(lambda: source if get_language() is None else translated, str)() + + class TestPhaseSourceNames(SimpleTestCase): """The names the code matches on must not follow the active language.""" @@ -188,15 +198,17 @@ def phase(self, display): def test_source_name_slug_and_colour_ignore_the_active_language(self): with override("cs"): - accepted = self.phase(gettext_lazy("Accepted")) - discussion = self.phase(gettext_lazy("Ready for Discussion")) - - # Sanity check that "cs" really does translate these. - self.assertNotEqual(accepted.display_name, "Accepted") + accepted = self.phase(fake_translation("Accepted", "Prijato")) + discussion = self.phase( + fake_translation("Ready for Discussion", "Pripraveno k diskusi") + ) + # The staff facing name is translated, everything matched on is not. + self.assertEqual(accepted.display_name, "Prijato") self.assertEqual(accepted.display_name_source, "Accepted") self.assertEqual(accepted.display_slug, "accepted") self.assertEqual(accepted.bg_color, "bg-green-200") + self.assertEqual(discussion.display_name, "Pripraveno k diskusi") self.assertEqual(discussion.display_name_source, "Ready for Discussion") self.assertEqual(discussion.display_slug, "ready-for-discussion") self.assertEqual(discussion.bg_color, "bg-blue-100") @@ -205,7 +217,7 @@ def test_status_choices_ignore_the_active_language(self): # These choices are written into migrations, so phases built on a # non-English install must not label them in that language. with override("cs"): - phase = self.phase(gettext_lazy("Accepted")) + phase = self.phase(fake_translation("Accepted", "Prijato")) with mock.patch.dict(WORKFLOWS, {"fake": {"a_phase": phase}}, clear=True): self.assertEqual(get_all_possible_states(), [("a_phase", "Accepted")])