diff --git a/docs/references/workflows.md b/docs/references/workflows.md index 944e24c366..2fa5e3b630 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. @@ -36,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. @@ -55,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. @@ -79,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. @@ -112,6 +113,56 @@ 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. + +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:** + +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. +- 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`) + - `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, 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 → 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. + +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. + +**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 Not Accepted (`ext_int_rejected`, "Dismissed" to staff) once the application reaches that step + +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 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/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/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/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..8008e76474 --- /dev/null +++ b/hypha/apply/funds/migrations/0137_alter_anonymizedsubmission_status_and_more.py @@ -0,0 +1,187 @@ +# Generated by Django 5.2.17 on 2026-08-27 12:07 + +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_screened", "Ready for Review"), + ("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_screened", "Ready for Review"), + ("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/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/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/tests/test_workflows.py b/hypha/apply/funds/tests/test_workflows.py new file mode 100644 index 0000000000..b8c9978f66 --- /dev/null +++ b/hypha/apply/funds/tests/test_workflows.py @@ -0,0 +1,223 @@ +"""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 +from django.utils.functional import lazy +from django.utils.translation import get_language, override + +from hypha.apply.users.tests.factories import StaffFactory + +from ..models.submissions import get_all_possible_states +from ..workflows import ( + 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" + + +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_screened": 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_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_screened", 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_screened"] + 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, + 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_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): + 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.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) + + +@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_screened", + "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" + + +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.""" + + 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(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") + + 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(fake_translation("Accepted", "Prijato")) + + with mock.patch.dict(WORKFLOWS, {"fake": {"a_phase": phase}}, clear=True): + self.assertEqual(get_all_possible_states(), [("a_phase", "Accepted")]) 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/constants.py b/hypha/apply/funds/workflows/constants.py index 3785666edc..d6045458dd 100644 --- a/hypha/apply/funds/workflows/constants.py +++ b/hypha/apply/funds/workflows/constants.py @@ -16,12 +16,14 @@ "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", + "Ready for Review": "bg-yellow-100", "Invited for Proposal": "bg-green-100", "Internal Review": "bg-yellow-200", "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 +44,8 @@ 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", ] DETERMINATION_OUTCOMES = get_determination_transitions() 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 new file mode 100644 index 0000000000..d26c4178c1 --- /dev/null +++ b/hypha/apply/funds/workflows/definitions/single_stage_ext_int.py @@ -0,0 +1,204 @@ +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_screened": _("Ready for 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_screened": { + "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"), + "ext_int_screened": _("Ready for Review (revert)"), + }, + "display": _("External Review"), + "public": _("Application 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_accepted": _("Accept"), + "ext_int_waitlisted": _("Waitlist"), + "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"), + "stage": RequestExtInt, + "permissions": hidden_from_applicant_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"), + "public": _("Not Accepted"), + "stage": RequestExtInt, + "permissions": no_permissions, + }, + }, +] 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, diff --git a/hypha/apply/funds/workflows/models/phase.py b/hypha/apply/funds/workflows/models/phase.py index be390069ae..663c78c33c 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 @@ -27,13 +28,20 @@ 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 - self.bg_color = PHASE_BG_COLORS.get(self.display_name, "bg-gray-200") + # 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 self.stage = stage self.permissions = Permissions(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..97a28a1894 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, } @@ -104,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 @@ -168,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 @@ -176,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 d75c1e33d0..125d209e35 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