From fa7302c9c7812e615bd9231b3f720e1df29277b6 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Fri, 10 Jan 2025 14:29:29 +0530 Subject: [PATCH] Refactor fund -> workflow module (#4318) - Refactor workflow.py to break it down into smaller modules - Rename funds.workflow -> funds.workflows - Improvement to Documentation - Remove unused constant declation`DETERMINATION_PHASES` --- Makefile | 14 +- .../apply/activity/adapters/activity_feed.py | 2 +- hypha/apply/activity/adapters/emails.py | 2 +- hypha/apply/dashboard/views_partials.py | 2 +- hypha/apply/determinations/models.py | 2 +- hypha/apply/determinations/options.py | 2 +- hypha/apply/determinations/permissions.py | 3 +- hypha/apply/determinations/views.py | 3 +- hypha/apply/funds/admin_forms.py | 2 +- .../management/commands/drafts_cleanup.py | 2 +- .../management/commands/migration_base.py | 2 +- hypha/apply/funds/models/applications.py | 2 +- hypha/apply/funds/models/submissions.py | 18 +- hypha/apply/funds/models/utils.py | 2 +- hypha/apply/funds/services.py | 2 +- hypha/apply/funds/tables.py | 2 +- hypha/apply/funds/tests/factories/models.py | 6 +- hypha/apply/funds/tests/test_models.py | 3 +- hypha/apply/funds/tests/test_views.py | 2 +- .../tests/views/test_submission_delete.py | 2 +- hypha/apply/funds/views/all.py | 2 +- hypha/apply/funds/views/partials.py | 2 +- hypha/apply/funds/views/staff_assignments.py | 2 +- hypha/apply/funds/views/submission_delete.py | 2 +- hypha/apply/funds/views/submission_detail.py | 2 +- hypha/apply/funds/views/submission_edit.py | 2 +- hypha/apply/funds/workflow.py | 1498 ----------------- hypha/apply/funds/workflows/__init__.py | 61 + hypha/apply/funds/workflows/constants.py | 96 ++ .../workflows/definitions/double_stage.py | 331 ++++ .../workflows/definitions/single_stage.py | 154 ++ .../definitions/single_stage_community.py | 219 +++ .../definitions/single_stage_external.py | 192 +++ .../definitions/single_stage_same.py | 143 ++ hypha/apply/funds/workflows/models/phase.py | 74 + hypha/apply/funds/workflows/models/stage.py | 21 + .../apply/funds/workflows/models/workflow.py | 43 + hypha/apply/funds/workflows/permissions.py | 62 + hypha/apply/funds/workflows/registry.py | 186 ++ hypha/apply/funds/workflows/utils.py | 65 + hypha/apply/review/tests/test_views.py | 2 +- hypha/apply/review/views.py | 2 +- 42 files changed, 1697 insertions(+), 1539 deletions(-) delete mode 100644 hypha/apply/funds/workflow.py create mode 100644 hypha/apply/funds/workflows/__init__.py create mode 100644 hypha/apply/funds/workflows/constants.py create mode 100644 hypha/apply/funds/workflows/definitions/double_stage.py create mode 100644 hypha/apply/funds/workflows/definitions/single_stage.py create mode 100644 hypha/apply/funds/workflows/definitions/single_stage_community.py create mode 100644 hypha/apply/funds/workflows/definitions/single_stage_external.py create mode 100644 hypha/apply/funds/workflows/definitions/single_stage_same.py create mode 100644 hypha/apply/funds/workflows/models/phase.py create mode 100644 hypha/apply/funds/workflows/models/stage.py create mode 100644 hypha/apply/funds/workflows/models/workflow.py create mode 100644 hypha/apply/funds/workflows/permissions.py create mode 100644 hypha/apply/funds/workflows/registry.py create mode 100644 hypha/apply/funds/workflows/utils.py diff --git a/Makefile b/Makefile index 348b8a92ff..24a2717bb2 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ JS_ESM_DIR = ./hypha/static_src/javascript/esm # Check if uv is installed then use it, else fallback to pip PIP := $(shell (command -v uv > /dev/null 2>&1 && echo "uv pip") || (command -v pip > /dev/null 2>&1 && echo "pip")) - +UV_RUN := $(shell (command -v uv > /dev/null 2>&1 && echo "uv run ") || echo "") .PHONY: help help: ## Show this help menu with a list of available commands and their descriptions @@ -16,9 +16,9 @@ help: ## Show this help menu with a list of available commands and their descrip .PHONY: serve serve: .cache/tandem .cache/py-packages .cache/dev-build-fe ## Run Django server, docs preview, and watch frontend changes @.cache/tandem \ - 'python manage.py runserver_plus --settings=$(DJANGO_SETTINGS_MODULE)' \ + '${UV_RUN}python manage.py runserver_plus --settings=$(DJANGO_SETTINGS_MODULE)' \ 'npm:watch:*' \ - 'mkdocs serve' + '${UV_RUN}mkdocs serve' .PHONY: test test: lint py-test cov-html ## Run all tests (linting, Python tests) and generate coverage report @@ -26,19 +26,19 @@ test: lint py-test cov-html ## Run all tests (linting, Python tests) and genera .PHONY: fmt fmt: .cache/dev-build-fe ## Run code formatters on all code using pre-commit - @pre-commit run --all-files + ${UV_RUN}pre-commit run --all-files .PHONY: lint lint: .cache/dev-build-fe ## Run all linters @echo "Running linters" - @pre-commit run --all-files + ${UV_RUN}pre-commit run --all-files .PHONY: py-test py-test: .cache/py-packages ## Run Python tests with pytest, including coverage report @echo "Running python tests" - pytest --reuse-db --cov --cov-report term:skip-covered + ${UV_RUN}pytest --reuse-db --cov --cov-report term:skip-covered @echo "Removing test files generated during test" @find media/ -iname 'test_*.pdf' -o -iname 'test_image*' -o -iname '*.dat' -delete @@ -51,7 +51,7 @@ cov-html: ## Generate HTML coverage report from previous test run ifneq ("$(wildcard .coverage)","") @rm -rf htmlcov @echo "Generate html coverage report…" - coverage html + ${UV_RUN}coverage html @echo "Open 'htmlcov/index.html' in your browser to see the report." else $(error Unable to generate html coverage report, please run 'make test' or 'make py-test') diff --git a/hypha/apply/activity/adapters/activity_feed.py b/hypha/apply/activity/adapters/activity_feed.py index 07a487fb47..939154b2f6 100644 --- a/hypha/apply/activity/adapters/activity_feed.py +++ b/hypha/apply/activity/adapters/activity_feed.py @@ -6,7 +6,7 @@ from hypha.apply.activity.models import ALL, APPLICANT, TEAM from hypha.apply.activity.options import MESSAGES -from hypha.apply.funds.workflow import PHASE_BG_COLORS +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, diff --git a/hypha/apply/activity/adapters/emails.py b/hypha/apply/activity/adapters/emails.py index 6fe4df2ede..c6f58ce450 100644 --- a/hypha/apply/activity/adapters/emails.py +++ b/hypha/apply/activity/adapters/emails.py @@ -148,7 +148,7 @@ def extra_kwargs(self, message_type, source, sources, **kwargs): } def handle_transition(self, old_phase, source, **kwargs): - from hypha.apply.funds.workflow import PHASES + from hypha.apply.funds.workflows import PHASES submission = source # Retrieve status index to see if we are going forward or backward. diff --git a/hypha/apply/dashboard/views_partials.py b/hypha/apply/dashboard/views_partials.py index 0afdc0a8fd..1c3f7e0e09 100644 --- a/hypha/apply/dashboard/views_partials.py +++ b/hypha/apply/dashboard/views_partials.py @@ -5,7 +5,7 @@ from django.views.decorators.http import require_GET from hypha.apply.funds.models.submissions import ApplicationSubmission -from hypha.apply.funds.workflow import active_statuses +from hypha.apply.funds.workflows import active_statuses from hypha.apply.projects.models import Project diff --git a/hypha/apply/determinations/models.py b/hypha/apply/determinations/models.py index 1a595559f9..1951500dc3 100644 --- a/hypha/apply/determinations/models.py +++ b/hypha/apply/determinations/models.py @@ -14,7 +14,7 @@ from wagtail.fields import RichTextField, StreamField from hypha.apply.funds.models.mixins import AccessFormData -from hypha.apply.funds.workflow import Concept, Proposal, Request +from hypha.apply.funds.workflows.models.stage import Concept, Proposal, Request from .blocks import ( DeterminationBlock, diff --git a/hypha/apply/determinations/options.py b/hypha/apply/determinations/options.py index c70b8eec9b..f89953353a 100644 --- a/hypha/apply/determinations/options.py +++ b/hypha/apply/determinations/options.py @@ -1,6 +1,6 @@ from django.utils.translation import gettext_lazy as _ -from hypha.apply.funds.workflow import DETERMINATION_OUTCOMES +from hypha.apply.funds.workflows import DETERMINATION_OUTCOMES REJECTED = 0 NEEDS_MORE_INFO = 1 diff --git a/hypha/apply/determinations/permissions.py b/hypha/apply/determinations/permissions.py index 3d6c7d9f01..a98be0d9bb 100644 --- a/hypha/apply/determinations/permissions.py +++ b/hypha/apply/determinations/permissions.py @@ -1,4 +1,5 @@ -from .options import DETERMINATION_OUTCOMES +from hypha.apply.funds.workflows import DETERMINATION_OUTCOMES + from .utils import determination_actions, transition_from_outcome diff --git a/hypha/apply/determinations/views.py b/hypha/apply/determinations/views.py index e303b38c0c..9ce6aec833 100644 --- a/hypha/apply/determinations/views.py +++ b/hypha/apply/determinations/views.py @@ -16,7 +16,8 @@ from hypha.apply.activity.messaging import MESSAGES, messenger from hypha.apply.activity.models import Activity from hypha.apply.funds.models import ApplicationSubmission -from hypha.apply.funds.workflow import DETERMINATION_OUTCOMES, Concept +from hypha.apply.funds.workflows import DETERMINATION_OUTCOMES +from hypha.apply.funds.workflows.models.stage import Concept from hypha.apply.projects.models import Project from hypha.apply.review.models import Review from hypha.apply.stream_forms.models import BaseStreamForm diff --git a/hypha/apply/funds/admin_forms.py b/hypha/apply/funds/admin_forms.py index f52e331720..108755d55d 100644 --- a/hypha/apply/funds/admin_forms.py +++ b/hypha/apply/funds/admin_forms.py @@ -6,7 +6,7 @@ from wagtail.admin.forms import WagtailAdminModelForm, WagtailAdminPageForm from .models.submissions import ApplicationSubmission -from .workflow import WORKFLOWS +from .workflows import WORKFLOWS class WorkflowFormAdminForm(WagtailAdminPageForm): diff --git a/hypha/apply/funds/management/commands/drafts_cleanup.py b/hypha/apply/funds/management/commands/drafts_cleanup.py index 4d19051680..c9eb48566b 100644 --- a/hypha/apply/funds/management/commands/drafts_cleanup.py +++ b/hypha/apply/funds/management/commands/drafts_cleanup.py @@ -6,7 +6,7 @@ from django.utils import timezone from hypha.apply.funds.models.submissions import ApplicationSubmission -from hypha.apply.funds.workflow import DRAFT_STATE +from hypha.apply.funds.workflows import DRAFT_STATE def check_not_negative(value) -> int: diff --git a/hypha/apply/funds/management/commands/migration_base.py b/hypha/apply/funds/management/commands/migration_base.py index 7f9091bf3d..3cd82352c3 100644 --- a/hypha/apply/funds/management/commands/migration_base.py +++ b/hypha/apply/funds/management/commands/migration_base.py @@ -16,7 +16,7 @@ from hypha.apply.categories.models import Category, Option from hypha.apply.funds.models import ApplicationSubmission, FundType, LabType, Round from hypha.apply.funds.models.forms import LabBaseForm, RoundBaseForm -from hypha.apply.funds.workflow import INITIAL_STATE +from hypha.apply.funds.workflows import INITIAL_STATE class MigrationStorage(S3Boto3Storage): diff --git a/hypha/apply/funds/models/applications.py b/hypha/apply/funds/models/applications.py index 4d71896101..7636aa770e 100644 --- a/hypha/apply/funds/models/applications.py +++ b/hypha/apply/funds/models/applications.py @@ -48,7 +48,7 @@ from ..admin_forms import RoundBasePageAdminForm, WorkflowFormAdminForm from ..edit_handlers import ReadOnlyPanel -from ..workflow import OPEN_CALL_PHASES +from ..workflows.constants import OPEN_CALL_PHASES from .submissions import ApplicationSubmission from .utils import ( LIMIT_TO_REVIEWERS, diff --git a/hypha/apply/funds/models/submissions.py b/hypha/apply/funds/models/submissions.py index 6bf73d5909..2bd3b6812c 100644 --- a/hypha/apply/funds/models/submissions.py +++ b/hypha/apply/funds/models/submissions.py @@ -51,16 +51,9 @@ from hypha.apply.users.roles import APPLICANT_GROUP_NAME from ..blocks import NAMED_BLOCKS, ApplicationCustomFormFieldsBlock -from ..workflow import ( - COMMUNITY_REVIEW_PHASES, - DETERMINATION_RESPONSE_PHASES, - DRAFT_STATE, - INITIAL_STATE, +from ..workflows import ( PHASES, - PHASES_MAPPING, - STAGE_CHANGE_ACTIONS, WORKFLOWS, - UserPermissions, accepted_statuses, active_statuses, dismissed_statuses, @@ -69,6 +62,15 @@ get_review_active_statuses, review_statuses, ) +from ..workflows.constants import ( + COMMUNITY_REVIEW_PHASES, + DETERMINATION_RESPONSE_PHASES, + DRAFT_STATE, + INITIAL_STATE, + PHASES_MAPPING, + STAGE_CHANGE_ACTIONS, + UserPermissions, +) from .mixins import AccessFormData from .reviewer_role import ReviewerRole from .utils import ( diff --git a/hypha/apply/funds/models/utils.py b/hypha/apply/funds/models/utils.py index e304df75fc..2b3ef0a792 100644 --- a/hypha/apply/funds/models/utils.py +++ b/hypha/apply/funds/models/utils.py @@ -24,7 +24,7 @@ STAFF_GROUP_NAME, ) -from ..workflow import DRAFT_STATE, WORKFLOWS +from ..workflows import DRAFT_STATE, WORKFLOWS REVIEW_GROUPS = [ STAFF_GROUP_NAME, diff --git a/hypha/apply/funds/services.py b/hypha/apply/funds/services.py index 8d372e28b2..3c9c58e9cf 100644 --- a/hypha/apply/funds/services.py +++ b/hypha/apply/funds/services.py @@ -18,7 +18,7 @@ from hypha.apply.activity.messaging import MESSAGES, messenger from hypha.apply.activity.models import Activity, Event from hypha.apply.funds.models.assigned_reviewers import AssignedReviewers -from hypha.apply.funds.workflow import INITIAL_STATE +from hypha.apply.funds.workflows import INITIAL_STATE from hypha.apply.review.options import DISAGREE, MAYBE diff --git a/hypha/apply/funds/tables.py b/hypha/apply/funds/tables.py index 9c7823a64f..8e14f452d8 100644 --- a/hypha/apply/funds/tables.py +++ b/hypha/apply/funds/tables.py @@ -23,7 +23,7 @@ from .models import ApplicationSubmission, Round, ScreeningStatus from .widgets import Select2MultiCheckboxesWidget -from .workflow import STATUSES, get_review_active_statuses +from .workflows import STATUSES, get_review_active_statuses User = get_user_model() diff --git a/hypha/apply/funds/tests/factories/models.py b/hypha/apply/funds/tests/factories/models.py index 17164445da..338bc2f997 100644 --- a/hypha/apply/funds/tests/factories/models.py +++ b/hypha/apply/funds/tests/factories/models.py @@ -26,7 +26,11 @@ RoundBaseForm, RoundBaseReviewForm, ) -from hypha.apply.funds.workflow import ConceptProposal, Request, RequestExternal +from hypha.apply.funds.workflows.registry import ( + ConceptProposal, + Request, + RequestExternal, +) from hypha.apply.stream_forms.testing.factories import FormDataFactory from hypha.apply.users.roles import REVIEWER_GROUP_NAME, STAFF_GROUP_NAME from hypha.apply.users.tests.factories import ( diff --git a/hypha/apply/funds/tests/test_models.py b/hypha/apply/funds/tests/test_models.py index bc8c3678a3..1f99ffdfc7 100644 --- a/hypha/apply/funds/tests/test_models.py +++ b/hypha/apply/funds/tests/test_models.py @@ -12,7 +12,8 @@ from hypha.apply.funds.blocks import EmailBlock, FullNameBlock from hypha.apply.funds.models import ApplicationSubmission, AssignedReviewers, Reminder -from hypha.apply.funds.workflow import DRAFT_STATE, Request +from hypha.apply.funds.workflows.constants import DRAFT_STATE +from hypha.apply.funds.workflows.registry import Request from hypha.apply.review.options import AGREE, MAYBE, NO from hypha.apply.review.tests.factories import ReviewFactory, ReviewOpinionFactory from hypha.apply.users.tests.factories import StaffFactory diff --git a/hypha/apply/funds/tests/test_views.py b/hypha/apply/funds/tests/test_views.py index 6b9316dbc2..affce4cd54 100644 --- a/hypha/apply/funds/tests/test_views.py +++ b/hypha/apply/funds/tests/test_views.py @@ -26,7 +26,7 @@ SealedSubmissionFactory, ) from hypha.apply.funds.views.submission_detail import SubmissionDetailView -from hypha.apply.funds.workflow import INITIAL_STATE +from hypha.apply.funds.workflows import INITIAL_STATE from hypha.apply.projects.models import Project from hypha.apply.projects.tests.factories import ProjectFactory from hypha.apply.review.tests.factories import ReviewFactory diff --git a/hypha/apply/funds/tests/views/test_submission_delete.py b/hypha/apply/funds/tests/views/test_submission_delete.py index 9016285f9c..6b8119d8e0 100644 --- a/hypha/apply/funds/tests/views/test_submission_delete.py +++ b/hypha/apply/funds/tests/views/test_submission_delete.py @@ -1,7 +1,7 @@ from django.urls import reverse from hypha.apply.funds.tests.factories.models import ApplicationSubmissionFactory -from hypha.apply.funds.workflow import DRAFT_STATE +from hypha.apply.funds.workflows import DRAFT_STATE from hypha.apply.users.tests.factories import AdminFactory, ApplicantFactory diff --git a/hypha/apply/funds/views/all.py b/hypha/apply/funds/views/all.py index 3a47586672..ce6b6d3fc8 100644 --- a/hypha/apply/funds/views/all.py +++ b/hypha/apply/funds/views/all.py @@ -20,7 +20,7 @@ from hypha.apply.determinations.views import BatchDeterminationCreateView from hypha.apply.funds.models.screening import ScreeningStatus from hypha.apply.funds.utils import export_submissions_to_csv -from hypha.apply.funds.workflow import PHASES, get_action_mapping, review_statuses +from hypha.apply.funds.workflows import PHASES, get_action_mapping, review_statuses from hypha.apply.search.filters import apply_date_filter from hypha.apply.search.query_parser import parse_search_query from hypha.apply.users.decorators import ( diff --git a/hypha/apply/funds/views/partials.py b/hypha/apply/funds/views/partials.py index d4ccdc2741..77db81fcfc 100644 --- a/hypha/apply/funds/views/partials.py +++ b/hypha/apply/funds/views/partials.py @@ -36,7 +36,7 @@ get_statuses_as_params, status_and_phases_mapping, ) -from ..workflow import PHASES_MAPPING +from ..workflows.constants import PHASES_MAPPING User = get_user_model() diff --git a/hypha/apply/funds/views/staff_assignments.py b/hypha/apply/funds/views/staff_assignments.py index c3041de655..4024a033dd 100644 --- a/hypha/apply/funds/views/staff_assignments.py +++ b/hypha/apply/funds/views/staff_assignments.py @@ -11,7 +11,7 @@ ReviewerRole, ) from ..tables import StaffAssignmentsTable -from ..workflow import active_statuses +from ..workflows import active_statuses User = get_user_model() diff --git a/hypha/apply/funds/views/submission_delete.py b/hypha/apply/funds/views/submission_delete.py index e1e5b1d294..66e4a1f61a 100644 --- a/hypha/apply/funds/views/submission_delete.py +++ b/hypha/apply/funds/views/submission_delete.py @@ -7,7 +7,7 @@ from hypha.apply.activity.models import Event from ..models import ApplicationSubmission -from ..workflow import DRAFT_STATE +from ..workflows.constants import DRAFT_STATE class SubmissionDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView): diff --git a/hypha/apply/funds/views/submission_detail.py b/hypha/apply/funds/views/submission_detail.py index 81c24ed039..8b5605666f 100644 --- a/hypha/apply/funds/views/submission_detail.py +++ b/hypha/apply/funds/views/submission_detail.py @@ -41,7 +41,7 @@ get_archive_view_groups, has_permission, ) -from ..workflow import DRAFT_STATE +from ..workflows import DRAFT_STATE if settings.APPLICATION_TRANSLATIONS_ENABLED: from hypha.apply.translate.utils import ( diff --git a/hypha/apply/funds/views/submission_edit.py b/hypha/apply/funds/views/submission_edit.py index 7efc4c2136..82661abf01 100644 --- a/hypha/apply/funds/views/submission_edit.py +++ b/hypha/apply/funds/views/submission_edit.py @@ -62,7 +62,7 @@ from ..permissions import ( has_permission, ) -from ..workflow import ( +from ..workflows.constants import ( DRAFT_STATE, STAGE_CHANGE_ACTIONS, ) diff --git a/hypha/apply/funds/workflow.py b/hypha/apply/funds/workflow.py deleted file mode 100644 index 89c9b5b931..0000000000 --- a/hypha/apply/funds/workflow.py +++ /dev/null @@ -1,1498 +0,0 @@ -import itertools -from collections import defaultdict -from enum import Enum - -from django.conf import settings -from django.utils.text import slugify -from django.utils.translation import gettext as _ - -""" -This file defines classes which allow you to compose workflows based on the following structure: - -Workflow -> Stage -> Phase -> Action - -Current limitations: -* Changing the name of a phase will mean that any object which references it cannot progress. [will -be fixed when streamfield, may require intermediate fix prior to launch] -* Do not reorder without looking at workflow automations steps in form_valid() in -hypha/apply/funds/views.py and hypha/apply/review/views.py. -""" - -PHASE_BG_COLORS = { - "Draft": "bg-gray-200", - "Accepted": "bg-green-200", - "Need screening": "bg-cyan-200", - "Ready for Determination": "bg-blue-200", - "Ready For Discussion": "bg-blue-100", - "Invited for Proposal": "bg-green-100", - "Internal Review": "bg-yellow-200", - "External Review": "bg-yellow-200", - "More information required": "bg-yellow-100", - "Accepted but additional info required": "bg-green-100", - "Dismissed": "bg-rose-200", -} - - -class UserPermissions(Enum): - STAFF = 1 - ADMIN = 2 - LEAD = 3 - APPLICANT = 4 - - -class Workflow(dict): - def __init__(self, name, admin_name, **data): - self.name = name - self.admin_name = admin_name - super().__init__(**data) - - def __str__(self): - return self.name - - @property - def stages(self): - stages = [] - for phase in self.values(): - if phase.stage not in stages: - stages.append(phase.stage) - return stages - - @property - def stepped_phases(self): - phases = defaultdict(list) - for phase in list(self.values()): - phases[phase.step].append(phase) - return phases - - def phases_for(self, user=None): - # Grab the first phase for each step - visible only, the display phase - return [ - phase - for phase, *_ in self.stepped_phases.values() - if not user or phase.permissions.can_view(user) - ] - - def previous_visible(self, current, user): - """Find the latest phase that the user has view permissions for""" - display_phase = self.stepped_phases[current.step][0] - phases = self.phases_for() - index = phases.index(display_phase) - for phase in phases[index - 1 :: -1]: - if phase.permissions.can_view(user): - return phase - - -class Phase: - """ - Phase Names: - display_name = phase name displayed to staff members in the system - public_name = phase name displayed to applicants in the system - future_name = phase_name displayed to applicants if they haven't passed this stage - """ - - def __init__( - self, - name, - display, - stage, - permissions, - step, - public=None, - future=None, - transitions=None, - ): - if transitions is None: - transitions = {} - self.name = name - self.display_name = display - self.display_slug = slugify(display) - if public and future: - raise ValueError("Cant provide both a future and a public name") - - self.public_name = public or self.display_name - self.future_name_staff = future or self.display_name - self.bg_color = PHASE_BG_COLORS.get(self.display_name, "bg-gray-200") - self.future_name_public = future or self.public_name - self.stage = stage - self.permissions = Permissions(permissions) - self.step = step - - # For building transition methods on the parent - self.transitions = {} - - default_permissions = { - UserPermissions.STAFF, - UserPermissions.LEAD, - UserPermissions.ADMIN, - } - - for transition_target, action in transitions.items(): - transition = {} - try: - transition["display"] = action.get("display") - except AttributeError: - transition["display"] = action - transition["permissions"] = default_permissions - else: - transition["method"] = action.get("method") - conditions = action.get("conditions", "") - transition["conditions"] = conditions.split(",") if conditions else [] - transition["permissions"] = action.get( - "permissions", default_permissions - ) - if "custom" in action: - transition["custom"] = action["custom"] - - self.transitions[transition_target] = transition - - def __str__(self): - return self.display_name - - def __repr__(self): - return f"" - - -class Stage: - def __init__(self, name, has_external_review=False): - self.name = name - self.has_external_review = has_external_review - - def __str__(self): - return self.name - - def __repr__(self): - return f"" - - -class Permissions: - def __init__(self, permissions): - self.permissions = permissions - - def can_do(self, user, action): - checks = self.permissions.get(action, []) - return any(check(user) for check in checks) - - def can_edit(self, user): - return self.can_do(user, "edit") - - def can_review(self, user): - return self.can_do(user, "review") - - def can_view(self, user): - return self.can_do(user, "view") - - -staff_can = lambda user: user.is_apply_staff # NOQA - -applicant_can = lambda user: user.is_applicant # NOQA - -reviewer_can = lambda user: user.is_reviewer # NOQA - -partner_can = lambda user: user.is_partner # NOQA - -community_can = lambda user: user.is_community_reviewer # NOQA - - -def make_permissions(edit=None, review=None, view=None): - return { - "edit": edit or [], - "review": review or [], - "view": view - or [ - staff_can, - applicant_can, - reviewer_can, - partner_can, - ], - } - - -no_permissions = make_permissions() - -default_permissions = make_permissions(edit=[staff_can], review=[staff_can]) - -hidden_from_applicant_permissions = make_permissions( - edit=[staff_can], review=[staff_can], view=[staff_can, reviewer_can] -) - -reviewer_review_permissions = make_permissions( - edit=[staff_can], review=[staff_can, reviewer_can] -) - -community_review_permissions = make_permissions( - edit=[staff_can], review=[staff_can, reviewer_can, community_can] -) - -applicant_edit_permissions = make_permissions( - edit=[applicant_can, partner_can], review=[staff_can] -) - -staff_edit_permissions = make_permissions(edit=[staff_can]) - - -Request = Stage("Request", False) - -RequestSame = Stage("RequestSame", True) - -RequestExt = Stage("RequestExt", True) - -RequestCom = Stage("RequestCom", True) - -Concept = Stage("Concept", False) - -Proposal = Stage("Proposal", True) - -DRAFT_STATE = "draft" - -INITIAL_STATE = "in_discussion" - -SingleStageDefinition = [ - { - DRAFT_STATE: { - "transitions": { - INITIAL_STATE: { - "display": _("Submit"), - "permissions": {UserPermissions.APPLICANT}, - "method": "create_revision", - "custom": {"trigger_on_submit": True}, - }, - }, - "display": _("Draft"), - "stage": Request, - "permissions": applicant_edit_permissions, - } - }, - { - INITIAL_STATE: { - "transitions": { - "more_info": _("Request More Information"), - "internal_review": _("Open Review"), - "determination": _("Ready For Determination"), - "almost": _("Accept but additional info required"), - "accepted": _("Accept"), - "rejected": _("Dismiss"), - }, - "display": _("Need screening"), - "public": _("Application Received"), - "stage": Request, - "permissions": default_permissions, - }, - "more_info": { - "transitions": { - INITIAL_STATE: { - "display": _("Submit"), - "permissions": { - UserPermissions.APPLICANT, - UserPermissions.STAFF, - UserPermissions.LEAD, - UserPermissions.ADMIN, - }, - "method": "create_revision", - "custom": {"trigger_on_submit": True}, - }, - "determination": _("Ready For Determination"), - "almost": _("Accept but additional info required"), - "accepted": _("Accept"), - "rejected": _("Dismiss"), - }, - "display": _("More information required"), - "stage": Request, - "permissions": applicant_edit_permissions, - }, - }, - { - "internal_review": { - "transitions": { - "post_review_discussion": _("Close Review"), - INITIAL_STATE: _("Need screening (revert)"), - }, - "display": _("Internal Review"), - "public": _("{org_short_name} Review").format( - org_short_name=settings.ORG_SHORT_NAME - ), - "stage": Request, - "permissions": default_permissions, - }, - }, - { - "post_review_discussion": { - "transitions": { - "post_review_more_info": _("Request More Information"), - "determination": _("Ready For Determination"), - "internal_review": _("Open Review (revert)"), - "almost": _("Accept but additional info required"), - "accepted": _("Accept"), - "rejected": _("Dismiss"), - }, - "display": _("Ready For Discussion"), - "stage": Request, - "permissions": hidden_from_applicant_permissions, - }, - "post_review_more_info": { - "transitions": { - "post_review_discussion": { - "display": _("Submit"), - "permissions": { - UserPermissions.APPLICANT, - UserPermissions.STAFF, - UserPermissions.LEAD, - UserPermissions.ADMIN, - }, - "method": "create_revision", - "custom": {"trigger_on_submit": True}, - }, - "determination": _("Ready For Determination"), - "almost": _("Accept but additional info required"), - "accepted": _("Accept"), - "rejected": _("Dismiss"), - }, - "display": _("More information required"), - "stage": Request, - "permissions": applicant_edit_permissions, - }, - }, - { - "determination": { - "transitions": { - "post_review_discussion": _("Ready For Discussion (revert)"), - "almost": _("Accept but additional info required"), - "accepted": _("Accept"), - "rejected": _("Dismiss"), - }, - "display": _("Ready for Determination"), - "permissions": hidden_from_applicant_permissions, - "stage": Request, - }, - }, - { - "accepted": { - "display": _("Accepted"), - "future": _("Application Outcome"), - "stage": Request, - "permissions": staff_edit_permissions, - }, - "almost": { - "transitions": { - "accepted": _("Accept"), - "post_review_discussion": _("Ready For Discussion (revert)"), - }, - "display": _("Accepted but additional info required"), - "stage": Request, - "permissions": applicant_edit_permissions, - }, - "rejected": { - "display": _("Dismissed"), - "stage": Request, - "permissions": no_permissions, - }, - }, -] - -SingleStageSameDefinition = [ - { - DRAFT_STATE: { - "transitions": { - INITIAL_STATE: { - "display": _("Submit"), - "permissions": {UserPermissions.APPLICANT}, - "method": "create_revision", - "custom": {"trigger_on_submit": True}, - }, - }, - "display": _("Draft"), - "stage": RequestSame, - "permissions": applicant_edit_permissions, - } - }, - { - INITIAL_STATE: { - "transitions": { - "same_more_info": _("Request More Information"), - "same_internal_review": _("Open Review"), - "same_determination": _("Ready For Determination"), - "same_rejected": _("Dismiss"), - }, - "display": _("Need screening"), - "public": _("Application Received"), - "stage": RequestSame, - "permissions": default_permissions, - }, - "same_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": RequestSame, - "permissions": applicant_edit_permissions, - }, - }, - { - "same_internal_review": { - "transitions": { - "same_post_review_discussion": _("Close Review"), - INITIAL_STATE: _("Need screening (revert)"), - }, - "display": _("Review"), - "public": _("{org_short_name} Review").format( - org_short_name=settings.ORG_SHORT_NAME - ), - "stage": RequestSame, - "permissions": reviewer_review_permissions, - }, - }, - { - "same_post_review_discussion": { - "transitions": { - "same_post_review_more_info": _("Request More Information"), - "same_determination": _("Ready For Determination"), - "same_internal_review": _("Open Review (revert)"), - "same_rejected": _("Dismiss"), - }, - "display": _("Ready For Discussion"), - "stage": RequestSame, - "permissions": hidden_from_applicant_permissions, - }, - "same_post_review_more_info": { - "transitions": { - "same_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": RequestSame, - "permissions": applicant_edit_permissions, - }, - }, - { - "same_determination": { - "transitions": { - "same_post_review_discussion": _("Ready For Discussion (revert)"), - "same_almost": _("Accept but additional info required"), - "same_accepted": _("Accept"), - "same_rejected": _("Dismiss"), - }, - "display": _("Ready for Determination"), - "permissions": hidden_from_applicant_permissions, - "stage": RequestSame, - }, - }, - { - "same_accepted": { - "display": _("Accepted"), - "future": _("Application Outcome"), - "stage": RequestSame, - "permissions": staff_edit_permissions, - }, - "same_almost": { - "transitions": { - "same_accepted": _("Accept"), - "same_post_review_discussion": _("Ready For Discussion (revert)"), - }, - "display": _("Accepted but additional info required"), - "stage": RequestSame, - "permissions": applicant_edit_permissions, - }, - "same_rejected": { - "display": _("Dismissed"), - "stage": RequestSame, - "permissions": no_permissions, - }, - }, -] - - -SingleStageExternalDefinition = [ - { - DRAFT_STATE: { - "transitions": { - INITIAL_STATE: { - "display": _("Submit"), - "permissions": {UserPermissions.APPLICANT}, - "method": "create_revision", - "custom": {"trigger_on_submit": True}, - }, - }, - "display": _("Draft"), - "stage": RequestExt, - "permissions": applicant_edit_permissions, - } - }, - { - INITIAL_STATE: { - "transitions": { - "ext_more_info": _("Request More Information"), - "ext_internal_review": _("Open Review"), - "ext_determination": _("Ready For Determination"), - "ext_rejected": _("Dismiss"), - }, - "display": _("Need screening"), - "public": _("Application Received"), - "stage": RequestExt, - "permissions": default_permissions, - }, - "ext_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": RequestExt, - "permissions": applicant_edit_permissions, - }, - }, - { - "ext_internal_review": { - "transitions": { - "ext_post_review_discussion": _("Close Review"), - INITIAL_STATE: _("Need screening (revert)"), - }, - "display": _("Internal Review"), - "public": _("{org_short_name} Review").format( - org_short_name=settings.ORG_SHORT_NAME - ), - "stage": RequestExt, - "permissions": default_permissions, - }, - }, - { - "ext_post_review_discussion": { - "transitions": { - "ext_post_review_more_info": _("Request More Information"), - "ext_external_review": _("Open External Review"), - "ext_determination": _("Ready For Determination"), - "ext_internal_review": _("Open Internal Review (revert)"), - "ext_rejected": _("Dismiss"), - }, - "display": _("Ready For Discussion"), - "stage": RequestExt, - "permissions": hidden_from_applicant_permissions, - }, - "ext_post_review_more_info": { - "transitions": { - "ext_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": RequestExt, - "permissions": applicant_edit_permissions, - }, - }, - { - "ext_external_review": { - "transitions": { - "ext_post_external_review_discussion": _("Close Review"), - "ext_post_review_discussion": _("Ready For Discussion (revert)"), - }, - "display": _("External Review"), - "stage": RequestExt, - "permissions": reviewer_review_permissions, - }, - }, - { - "ext_post_external_review_discussion": { - "transitions": { - "ext_post_external_review_more_info": _("Request More Information"), - "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"), - "stage": RequestExt, - "permissions": hidden_from_applicant_permissions, - }, - "ext_post_external_review_more_info": { - "transitions": { - "ext_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": RequestExt, - "permissions": applicant_edit_permissions, - }, - }, - { - "ext_determination": { - "transitions": { - "ext_post_external_review_discussion": _( - "Ready For Discussion (revert)" - ), - "ext_almost": _("Accept but additional info required"), - "ext_accepted": _("Accept"), - "ext_rejected": _("Dismiss"), - }, - "display": _("Ready for Determination"), - "permissions": hidden_from_applicant_permissions, - "stage": RequestExt, - }, - }, - { - "ext_accepted": { - "display": _("Accepted"), - "future": _("Application Outcome"), - "stage": RequestExt, - "permissions": staff_edit_permissions, - }, - "ext_almost": { - "transitions": { - "ext_accepted": _("Accept"), - "ext_post_external_review_discussion": _( - "Ready For Discussion (revert)" - ), - }, - "display": _("Accepted but additional info required"), - "stage": RequestExt, - "permissions": applicant_edit_permissions, - }, - "ext_rejected": { - "display": _("Dismissed"), - "stage": RequestExt, - "permissions": no_permissions, - }, - }, -] - - -SingleStageCommunityDefinition = [ - { - DRAFT_STATE: { - "transitions": { - INITIAL_STATE: { - "display": _("Submit"), - "permissions": {UserPermissions.APPLICANT}, - "method": "create_revision", - "custom": {"trigger_on_submit": True}, - }, - }, - "display": _("Draft"), - "stage": RequestCom, - "permissions": applicant_edit_permissions, - } - }, - { - INITIAL_STATE: { - "transitions": { - "com_more_info": _("Request More Information"), - "com_open_call": "Open Call (public)", - "com_internal_review": _("Open Review"), - "com_community_review": _("Open Community Review"), - "com_determination": _("Ready For Determination"), - "com_rejected": _("Dismiss"), - }, - "display": _("Need screening"), - "public": _("Application Received"), - "stage": RequestCom, - "permissions": default_permissions, - }, - "com_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": RequestCom, - "permissions": applicant_edit_permissions, - }, - "com_open_call": { - "transitions": { - INITIAL_STATE: _("Need screening (revert)"), - "com_rejected": _("Dismiss"), - }, - "display": "Open Call (public)", - "stage": RequestCom, - "permissions": staff_edit_permissions, - }, - }, - { - "com_internal_review": { - "transitions": { - "com_community_review": _("Open Community Review"), - "com_post_review_discussion": _("Close Review"), - INITIAL_STATE: _("Need screening (revert)"), - "com_rejected": _("Dismiss"), - }, - "display": _("Internal Review"), - "public": _("{org_short_name} Review").format( - org_short_name=settings.ORG_SHORT_NAME - ), - "stage": RequestCom, - "permissions": default_permissions, - }, - "com_community_review": { - "transitions": { - "com_post_review_discussion": _("Close Review"), - "com_internal_review": _("Open Internal Review (revert)"), - "com_rejected": _("Dismiss"), - }, - "display": _("Community Review"), - "public": _("{org_short_name} Review").format( - org_short_name=settings.ORG_SHORT_NAME - ), - "stage": RequestCom, - "permissions": community_review_permissions, - }, - }, - { - "com_post_review_discussion": { - "transitions": { - "com_post_review_more_info": _("Request More Information"), - "com_external_review": _("Open External Review"), - "com_determination": _("Ready For Determination"), - "com_internal_review": _("Open Internal Review (revert)"), - "com_rejected": _("Dismiss"), - }, - "display": _("Ready For Discussion"), - "stage": RequestCom, - "permissions": hidden_from_applicant_permissions, - }, - "com_post_review_more_info": { - "transitions": { - "com_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": RequestCom, - "permissions": applicant_edit_permissions, - }, - }, - { - "com_external_review": { - "transitions": { - "com_post_external_review_discussion": _("Close Review"), - "com_post_review_discussion": _("Ready For Discussion (revert)"), - }, - "display": _("External Review"), - "stage": RequestCom, - "permissions": reviewer_review_permissions, - }, - }, - { - "com_post_external_review_discussion": { - "transitions": { - "com_post_external_review_more_info": _("Request More Information"), - "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"), - "stage": RequestCom, - "permissions": hidden_from_applicant_permissions, - }, - "com_post_external_review_more_info": { - "transitions": { - "com_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": RequestCom, - "permissions": applicant_edit_permissions, - }, - }, - { - "com_determination": { - "transitions": { - "com_post_external_review_discussion": _( - "Ready For Discussion (revert)" - ), - "com_almost": _("Accept but additional info required"), - "com_accepted": _("Accept"), - "com_rejected": _("Dismiss"), - }, - "display": _("Ready for Determination"), - "permissions": hidden_from_applicant_permissions, - "stage": RequestCom, - }, - }, - { - "com_accepted": { - "display": _("Accepted"), - "future": _("Application Outcome"), - "stage": RequestCom, - "permissions": staff_edit_permissions, - }, - "com_almost": { - "transitions": { - "com_accepted": _("Accept"), - "com_post_external_review_discussion": _( - "Ready For Discussion (revert)" - ), - }, - "display": _("Accepted but additional info required"), - "stage": RequestCom, - "permissions": applicant_edit_permissions, - }, - "com_rejected": { - "display": _("Dismissed"), - "stage": RequestCom, - "permissions": no_permissions, - }, - }, -] - - -DoubleStageDefinition = [ - { - DRAFT_STATE: { - "transitions": { - INITIAL_STATE: { - "display": _("Submit"), - "permissions": {UserPermissions.APPLICANT}, - "method": "create_revision", - "custom": {"trigger_on_submit": True}, - }, - }, - "display": _("Draft"), - "stage": Concept, - "permissions": applicant_edit_permissions, - } - }, - { - INITIAL_STATE: { - "transitions": { - "concept_more_info": _("Request More Information"), - "concept_internal_review": _("Open Review"), - "concept_determination": _("Ready For Preliminary Determination"), - "invited_to_proposal": _("Invite to Proposal"), - "concept_rejected": _("Dismiss"), - }, - "display": _("Need screening"), - "public": _("Concept Note Received"), - "stage": Concept, - "permissions": default_permissions, - }, - "concept_more_info": { - "transitions": { - INITIAL_STATE: { - "display": _("Submit"), - "permissions": { - UserPermissions.APPLICANT, - UserPermissions.STAFF, - UserPermissions.LEAD, - UserPermissions.ADMIN, - }, - "method": "create_revision", - "custom": {"trigger_on_submit": True}, - }, - "concept_rejected": _("Dismiss"), - "invited_to_proposal": _("Invite to Proposal"), - "concept_determination": _("Ready For Preliminary Determination"), - }, - "display": _("More information required"), - "stage": Concept, - "permissions": applicant_edit_permissions, - }, - }, - { - "concept_internal_review": { - "transitions": { - "concept_review_discussion": _("Close Review"), - INITIAL_STATE: _("Need screening (revert)"), - "invited_to_proposal": _("Invite to Proposal"), - }, - "display": _("Internal Review"), - "public": _("{org_short_name} Review").format( - org_short_name=settings.ORG_SHORT_NAME - ), - "stage": Concept, - "permissions": default_permissions, - }, - }, - { - "concept_review_discussion": { - "transitions": { - "concept_review_more_info": _("Request More Information"), - "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"), - "stage": Concept, - "permissions": hidden_from_applicant_permissions, - }, - "concept_review_more_info": { - "transitions": { - "concept_review_discussion": { - "display": _("Submit"), - "permissions": { - UserPermissions.APPLICANT, - UserPermissions.STAFF, - UserPermissions.LEAD, - UserPermissions.ADMIN, - }, - "method": "create_revision", - "custom": {"trigger_on_submit": True}, - }, - "invited_to_proposal": _("Invite to Proposal"), - }, - "display": _("More information required"), - "stage": Concept, - "permissions": applicant_edit_permissions, - }, - }, - { - "concept_determination": { - "transitions": { - "concept_review_discussion": _("Ready For Discussion (revert)"), - "invited_to_proposal": _("Invite to Proposal"), - "concept_rejected": _("Dismiss"), - }, - "display": _("Ready for Preliminary Determination"), - "permissions": hidden_from_applicant_permissions, - "stage": Concept, - }, - }, - { - "invited_to_proposal": { - "display": _("Concept Accepted"), - "future": _("Preliminary Determination"), - "transitions": { - "draft_proposal": { - "display": _("Progress"), - "method": "progress_application", - "permissions": { - UserPermissions.STAFF, - UserPermissions.LEAD, - UserPermissions.ADMIN, - }, - "conditions": "not_progressed", - }, - }, - "stage": Concept, - "permissions": no_permissions, - }, - "concept_rejected": { - "display": _("Dismissed"), - "stage": Concept, - "permissions": no_permissions, - }, - }, - { - "draft_proposal": { - "transitions": { - "proposal_discussion": { - "display": _("Submit"), - "permissions": {UserPermissions.APPLICANT}, - "method": "create_revision", - "custom": {"trigger_on_submit": True}, - }, - "external_review": _("Open External Review"), - "proposal_determination": _("Ready For Final Determination"), - "proposal_rejected": _("Dismiss"), - }, - "display": _("Invited for Proposal"), - "stage": Proposal, - "permissions": applicant_edit_permissions, - }, - }, - { - "proposal_discussion": { - "transitions": { - "proposal_more_info": _("Request More Information"), - "proposal_internal_review": _("Open Review"), - "external_review": _("Open External Review"), - "proposal_determination": _("Ready For Final Determination"), - "proposal_rejected": _("Dismiss"), - }, - "display": _("Proposal Received"), - "stage": Proposal, - "permissions": default_permissions, - }, - "proposal_more_info": { - "transitions": { - "proposal_discussion": { - "display": _("Submit"), - "permissions": { - UserPermissions.APPLICANT, - UserPermissions.STAFF, - UserPermissions.LEAD, - UserPermissions.ADMIN, - }, - "method": "create_revision", - "custom": {"trigger_on_submit": True}, - }, - "external_review": _("Open External Review"), - "proposal_determination": _("Ready For Final Determination"), - "proposal_rejected": _("Dismiss"), - }, - "display": _("More information required"), - "stage": Proposal, - "permissions": applicant_edit_permissions, - }, - }, - { - "proposal_internal_review": { - "transitions": { - "post_proposal_review_discussion": _("Close Review"), - "proposal_discussion": _("Proposal Received (revert)"), - }, - "display": _("Internal Review"), - "public": _("{org_short_name} Review").format( - org_short_name=settings.ORG_SHORT_NAME - ), - "stage": Proposal, - "permissions": default_permissions, - }, - }, - { - "post_proposal_review_discussion": { - "transitions": { - "post_proposal_review_more_info": _("Request More Information"), - "external_review": _("Open External Review"), - "proposal_determination": _("Ready For Final Determination"), - "proposal_internal_review": _("Open Internal Review (revert)"), - "proposal_rejected": _("Dismiss"), - }, - "display": _("Ready For Discussion"), - "stage": Proposal, - "permissions": hidden_from_applicant_permissions, - }, - "post_proposal_review_more_info": { - "transitions": { - "post_proposal_review_discussion": { - "display": _("Submit"), - "permissions": { - UserPermissions.APPLICANT, - UserPermissions.STAFF, - UserPermissions.LEAD, - UserPermissions.ADMIN, - }, - "method": "create_revision", - "custom": {"trigger_on_submit": True}, - }, - "external_review": _("Open External Review"), - }, - "display": _("More information required"), - "stage": Proposal, - "permissions": applicant_edit_permissions, - }, - }, - { - "external_review": { - "transitions": { - "post_external_review_discussion": _("Close Review"), - "post_proposal_review_discussion": _("Ready For Discussion (revert)"), - }, - "display": _("External Review"), - "stage": Proposal, - "permissions": reviewer_review_permissions, - }, - }, - { - "post_external_review_discussion": { - "transitions": { - "post_external_review_more_info": _("Request More Information"), - "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"), - "stage": Proposal, - "permissions": hidden_from_applicant_permissions, - }, - "post_external_review_more_info": { - "transitions": { - "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": Proposal, - "permissions": applicant_edit_permissions, - }, - }, - { - "proposal_determination": { - "transitions": { - "post_external_review_discussion": _("Ready For Discussion (revert)"), - "proposal_almost": _("Accept but additional info required"), - "proposal_accepted": _("Accept"), - "proposal_rejected": _("Dismiss"), - }, - "display": _("Ready for Final Determination"), - "permissions": hidden_from_applicant_permissions, - "stage": Proposal, - }, - }, - { - "proposal_accepted": { - "display": _("Accepted"), - "future": _("Final Determination"), - "stage": Proposal, - "permissions": staff_edit_permissions, - }, - "proposal_almost": { - "transitions": { - "proposal_accepted": _("Accept"), - "post_external_review_discussion": _("Ready For Discussion (revert)"), - }, - "display": _("Accepted but additional info required"), - "stage": Proposal, - "permissions": applicant_edit_permissions, - }, - "proposal_rejected": { - "display": _("Dismissed"), - "stage": Proposal, - "permissions": no_permissions, - }, - }, -] - - -def unpack_phases(phases): - """Unpack a list of phases into a generator of step, name, phase_data.""" - return ( - (step, name, phase_data) - for step, step_data in enumerate(phases) - for name, phase_data in step_data.items() - ) - - -def phase_data(phases): - """Convert a list of phases into a dictionary of Phase objects. - - Adds the step number to the phase data. The step number is the index of the - phase in the list of phases. - """ - return { - phase_name: Phase(phase_name, step=step, **phase_data) - for step, phase_name, phase_data in unpack_phases(phases) - } - - -Request = Workflow("Request", "single", **phase_data(SingleStageDefinition)) - -RequestSameTime = Workflow( - "Request with same time review", - "single_same", - **phase_data(SingleStageSameDefinition), -) - -RequestExternal = Workflow( - "Request with external review", - "single_ext", - **phase_data(SingleStageExternalDefinition), -) - -RequestCommunity = Workflow( - "Request with community review", - "single_com", - **phase_data(SingleStageCommunityDefinition), -) - -ConceptProposal = Workflow( - "Concept & Proposal", "double", **phase_data(DoubleStageDefinition) -) - - -WORKFLOWS = { - Request.admin_name: Request, - RequestSameTime.admin_name: RequestSameTime, - RequestExternal.admin_name: RequestExternal, - RequestCommunity.admin_name: RequestCommunity, - ConceptProposal.admin_name: ConceptProposal, -} - - -# This is not a dictionary as the keys will clash for the first phase of each workflow -# We cannot find the transitions for the first stage in this instance -PHASES = list( - itertools.chain.from_iterable(workflow.items() for workflow in WORKFLOWS.values()) -) - - -def get_stage_change_actions(): - changes = set() - for workflow in WORKFLOWS.values(): - stage = None - for phase in workflow.values(): - if phase.stage != stage and stage: - changes.add(phase.name) - stage = phase.stage - - return changes - - -STAGE_CHANGE_ACTIONS = get_stage_change_actions() - - -STATUSES = defaultdict(set) - -for key, value in PHASES: - STATUSES[value.display_name].add(key) - -active_statuses = [ - status - for status, _ in PHASES - if "accepted" not in status and "rejected" not in status and "invited" not in status -] - - -def get_review_active_statuses(user=None): - reviews = set() - - for phase_name, phase in PHASES: - if phase_name in active_statuses: - if user is None: - reviews.add(phase_name) - elif phase.permissions.can_review(user): - reviews.add(phase_name) - return reviews - - -def get_review_statuses(user=None): - reviews = set() - - for phase_name, phase in PHASES: - if "review" in phase_name and "discussion" not in phase_name: - if user is None: - reviews.add(phase_name) - elif phase.permissions.can_review(user): - reviews.add(phase_name) - return reviews - - -def get_ext_review_statuses(): - reviews = set() - - for phase_name, _phase in PHASES: - if phase_name.endswith("external_review"): - reviews.add(phase_name) - return reviews - - -def get_ext_or_higher_statuses(): - """ - Returns a set of all the statuses for all workflow which are - External Review or higher than that. - """ - reviews = set() - - for workflow in WORKFLOWS.values(): - step = None - for phase in workflow.values(): - if phase.name.endswith("external_review"): - # Update the step for this workflow as External review state - step = phase.step - - # Phase should have step higher or equal than External - # review state for this workflow - if step and phase.step >= step: - reviews.add(phase.name) - return reviews - - -def get_accepted_statuses(): - accepted_statuses = set() - for phase_name, phase in PHASES: - if phase.display_name == "Accepted": - accepted_statuses.add(phase_name) - return accepted_statuses - - -def get_dismissed_statuses(): - dismissed_statuses = set() - for phase_name, phase in PHASES: - if phase.display_name == "Dismissed": - dismissed_statuses.add(phase_name) - return dismissed_statuses - - -review_statuses = get_review_statuses() -ext_review_statuses = get_ext_review_statuses() -ext_or_higher_statuses = get_ext_or_higher_statuses() -accepted_statuses = get_accepted_statuses() -dismissed_statuses = get_dismissed_statuses() - -DETERMINATION_PHASES = [ - phase_name for phase_name, _ in PHASES if "_discussion" in phase_name -] -DETERMINATION_RESPONSE_PHASES = [ - "post_review_discussion", - "concept_review_discussion", - "same_post_review_discussion", - "post_external_review_discussion", - "ext_post_external_review_discussion", - "com_post_external_review_discussion", -] - - -def get_determination_transitions(): - transitions = {} - for _phase_name, phase in PHASES: - for transition_name in phase.transitions: - if "accepted" in transition_name: - transitions[transition_name] = "accepted" - elif "rejected" in transition_name: - transitions[transition_name] = "rejected" - elif "more_info" in transition_name: - transitions[transition_name] = "more_info" - elif "invited_to_proposal" in transition_name: - transitions[transition_name] = "accepted" - - return transitions - - -def get_action_mapping(workflow): - # Maps action names to the phase they originate from - transitions = defaultdict(lambda: {"display": "", "transitions": []}) - if workflow: - phases = workflow.items() - else: - phases = PHASES - for _phase_name, phase in phases: - for transition_name, transition in phase.transitions.items(): - transition_display = transition["display"] - transition_key = slugify(transition_display) - transitions[transition_key]["transitions"].append(transition_name) - transitions[transition_key]["display"] = transition_display - - return transitions - - -DETERMINATION_OUTCOMES = get_determination_transitions() - - -def phases_matching(phrase, exclude=None): - if exclude is None: - exclude = [] - return [ - status - for status, _ in PHASES - if status.endswith(phrase) and status not in exclude - ] - - -PHASES_MAPPING = { - "received": { - "name": _("Received"), - "statuses": [INITIAL_STATE, "proposal_discussion"], - }, - "internal-review": { - "name": _("Internal Review"), - "statuses": phases_matching("internal_review"), - }, - "in-discussion": { - "name": _("Ready for Discussion"), - "statuses": phases_matching( - "discussion", exclude=[INITIAL_STATE, "proposal_discussion"] - ), - }, - "more-information": { - "name": _("More Information Requested"), - "statuses": phases_matching("more_info"), - }, - "invited-for-proposal": { - "name": _("Invited for Proposal"), - "statuses": ["draft_proposal"], - }, - "external-review": { - "name": _("External Review"), - "statuses": phases_matching("external_review"), - }, - "ready-for-determination": { - "name": _("Ready for Determination"), - "statuses": phases_matching("determination"), - }, - "accepted": { - "name": _("Accepted"), - "statuses": phases_matching("accepted"), - }, - "dismissed": { - "name": _("Dismissed"), - "statuses": phases_matching("rejected"), - }, -} - -OPEN_CALL_PHASES = [ - "com_open_call", -] - -COMMUNITY_REVIEW_PHASES = [ - "com_community_review", -] diff --git a/hypha/apply/funds/workflows/__init__.py b/hypha/apply/funds/workflows/__init__.py new file mode 100644 index 0000000000..47d97de00c --- /dev/null +++ b/hypha/apply/funds/workflows/__init__.py @@ -0,0 +1,61 @@ +""" +Workflow System Documentation + +This package implements a flexible workflow system for managing application states +and transitions. The system is built on the following key concepts: + +- Workflow: Overall process definition containing stages and phases +- Stage: Major sections of the workflow (e.g. Request, Proposal) +- Phase: Individual states within a stage +- Transition: Allowed movements between phases + +Key Components: +- models/: Core workflow model classes +- definitions/: Workflow configuration definitions +- registry.py: Central workflow registration and lookup +- permissions.py: Permission checking system +""" + +from .constants import ( + DETERMINATION_OUTCOMES, + DRAFT_STATE, + INITIAL_STATE, + STAGE_CHANGE_ACTIONS, + UserPermissions, +) +from .models.stage import Stage +from .registry import ( + PHASES, + STATUSES, + WORKFLOWS, + accepted_statuses, + active_statuses, + dismissed_statuses, + ext_or_higher_statuses, + ext_review_statuses, + get_review_active_statuses, + review_statuses, +) +from .utils import ( + get_action_mapping, +) + +__all__ = [ + "DETERMINATION_OUTCOMES", + "DRAFT_STATE", + "INITIAL_STATE", + "PHASES", + "STAGE_CHANGE_ACTIONS", + "STATUSES", + "Stage", + "UserPermissions", + "WORKFLOWS", + "accepted_statuses", + "active_statuses", + "dismissed_statuses", + "ext_or_higher_statuses", + "ext_review_statuses", + "get_action_mapping", + "get_review_active_statuses", + "review_statuses", +] diff --git a/hypha/apply/funds/workflows/constants.py b/hypha/apply/funds/workflows/constants.py new file mode 100644 index 0000000000..5025ded34a --- /dev/null +++ b/hypha/apply/funds/workflows/constants.py @@ -0,0 +1,96 @@ +from enum import Enum + +from django.utils.translation import gettext as _ + +from .utils import ( + get_determination_transitions, + get_stage_change_actions, + phases_matching, +) + +DRAFT_STATE = "draft" +INITIAL_STATE = "in_discussion" + +PHASE_BG_COLORS = { + "Draft": "bg-gray-200", + "Accepted": "bg-green-200", + "Need screening": "bg-cyan-200", + "Ready for Determination": "bg-blue-200", + "Ready For Discussion": "bg-blue-100", + "Invited for Proposal": "bg-green-100", + "Internal Review": "bg-yellow-200", + "External Review": "bg-yellow-200", + "More information required": "bg-yellow-100", + "Accepted but additional info required": "bg-green-100", + "Dismissed": "bg-rose-200", +} + + +class UserPermissions(Enum): + STAFF = 1 + ADMIN = 2 + LEAD = 3 + APPLICANT = 4 + + +STAGE_CHANGE_ACTIONS = get_stage_change_actions() + +DETERMINATION_RESPONSE_PHASES = [ + "post_review_discussion", + "concept_review_discussion", + "same_post_review_discussion", + "post_external_review_discussion", + "ext_post_external_review_discussion", + "com_post_external_review_discussion", +] + +DETERMINATION_OUTCOMES = get_determination_transitions() + +OPEN_CALL_PHASES = [ + "com_open_call", +] + +COMMUNITY_REVIEW_PHASES = [ + "com_community_review", +] + +PHASES_MAPPING = { + "received": { + "name": _("Received"), + "statuses": [INITIAL_STATE, "proposal_discussion"], + }, + "internal-review": { + "name": _("Internal Review"), + "statuses": phases_matching("internal_review"), + }, + "in-discussion": { + "name": _("Ready for Discussion"), + "statuses": phases_matching( + "discussion", exclude=[INITIAL_STATE, "proposal_discussion"] + ), + }, + "more-information": { + "name": _("More Information Requested"), + "statuses": phases_matching("more_info"), + }, + "invited-for-proposal": { + "name": _("Invited for Proposal"), + "statuses": ["draft_proposal"], + }, + "external-review": { + "name": _("External Review"), + "statuses": phases_matching("external_review"), + }, + "ready-for-determination": { + "name": _("Ready for Determination"), + "statuses": phases_matching("determination"), + }, + "accepted": { + "name": _("Accepted"), + "statuses": phases_matching("accepted"), + }, + "dismissed": { + "name": _("Dismissed"), + "statuses": phases_matching("rejected"), + }, +} diff --git a/hypha/apply/funds/workflows/definitions/double_stage.py b/hypha/apply/funds/workflows/definitions/double_stage.py new file mode 100644 index 0000000000..785ab41242 --- /dev/null +++ b/hypha/apply/funds/workflows/definitions/double_stage.py @@ -0,0 +1,331 @@ +from django.conf import settings +from django.utils.translation import gettext as _ + +from ..constants import DRAFT_STATE, INITIAL_STATE, UserPermissions +from ..models.stage import Concept, Proposal +from ..permissions import ( + applicant_edit_permissions, + default_permissions, + hidden_from_applicant_permissions, + no_permissions, + reviewer_review_permissions, + staff_edit_permissions, +) + +DoubleStageDefinition = [ + { + DRAFT_STATE: { + "transitions": { + INITIAL_STATE: { + "display": _("Submit"), + "permissions": {UserPermissions.APPLICANT}, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + }, + "display": _("Draft"), + "stage": Concept, + "permissions": applicant_edit_permissions, + } + }, + { + INITIAL_STATE: { + "transitions": { + "concept_more_info": _("Request More Information"), + "concept_internal_review": _("Open Review"), + "concept_determination": _("Ready For Preliminary Determination"), + "invited_to_proposal": _("Invite to Proposal"), + "concept_rejected": _("Dismiss"), + }, + "display": _("Need screening"), + "public": _("Concept Note Received"), + "stage": Concept, + "permissions": default_permissions, + }, + "concept_more_info": { + "transitions": { + INITIAL_STATE: { + "display": _("Submit"), + "permissions": { + UserPermissions.APPLICANT, + UserPermissions.STAFF, + UserPermissions.LEAD, + UserPermissions.ADMIN, + }, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + "concept_rejected": _("Dismiss"), + "invited_to_proposal": _("Invite to Proposal"), + "concept_determination": _("Ready For Preliminary Determination"), + }, + "display": _("More information required"), + "stage": Concept, + "permissions": applicant_edit_permissions, + }, + }, + { + "concept_internal_review": { + "transitions": { + "concept_review_discussion": _("Close Review"), + INITIAL_STATE: _("Need screening (revert)"), + "invited_to_proposal": _("Invite to Proposal"), + }, + "display": _("Internal Review"), + "public": _("{org_short_name} Review").format( + org_short_name=settings.ORG_SHORT_NAME + ), + "stage": Concept, + "permissions": default_permissions, + }, + }, + { + "concept_review_discussion": { + "transitions": { + "concept_review_more_info": _("Request More Information"), + "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"), + "stage": Concept, + "permissions": hidden_from_applicant_permissions, + }, + "concept_review_more_info": { + "transitions": { + "concept_review_discussion": { + "display": _("Submit"), + "permissions": { + UserPermissions.APPLICANT, + UserPermissions.STAFF, + UserPermissions.LEAD, + UserPermissions.ADMIN, + }, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + "invited_to_proposal": _("Invite to Proposal"), + }, + "display": _("More information required"), + "stage": Concept, + "permissions": applicant_edit_permissions, + }, + }, + { + "concept_determination": { + "transitions": { + "concept_review_discussion": _("Ready For Discussion (revert)"), + "invited_to_proposal": _("Invite to Proposal"), + "concept_rejected": _("Dismiss"), + }, + "display": _("Ready for Preliminary Determination"), + "permissions": hidden_from_applicant_permissions, + "stage": Concept, + }, + }, + { + "invited_to_proposal": { + "display": _("Concept Accepted"), + "future": _("Preliminary Determination"), + "transitions": { + "draft_proposal": { + "display": _("Progress"), + "method": "progress_application", + "permissions": { + UserPermissions.STAFF, + UserPermissions.LEAD, + UserPermissions.ADMIN, + }, + "conditions": "not_progressed", + }, + }, + "stage": Concept, + "permissions": no_permissions, + }, + "concept_rejected": { + "display": _("Dismissed"), + "stage": Concept, + "permissions": no_permissions, + }, + }, + { + "draft_proposal": { + "transitions": { + "proposal_discussion": { + "display": _("Submit"), + "permissions": {UserPermissions.APPLICANT}, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + "external_review": _("Open External Review"), + "proposal_determination": _("Ready For Final Determination"), + "proposal_rejected": _("Dismiss"), + }, + "display": _("Invited for Proposal"), + "stage": Proposal, + "permissions": applicant_edit_permissions, + }, + }, + { + "proposal_discussion": { + "transitions": { + "proposal_more_info": _("Request More Information"), + "proposal_internal_review": _("Open Review"), + "external_review": _("Open External Review"), + "proposal_determination": _("Ready For Final Determination"), + "proposal_rejected": _("Dismiss"), + }, + "display": _("Proposal Received"), + "stage": Proposal, + "permissions": default_permissions, + }, + "proposal_more_info": { + "transitions": { + "proposal_discussion": { + "display": _("Submit"), + "permissions": { + UserPermissions.APPLICANT, + UserPermissions.STAFF, + UserPermissions.LEAD, + UserPermissions.ADMIN, + }, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + "external_review": _("Open External Review"), + "proposal_determination": _("Ready For Final Determination"), + "proposal_rejected": _("Dismiss"), + }, + "display": _("More information required"), + "stage": Proposal, + "permissions": applicant_edit_permissions, + }, + }, + { + "proposal_internal_review": { + "transitions": { + "post_proposal_review_discussion": _("Close Review"), + "proposal_discussion": _("Proposal Received (revert)"), + }, + "display": _("Internal Review"), + "public": _("{org_short_name} Review").format( + org_short_name=settings.ORG_SHORT_NAME + ), + "stage": Proposal, + "permissions": default_permissions, + }, + }, + { + "post_proposal_review_discussion": { + "transitions": { + "post_proposal_review_more_info": _("Request More Information"), + "external_review": _("Open External Review"), + "proposal_determination": _("Ready For Final Determination"), + "proposal_internal_review": _("Open Internal Review (revert)"), + "proposal_rejected": _("Dismiss"), + }, + "display": _("Ready For Discussion"), + "stage": Proposal, + "permissions": hidden_from_applicant_permissions, + }, + "post_proposal_review_more_info": { + "transitions": { + "post_proposal_review_discussion": { + "display": _("Submit"), + "permissions": { + UserPermissions.APPLICANT, + UserPermissions.STAFF, + UserPermissions.LEAD, + UserPermissions.ADMIN, + }, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + "external_review": _("Open External Review"), + }, + "display": _("More information required"), + "stage": Proposal, + "permissions": applicant_edit_permissions, + }, + }, + { + "external_review": { + "transitions": { + "post_external_review_discussion": _("Close Review"), + "post_proposal_review_discussion": _("Ready For Discussion (revert)"), + }, + "display": _("External Review"), + "stage": Proposal, + "permissions": reviewer_review_permissions, + }, + }, + { + "post_external_review_discussion": { + "transitions": { + "post_external_review_more_info": _("Request More Information"), + "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"), + "stage": Proposal, + "permissions": hidden_from_applicant_permissions, + }, + "post_external_review_more_info": { + "transitions": { + "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": Proposal, + "permissions": applicant_edit_permissions, + }, + }, + { + "proposal_determination": { + "transitions": { + "post_external_review_discussion": _("Ready For Discussion (revert)"), + "proposal_almost": _("Accept but additional info required"), + "proposal_accepted": _("Accept"), + "proposal_rejected": _("Dismiss"), + }, + "display": _("Ready for Final Determination"), + "permissions": hidden_from_applicant_permissions, + "stage": Proposal, + }, + }, + { + "proposal_accepted": { + "display": _("Accepted"), + "future": _("Final Determination"), + "stage": Proposal, + "permissions": staff_edit_permissions, + }, + "proposal_almost": { + "transitions": { + "proposal_accepted": _("Accept"), + "post_external_review_discussion": _("Ready For Discussion (revert)"), + }, + "display": _("Accepted but additional info required"), + "stage": Proposal, + "permissions": applicant_edit_permissions, + }, + "proposal_rejected": { + "display": _("Dismissed"), + "stage": Proposal, + "permissions": no_permissions, + }, + }, +] diff --git a/hypha/apply/funds/workflows/definitions/single_stage.py b/hypha/apply/funds/workflows/definitions/single_stage.py new file mode 100644 index 0000000000..3f2f21f1f8 --- /dev/null +++ b/hypha/apply/funds/workflows/definitions/single_stage.py @@ -0,0 +1,154 @@ +from django.conf import settings +from django.utils.translation import gettext as _ + +from ..constants import DRAFT_STATE, INITIAL_STATE, UserPermissions +from ..models.stage import Request +from ..permissions import ( + applicant_edit_permissions, + default_permissions, + hidden_from_applicant_permissions, + no_permissions, + staff_edit_permissions, +) + +SingleStageDefinition = [ + { + DRAFT_STATE: { + "transitions": { + INITIAL_STATE: { + "display": _("Submit"), + "permissions": {UserPermissions.APPLICANT}, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + }, + "display": _("Draft"), + "stage": Request, + "permissions": applicant_edit_permissions, + } + }, + { + INITIAL_STATE: { + "transitions": { + "more_info": _("Request More Information"), + "internal_review": _("Open Review"), + "determination": _("Ready For Determination"), + "almost": _("Accept but additional info required"), + "accepted": _("Accept"), + "rejected": _("Dismiss"), + }, + "display": _("Need screening"), + "public": _("Application Received"), + "stage": Request, + "permissions": default_permissions, + }, + "more_info": { + "transitions": { + INITIAL_STATE: { + "display": _("Submit"), + "permissions": { + UserPermissions.APPLICANT, + UserPermissions.STAFF, + UserPermissions.LEAD, + UserPermissions.ADMIN, + }, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + "determination": _("Ready For Determination"), + "almost": _("Accept but additional info required"), + "accepted": _("Accept"), + "rejected": _("Dismiss"), + }, + "display": _("More information required"), + "stage": Request, + "permissions": applicant_edit_permissions, + }, + }, + { + "internal_review": { + "transitions": { + "post_review_discussion": _("Close Review"), + INITIAL_STATE: _("Need screening (revert)"), + }, + "display": _("Internal Review"), + "public": _("{org_short_name} Review").format( + org_short_name=settings.ORG_SHORT_NAME + ), + "stage": Request, + "permissions": default_permissions, + }, + }, + { + "post_review_discussion": { + "transitions": { + "post_review_more_info": _("Request More Information"), + "determination": _("Ready For Determination"), + "internal_review": _("Open Review (revert)"), + "almost": _("Accept but additional info required"), + "accepted": _("Accept"), + "rejected": _("Dismiss"), + }, + "display": _("Ready For Discussion"), + "stage": Request, + "permissions": hidden_from_applicant_permissions, + }, + "post_review_more_info": { + "transitions": { + "post_review_discussion": { + "display": _("Submit"), + "permissions": { + UserPermissions.APPLICANT, + UserPermissions.STAFF, + UserPermissions.LEAD, + UserPermissions.ADMIN, + }, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + "determination": _("Ready For Determination"), + "almost": _("Accept but additional info required"), + "accepted": _("Accept"), + "rejected": _("Dismiss"), + }, + "display": _("More information required"), + "stage": Request, + "permissions": applicant_edit_permissions, + }, + }, + { + "determination": { + "transitions": { + "post_review_discussion": _("Ready For Discussion (revert)"), + "almost": _("Accept but additional info required"), + "accepted": _("Accept"), + "rejected": _("Dismiss"), + }, + "display": _("Ready for Determination"), + "permissions": hidden_from_applicant_permissions, + "stage": Request, + }, + }, + { + "accepted": { + "display": _("Accepted"), + "future": _("Application Outcome"), + "stage": Request, + "permissions": staff_edit_permissions, + }, + "almost": { + "transitions": { + "accepted": _("Accept"), + "post_review_discussion": _("Ready For Discussion (revert)"), + }, + "display": _("Accepted but additional info required"), + "stage": Request, + "permissions": applicant_edit_permissions, + }, + "rejected": { + "display": _("Dismissed"), + "stage": Request, + "permissions": no_permissions, + }, + }, +] diff --git a/hypha/apply/funds/workflows/definitions/single_stage_community.py b/hypha/apply/funds/workflows/definitions/single_stage_community.py new file mode 100644 index 0000000000..247e6eb5ce --- /dev/null +++ b/hypha/apply/funds/workflows/definitions/single_stage_community.py @@ -0,0 +1,219 @@ +from django.conf import settings +from django.utils.translation import gettext as _ + +from ..constants import DRAFT_STATE, INITIAL_STATE, UserPermissions +from ..models.stage import RequestCom +from ..permissions import ( + applicant_edit_permissions, + community_review_permissions, + default_permissions, + hidden_from_applicant_permissions, + no_permissions, + reviewer_review_permissions, + staff_edit_permissions, +) + +SingleStageCommunityDefinition = [ + { + DRAFT_STATE: { + "transitions": { + INITIAL_STATE: { + "display": _("Submit"), + "permissions": {UserPermissions.APPLICANT}, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + }, + "display": _("Draft"), + "stage": RequestCom, + "permissions": applicant_edit_permissions, + } + }, + { + INITIAL_STATE: { + "transitions": { + "com_more_info": _("Request More Information"), + "com_open_call": "Open Call (public)", + "com_internal_review": _("Open Review"), + "com_community_review": _("Open Community Review"), + "com_determination": _("Ready For Determination"), + "com_rejected": _("Dismiss"), + }, + "display": _("Need screening"), + "public": _("Application Received"), + "stage": RequestCom, + "permissions": default_permissions, + }, + "com_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": RequestCom, + "permissions": applicant_edit_permissions, + }, + "com_open_call": { + "transitions": { + INITIAL_STATE: _("Need screening (revert)"), + "com_rejected": _("Dismiss"), + }, + "display": "Open Call (public)", + "stage": RequestCom, + "permissions": staff_edit_permissions, + }, + }, + { + "com_internal_review": { + "transitions": { + "com_community_review": _("Open Community Review"), + "com_post_review_discussion": _("Close Review"), + INITIAL_STATE: _("Need screening (revert)"), + "com_rejected": _("Dismiss"), + }, + "display": _("Internal Review"), + "public": _("{org_short_name} Review").format( + org_short_name=settings.ORG_SHORT_NAME + ), + "stage": RequestCom, + "permissions": default_permissions, + }, + "com_community_review": { + "transitions": { + "com_post_review_discussion": _("Close Review"), + "com_internal_review": _("Open Internal Review (revert)"), + "com_rejected": _("Dismiss"), + }, + "display": _("Community Review"), + "public": _("{org_short_name} Review").format( + org_short_name=settings.ORG_SHORT_NAME + ), + "stage": RequestCom, + "permissions": community_review_permissions, + }, + }, + { + "com_post_review_discussion": { + "transitions": { + "com_post_review_more_info": _("Request More Information"), + "com_external_review": _("Open External Review"), + "com_determination": _("Ready For Determination"), + "com_internal_review": _("Open Internal Review (revert)"), + "com_rejected": _("Dismiss"), + }, + "display": _("Ready For Discussion"), + "stage": RequestCom, + "permissions": hidden_from_applicant_permissions, + }, + "com_post_review_more_info": { + "transitions": { + "com_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": RequestCom, + "permissions": applicant_edit_permissions, + }, + }, + { + "com_external_review": { + "transitions": { + "com_post_external_review_discussion": _("Close Review"), + "com_post_review_discussion": _("Ready For Discussion (revert)"), + }, + "display": _("External Review"), + "stage": RequestCom, + "permissions": reviewer_review_permissions, + }, + }, + { + "com_post_external_review_discussion": { + "transitions": { + "com_post_external_review_more_info": _("Request More Information"), + "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"), + "stage": RequestCom, + "permissions": hidden_from_applicant_permissions, + }, + "com_post_external_review_more_info": { + "transitions": { + "com_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": RequestCom, + "permissions": applicant_edit_permissions, + }, + }, + { + "com_determination": { + "transitions": { + "com_post_external_review_discussion": _( + "Ready For Discussion (revert)" + ), + "com_almost": _("Accept but additional info required"), + "com_accepted": _("Accept"), + "com_rejected": _("Dismiss"), + }, + "display": _("Ready for Determination"), + "permissions": hidden_from_applicant_permissions, + "stage": RequestCom, + }, + }, + { + "com_accepted": { + "display": _("Accepted"), + "future": _("Application Outcome"), + "stage": RequestCom, + "permissions": staff_edit_permissions, + }, + "com_almost": { + "transitions": { + "com_accepted": _("Accept"), + "com_post_external_review_discussion": _( + "Ready For Discussion (revert)" + ), + }, + "display": _("Accepted but additional info required"), + "stage": RequestCom, + "permissions": applicant_edit_permissions, + }, + "com_rejected": { + "display": _("Dismissed"), + "stage": RequestCom, + "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 new file mode 100644 index 0000000000..a4db10d061 --- /dev/null +++ b/hypha/apply/funds/workflows/definitions/single_stage_external.py @@ -0,0 +1,192 @@ +from django.conf import settings +from django.utils.translation import gettext as _ + +from ..constants import DRAFT_STATE, INITIAL_STATE, UserPermissions +from ..models.stage import RequestExt +from ..permissions import ( + applicant_edit_permissions, + default_permissions, + hidden_from_applicant_permissions, + no_permissions, + reviewer_review_permissions, + staff_edit_permissions, +) + +SingleStageExternalDefinition = [ + { + DRAFT_STATE: { + "transitions": { + INITIAL_STATE: { + "display": _("Submit"), + "permissions": {UserPermissions.APPLICANT}, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + }, + "display": _("Draft"), + "stage": RequestExt, + "permissions": applicant_edit_permissions, + } + }, + { + INITIAL_STATE: { + "transitions": { + "ext_more_info": _("Request More Information"), + "ext_internal_review": _("Open Review"), + "ext_determination": _("Ready For Determination"), + "ext_rejected": _("Dismiss"), + }, + "display": _("Need screening"), + "public": _("Application Received"), + "stage": RequestExt, + "permissions": default_permissions, + }, + "ext_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": RequestExt, + "permissions": applicant_edit_permissions, + }, + }, + { + "ext_internal_review": { + "transitions": { + "ext_post_review_discussion": _("Close Review"), + INITIAL_STATE: _("Need screening (revert)"), + }, + "display": _("Internal Review"), + "public": _("{org_short_name} Review").format( + org_short_name=settings.ORG_SHORT_NAME + ), + "stage": RequestExt, + "permissions": default_permissions, + }, + }, + { + "ext_post_review_discussion": { + "transitions": { + "ext_post_review_more_info": _("Request More Information"), + "ext_external_review": _("Open External Review"), + "ext_determination": _("Ready For Determination"), + "ext_internal_review": _("Open Internal Review (revert)"), + "ext_rejected": _("Dismiss"), + }, + "display": _("Ready For Discussion"), + "stage": RequestExt, + "permissions": hidden_from_applicant_permissions, + }, + "ext_post_review_more_info": { + "transitions": { + "ext_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": RequestExt, + "permissions": applicant_edit_permissions, + }, + }, + { + "ext_external_review": { + "transitions": { + "ext_post_external_review_discussion": _("Close Review"), + "ext_post_review_discussion": _("Ready For Discussion (revert)"), + }, + "display": _("External Review"), + "stage": RequestExt, + "permissions": reviewer_review_permissions, + }, + }, + { + "ext_post_external_review_discussion": { + "transitions": { + "ext_post_external_review_more_info": _("Request More Information"), + "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"), + "stage": RequestExt, + "permissions": hidden_from_applicant_permissions, + }, + "ext_post_external_review_more_info": { + "transitions": { + "ext_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": RequestExt, + "permissions": applicant_edit_permissions, + }, + }, + { + "ext_determination": { + "transitions": { + "ext_post_external_review_discussion": _( + "Ready For Discussion (revert)" + ), + "ext_almost": _("Accept but additional info required"), + "ext_accepted": _("Accept"), + "ext_rejected": _("Dismiss"), + }, + "display": _("Ready for Determination"), + "permissions": hidden_from_applicant_permissions, + "stage": RequestExt, + }, + }, + { + "ext_accepted": { + "display": _("Accepted"), + "future": _("Application Outcome"), + "stage": RequestExt, + "permissions": staff_edit_permissions, + }, + "ext_almost": { + "transitions": { + "ext_accepted": _("Accept"), + "ext_post_external_review_discussion": _( + "Ready For Discussion (revert)" + ), + }, + "display": _("Accepted but additional info required"), + "stage": RequestExt, + "permissions": applicant_edit_permissions, + }, + "ext_rejected": { + "display": _("Dismissed"), + "stage": RequestExt, + "permissions": no_permissions, + }, + }, +] diff --git a/hypha/apply/funds/workflows/definitions/single_stage_same.py b/hypha/apply/funds/workflows/definitions/single_stage_same.py new file mode 100644 index 0000000000..15728cecf6 --- /dev/null +++ b/hypha/apply/funds/workflows/definitions/single_stage_same.py @@ -0,0 +1,143 @@ +from django.conf import settings +from django.utils.translation import gettext as _ + +from ..constants import DRAFT_STATE, INITIAL_STATE, UserPermissions +from ..models.stage import RequestSame +from ..permissions import ( + applicant_edit_permissions, + default_permissions, + hidden_from_applicant_permissions, + no_permissions, + reviewer_review_permissions, + staff_edit_permissions, +) + +SingleStageSameDefinition = [ + { + DRAFT_STATE: { + "transitions": { + INITIAL_STATE: { + "display": _("Submit"), + "permissions": {UserPermissions.APPLICANT}, + "method": "create_revision", + "custom": {"trigger_on_submit": True}, + }, + }, + "display": _("Draft"), + "stage": RequestSame, + "permissions": applicant_edit_permissions, + } + }, + { + INITIAL_STATE: { + "transitions": { + "same_more_info": _("Request More Information"), + "same_internal_review": _("Open Review"), + "same_determination": _("Ready For Determination"), + "same_rejected": _("Dismiss"), + }, + "display": _("Need screening"), + "public": _("Application Received"), + "stage": RequestSame, + "permissions": default_permissions, + }, + "same_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": RequestSame, + "permissions": applicant_edit_permissions, + }, + }, + { + "same_internal_review": { + "transitions": { + "same_post_review_discussion": _("Close Review"), + INITIAL_STATE: _("Need screening (revert)"), + }, + "display": _("Review"), + "public": _("{org_short_name} Review").format( + org_short_name=settings.ORG_SHORT_NAME + ), + "stage": RequestSame, + "permissions": reviewer_review_permissions, + }, + }, + { + "same_post_review_discussion": { + "transitions": { + "same_post_review_more_info": _("Request More Information"), + "same_determination": _("Ready For Determination"), + "same_internal_review": _("Open Review (revert)"), + "same_rejected": _("Dismiss"), + }, + "display": _("Ready For Discussion"), + "stage": RequestSame, + "permissions": hidden_from_applicant_permissions, + }, + "same_post_review_more_info": { + "transitions": { + "same_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": RequestSame, + "permissions": applicant_edit_permissions, + }, + }, + { + "same_determination": { + "transitions": { + "same_post_review_discussion": _("Ready For Discussion (revert)"), + "same_almost": _("Accept but additional info required"), + "same_accepted": _("Accept"), + "same_rejected": _("Dismiss"), + }, + "display": _("Ready for Determination"), + "permissions": hidden_from_applicant_permissions, + "stage": RequestSame, + }, + }, + { + "same_accepted": { + "display": _("Accepted"), + "future": _("Application Outcome"), + "stage": RequestSame, + "permissions": staff_edit_permissions, + }, + "same_almost": { + "transitions": { + "same_accepted": _("Accept"), + "same_post_review_discussion": _("Ready For Discussion (revert)"), + }, + "display": _("Accepted but additional info required"), + "stage": RequestSame, + "permissions": applicant_edit_permissions, + }, + "same_rejected": { + "display": _("Dismissed"), + "stage": RequestSame, + "permissions": no_permissions, + }, + }, +] diff --git a/hypha/apply/funds/workflows/models/phase.py b/hypha/apply/funds/workflows/models/phase.py new file mode 100644 index 0000000000..539b8cc057 --- /dev/null +++ b/hypha/apply/funds/workflows/models/phase.py @@ -0,0 +1,74 @@ +from django.utils.text import slugify + +from ..constants import PHASE_BG_COLORS, UserPermissions +from ..permissions import Permissions + + +class Phase: + """ + Phase Names: + display_name = phase name displayed to staff members in the system + public_name = phase name displayed to applicants in the system + future_name = phase_name displayed to applicants if they haven't passed this stage + """ + + def __init__( + self, + name, + display, + stage, + permissions, + step, + public=None, + future=None, + transitions=None, + ): + if transitions is None: + transitions = {} + self.name = name + self.display_name = display + self.display_slug = slugify(display) + if public and future: + raise ValueError("Cant provide both a future and a public name") + + self.public_name = public or self.display_name + self.future_name_staff = future or self.display_name + self.bg_color = PHASE_BG_COLORS.get(self.display_name, "bg-gray-200") + self.future_name_public = future or self.public_name + self.stage = stage + self.permissions = Permissions(permissions) + self.step = step + + # For building transition methods on the parent + self.transitions = {} + + default_permissions = { + UserPermissions.STAFF, + UserPermissions.LEAD, + UserPermissions.ADMIN, + } + + for transition_target, action in transitions.items(): + transition = {} + try: + transition["display"] = action.get("display") + except AttributeError: + transition["display"] = action + transition["permissions"] = default_permissions + else: + transition["method"] = action.get("method") + conditions = action.get("conditions", "") + transition["conditions"] = conditions.split(",") if conditions else [] + transition["permissions"] = action.get( + "permissions", default_permissions + ) + if "custom" in action: + transition["custom"] = action["custom"] + + self.transitions[transition_target] = transition + + def __str__(self): + return self.display_name + + def __repr__(self): + return f"" diff --git a/hypha/apply/funds/workflows/models/stage.py b/hypha/apply/funds/workflows/models/stage.py new file mode 100644 index 0000000000..aef13548a5 --- /dev/null +++ b/hypha/apply/funds/workflows/models/stage.py @@ -0,0 +1,21 @@ +class Stage: + __slots__ = ("name", "has_external_review") + + def __init__(self, name: str, has_external_review: bool = False) -> None: + self.name = name + self.has_external_review = has_external_review + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f"" + + +# Stage instances +Request = Stage("Request") +RequestSame = Stage("RequestSame", True) +RequestExt = Stage("RequestExt", True) +RequestCom = Stage("RequestCom", True) +Concept = Stage("Concept") +Proposal = Stage("Proposal", True) diff --git a/hypha/apply/funds/workflows/models/workflow.py b/hypha/apply/funds/workflows/models/workflow.py new file mode 100644 index 0000000000..23865fe9eb --- /dev/null +++ b/hypha/apply/funds/workflows/models/workflow.py @@ -0,0 +1,43 @@ +from collections import defaultdict + + +class Workflow(dict): + def __init__(self, name, admin_name, **data): + self.name = name + self.admin_name = admin_name + super().__init__(**data) + + def __str__(self): + return self.name + + @property + def stages(self): + stages = [] + for phase in self.values(): + if phase.stage not in stages: + stages.append(phase.stage) + return stages + + @property + def stepped_phases(self): + phases = defaultdict(list) + for phase in list(self.values()): + phases[phase.step].append(phase) + return phases + + def phases_for(self, user=None): + # Grab the first phase for each step - visible only, the display phase + return [ + phase + for phase, *_ in self.stepped_phases.values() + if not user or phase.permissions.can_view(user) + ] + + def previous_visible(self, current, user): + """Find the latest phase that the user has view permissions for""" + display_phase = self.stepped_phases[current.step][0] + phases = self.phases_for() + index = phases.index(display_phase) + for phase in phases[index - 1 :: -1]: + if phase.permissions.can_view(user): + return phase diff --git a/hypha/apply/funds/workflows/permissions.py b/hypha/apply/funds/workflows/permissions.py new file mode 100644 index 0000000000..426abc4333 --- /dev/null +++ b/hypha/apply/funds/workflows/permissions.py @@ -0,0 +1,62 @@ +def staff_can(user): + return user.is_apply_staff + + +def applicant_can(user): + return user.is_applicant + + +def reviewer_can(user): + return user.is_reviewer + + +def partner_can(user): + return user.is_partner + + +def community_can(user): + return user.is_community_reviewer + + +class Permissions: + def __init__(self, permissions): + self.permissions = permissions + + def can_do(self, user, action): + checks = self.permissions.get(action, []) + return any(check(user) for check in checks) + + def can_edit(self, user): + return self.can_do(user, "edit") + + def can_review(self, user): + return self.can_do(user, "review") + + def can_view(self, user): + return self.can_do(user, "view") + + +def make_permissions(edit=None, review=None, view=None): + return { + "edit": edit or [], + "review": review or [], + "view": view or [staff_can, applicant_can, reviewer_can, partner_can], + } + + +# Permission presets +no_permissions = make_permissions() +default_permissions = make_permissions(edit=[staff_can], review=[staff_can]) +hidden_from_applicant_permissions = make_permissions( + edit=[staff_can], review=[staff_can], view=[staff_can, reviewer_can] +) +reviewer_review_permissions = make_permissions( + edit=[staff_can], review=[staff_can, reviewer_can] +) +community_review_permissions = make_permissions( + edit=[staff_can], review=[staff_can, reviewer_can, community_can] +) +applicant_edit_permissions = make_permissions( + edit=[applicant_can, partner_can], review=[staff_can] +) +staff_edit_permissions = make_permissions(edit=[staff_can]) diff --git a/hypha/apply/funds/workflows/registry.py b/hypha/apply/funds/workflows/registry.py new file mode 100644 index 0000000000..a6aabb213e --- /dev/null +++ b/hypha/apply/funds/workflows/registry.py @@ -0,0 +1,186 @@ +""" +This file defines classes which allow you to compose workflows based on the following structure: + +Workflow -> Stage -> Phase -> Action + +Current limitations: +* Changing the name of a phase will mean that any object which references it cannot progress. [will +be fixed when streamfield, may require intermediate fix prior to launch] +* Do not reorder without looking at workflow automations steps in form_valid() in +hypha/apply/funds/views.py and hypha/apply/review/views.py. +""" + +import itertools +from collections import defaultdict + +from .definitions.double_stage import DoubleStageDefinition +from .definitions.single_stage import SingleStageDefinition +from .definitions.single_stage_community import SingleStageCommunityDefinition +from .definitions.single_stage_external import SingleStageExternalDefinition +from .definitions.single_stage_same import SingleStageSameDefinition +from .models.phase import Phase +from .models.workflow import Workflow + + +def phase_data(phases): + """ + Transforms a workflow definition into a dictionary of Phase objects. + + Args: + phases: A list of dictionaries defining the workflow phases and their configurations. + + Returns: + dict: A dictionary where keys are phase names and values are Phase objects, each initialized + with: + - phase name + - step number (order in workflow) + - additional configuration data from the phase definition + + Example: + Input phases = [ + {'draft': {'permissions': {...}}}, + {'review': {'permissions': {...}}} + ] + + Returns = { + 'draft': Phase('draft', step=0, permissions={...}), + 'review': Phase('review', step=1, permissions={...}) + } + """ + + def unpack_phases(phases): + return ( + (step, name, phase_data) + for step, step_data in enumerate(phases) + for name, phase_data in step_data.items() + ) + + return { + phase_name: Phase(phase_name, step=step, **phase_data) + for step, phase_name, phase_data in unpack_phases(phases) + } + + +Request = Workflow("Request", "single", **phase_data(SingleStageDefinition)) + +RequestSameTime = Workflow( + "Request with same time review", + "single_same", + **phase_data(SingleStageSameDefinition), +) + +RequestExternal = Workflow( + "Request with external review", + "single_ext", + **phase_data(SingleStageExternalDefinition), +) + +RequestCommunity = Workflow( + "Request with community review", + "single_com", + **phase_data(SingleStageCommunityDefinition), +) + +ConceptProposal = Workflow( + "Concept & Proposal", "double", **phase_data(DoubleStageDefinition) +) + +WORKFLOWS = { + Request.admin_name: Request, + RequestSameTime.admin_name: RequestSameTime, + RequestExternal.admin_name: RequestExternal, + RequestCommunity.admin_name: RequestCommunity, + ConceptProposal.admin_name: ConceptProposal, +} + +PHASES = list( + itertools.chain.from_iterable(workflow.items() for workflow in WORKFLOWS.values()) +) + +STATUSES = defaultdict(set) + +for key, value in PHASES: + STATUSES[value.display_name].add(key) + +active_statuses = [ + status + for status, _ in PHASES + if "accepted" not in status and "rejected" not in status and "invited" not in status +] + + +def get_review_active_statuses(user=None): + reviews = set() + + for phase_name, phase in PHASES: + if phase_name in active_statuses: + if user is None: + reviews.add(phase_name) + elif phase.permissions.can_review(user): + reviews.add(phase_name) + return reviews + + +def get_review_statuses(user=None): + reviews = set() + + for phase_name, phase in PHASES: + if "review" in phase_name and "discussion" not in phase_name: + if user is None: + reviews.add(phase_name) + elif phase.permissions.can_review(user): + reviews.add(phase_name) + return reviews + + +def get_ext_review_statuses(): + reviews = set() + + for phase_name, _phase in PHASES: + if phase_name.endswith("external_review"): + reviews.add(phase_name) + return reviews + + +def get_ext_or_higher_statuses(): + """ + Returns a set of all the statuses for all workflow which are + External Review or higher than that. + """ + reviews = set() + + for workflow in WORKFLOWS.values(): + step = None + for phase in workflow.values(): + if phase.name.endswith("external_review"): + # Update the step for this workflow as External review state + step = phase.step + + # Phase should have step higher or equal than External + # review state for this workflow + if step and phase.step >= step: + reviews.add(phase.name) + return reviews + + +def get_accepted_statuses(): + accepted_statuses = set() + for phase_name, phase in PHASES: + if phase.display_name == "Accepted": + accepted_statuses.add(phase_name) + return accepted_statuses + + +def get_dismissed_statuses(): + dismissed_statuses = set() + for phase_name, phase in PHASES: + if phase.display_name == "Dismissed": + dismissed_statuses.add(phase_name) + return dismissed_statuses + + +review_statuses = get_review_statuses() +ext_review_statuses = get_ext_review_statuses() +ext_or_higher_statuses = get_ext_or_higher_statuses() +accepted_statuses = get_accepted_statuses() +dismissed_statuses = get_dismissed_statuses() diff --git a/hypha/apply/funds/workflows/utils.py b/hypha/apply/funds/workflows/utils.py new file mode 100644 index 0000000000..09166f6ff0 --- /dev/null +++ b/hypha/apply/funds/workflows/utils.py @@ -0,0 +1,65 @@ +from collections import defaultdict + +from django.utils.text import slugify + + +def phases_matching(phrase, exclude=None): + from .registry import PHASES + + if exclude is None: + exclude = [] + return [ + status + for status, _ in PHASES + if status.endswith(phrase) and status not in exclude + ] + + +def get_stage_change_actions(): + from .registry import WORKFLOWS + + changes = set() + for workflow in WORKFLOWS.values(): + stage = None + for phase in workflow.values(): + if phase.stage != stage and stage: + changes.add(phase.name) + stage = phase.stage + return changes + + +def get_determination_transitions(): + from .registry import PHASES + + transitions = {} + for _phase_name, phase in PHASES: + for transition_name in phase.transitions: + if "accepted" in transition_name: + transitions[transition_name] = "accepted" + elif "rejected" in transition_name: + transitions[transition_name] = "rejected" + elif "more_info" in transition_name: + transitions[transition_name] = "more_info" + elif "invited_to_proposal" in transition_name: + transitions[transition_name] = "accepted" + + return transitions + + +def get_action_mapping(workflow): + from .registry import PHASES + + # Maps action names to the phase they originate from + transitions = defaultdict(lambda: {"display": "", "transitions": []}) + if workflow: + phases = workflow.items() + else: + phases = PHASES + for _phase_name, phase in phases: + for transition_name, transition in phase.transitions.items(): + transition_display = transition["display"] + transition_key = slugify(transition_display) + transitions[transition_key]["transitions"].append(transition_name) + transitions[transition_key]["display"] = transition_display + + return transitions diff --git a/hypha/apply/review/tests/test_views.py b/hypha/apply/review/tests/test_views.py index da476e32ec..3afeaf32b6 100644 --- a/hypha/apply/review/tests/test_views.py +++ b/hypha/apply/review/tests/test_views.py @@ -6,7 +6,7 @@ ApplicationSubmissionFactory, AssignedReviewersFactory, ) -from hypha.apply.funds.workflow import INITIAL_STATE +from hypha.apply.funds.workflows import INITIAL_STATE from hypha.apply.users.tests.factories import ReviewerFactory, StaffFactory, UserFactory from hypha.apply.utils.testing.tests import BaseViewTestCase diff --git a/hypha/apply/review/views.py b/hypha/apply/review/views.py index 3f4a03c68d..42d782f0da 100644 --- a/hypha/apply/review/views.py +++ b/hypha/apply/review/views.py @@ -19,7 +19,7 @@ from hypha.apply.activity.messaging import MESSAGES, messenger from hypha.apply.funds.models import ApplicationSubmission, AssignedReviewers -from hypha.apply.funds.workflow import INITIAL_STATE +from hypha.apply.funds.workflows import INITIAL_STATE from hypha.apply.review.blocks import RecommendationBlock, RecommendationCommentsBlock from hypha.apply.review.forms import ReviewModelForm, ReviewOpinionForm from hypha.apply.stream_forms.models import BaseStreamForm