diff --git a/src/country_workspace/contrib/hope/forms.py b/src/country_workspace/contrib/hope/forms.py index 7baf7f2c2..ac8a35a02 100644 --- a/src/country_workspace/contrib/hope/forms.py +++ b/src/country_workspace/contrib/hope/forms.py @@ -1,3 +1,5 @@ +from typing import Any + from django import forms from country_workspace.workspaces.admin.cleaners.base import BaseActionForm @@ -7,3 +9,13 @@ class CreateRDPForm(BaseActionForm): batch_name = forms.CharField( required=False, help_text="Label for this RDP creation. Defaults is the current date and time." ) + push_to_hope = forms.BooleanField( + required=False, + label="Push to HOPE after creation", + help_text="Automatically push beneficiaries to HOPE when RDP creation succeeds.", + ) + + def __init__(self, *args: Any, show_push_option: bool = False, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + if not show_push_option: + del self.fields["push_to_hope"] diff --git a/src/country_workspace/contrib/hope/push/__init__.py b/src/country_workspace/contrib/hope/push/__init__.py index 81ececa8b..a89137953 100644 --- a/src/country_workspace/contrib/hope/push/__init__.py +++ b/src/country_workspace/contrib/hope/push/__init__.py @@ -2,6 +2,7 @@ from .orchestration import ( claim_rdp_deduplication, clone_rdp_core, + create_and_push_rdp_core, create_rdp_core, dedup_existing_rdp_core, push_existing_rdp_core, @@ -18,6 +19,7 @@ "PushProcessor", "claim_rdp_deduplication", "clone_rdp_core", + "create_and_push_rdp_core", "create_rdp_core", "dedup_existing_rdp_core", "get_program_dedup_settings_policy", diff --git a/src/country_workspace/contrib/hope/push/orchestration.py b/src/country_workspace/contrib/hope/push/orchestration.py index f1e16d7da..229825580 100644 --- a/src/country_workspace/contrib/hope/push/orchestration.py +++ b/src/country_workspace/contrib/hope/push/orchestration.py @@ -115,6 +115,15 @@ def create_rdp_core(job: AsyncJob) -> dict[str, Any]: return {"rdp_id": rdp.id, "rdp_str": str(rdp)} +def create_and_push_rdp_core(job: AsyncJob) -> dict[str, Any]: + """Create an RDP and push it to HOPE when creation succeeds.""" + create_result = create_rdp_core(job) + job.refresh_from_db() + job.config = {**job.config, "rdp_id": create_result["rdp_id"]} + push_result = push_existing_rdp_core(job) + return {**create_result, **push_result} + + def claim_rdp_deduplication(rdp_id: int) -> tuple[ActionCheck, Rdp | None]: """Validate and mark RDP deduplication as requested inside an active transaction.""" rdp = lock_rdp_for_update(pk=rdp_id) diff --git a/src/country_workspace/workspaces/admin/cleaners/actions.py b/src/country_workspace/workspaces/admin/cleaners/actions.py index f1d07e7dd..aca4b411b 100644 --- a/src/country_workspace/workspaces/admin/cleaners/actions.py +++ b/src/country_workspace/workspaces/admin/cleaners/actions.py @@ -7,7 +7,7 @@ from strategy_field.utils import fqn from country_workspace.contrib.hope.forms import CreateRDPForm -from country_workspace.contrib.hope.push import CreateRdpConfig, create_rdp_core +from country_workspace.contrib.hope.push import CreateRdpConfig, create_and_push_rdp_core, create_rdp_core from country_workspace.models import AsyncJob from country_workspace.state import state from country_workspace.utils.fields import rdi_name_default @@ -248,27 +248,45 @@ def create_rdp( if model_admin._check_empty_queryset(request, queryset): return redirect(".") program = model_admin.get_selected_program(request) + show_push_option = model_admin.has_push_rdp_to_hope_permission(request) if request.method == "POST" and "_create" in request.POST: - if (form := CreateRDPForm(request.POST)).is_valid(): - config: CreateRdpConfig = { - "pks": list(queryset.values_list("pk", flat=True)), - "master_detail": program.beneficiary_group.master_detail, - "batch_name": form.cleaned_data["batch_name"] or rdi_name_default(), - "country_office_id": program.country_office.id, - "program_id": program.id, - "pushed_by_id": request.user.id, - } - job = AsyncJob.objects.create( - description=create_rdp.short_description, - type=AsyncJob.JobType.TASK, - owner=request.user, - action=fqn(create_rdp_core), - program=program, - config=config, - ) - job.queue() - model_admin.message_user(request, "RDP creation scheduled", messages.SUCCESS) - return redirect("workspace:workspaces_countryrdp_changelist") + form = CreateRDPForm(request.POST, show_push_option=show_push_option) + if form.is_valid(): + push_to_hope = form.cleaned_data.get("push_to_hope", False) + if push_to_hope and not show_push_option: + model_admin.message_user( + request, + "You do not have permission to push RDP to HOPE.", + messages.ERROR, + ) + else: + config: CreateRdpConfig = { + "pks": list(queryset.values_list("pk", flat=True)), + "master_detail": program.beneficiary_group.master_detail, + "batch_name": form.cleaned_data["batch_name"] or rdi_name_default(), + "country_office_id": program.country_office.id, + "program_id": program.id, + "pushed_by_id": request.user.id, + } + if push_to_hope: + description = "Create RDP and push to HOPE" + action = fqn(create_and_push_rdp_core) + success_message = "RDP creation and push scheduled" + else: + description = create_rdp.short_description + action = fqn(create_rdp_core) + success_message = "RDP creation scheduled" + job = AsyncJob.objects.create( + description=description, + type=AsyncJob.JobType.TASK, + owner=request.user, + action=action, + program=program, + config=config, + ) + job.queue() + model_admin.message_user(request, success_message, messages.SUCCESS) + return redirect("workspace:workspaces_countryrdp_changelist") else: form = CreateRDPForm( initial={ @@ -276,6 +294,7 @@ def create_rdp( "select_across": request.POST.get("select_across", False), "_selected_action": request.POST.getlist("_selected_action"), }, + show_push_option=show_push_option, ) ctx = model_admin.get_common_context(request, title=create_rdp.short_description, form=form) return render(request, "workspace/actions/create_rdp.html", ctx) diff --git a/src/country_workspace/workspaces/admin/hh_ind.py b/src/country_workspace/workspaces/admin/hh_ind.py index 2e03db2a1..a498e3c5c 100644 --- a/src/country_workspace/workspaces/admin/hh_ind.py +++ b/src/country_workspace/workspaces/admin/hh_ind.py @@ -117,6 +117,9 @@ def has_regex_update_permission(self, request: HttpRequest) -> bool: def has_create_rdp_permission(self, request: HttpRequest) -> bool: return request.user.has_perm("country_workspace.create_rdp") + def has_push_rdp_to_hope_permission(self, request: HttpRequest) -> bool: + return request.user.has_perm("country_workspace.push_rdp_to_hope") + def has_calculate_checksum_permission(self, request: HttpRequest) -> bool: return request.user.has_perm("country_workspace.calculate_checksum") diff --git a/tests/contrib/hope/push/test_orchestration.py b/tests/contrib/hope/push/test_orchestration.py index 94b2db0c9..f163a4c1b 100644 --- a/tests/contrib/hope/push/test_orchestration.py +++ b/tests/contrib/hope/push/test_orchestration.py @@ -20,6 +20,7 @@ claim_rdp_deduplication, clone_rdp_core, create_rdp_core, + create_and_push_rdp_core, dedup_existing_rdp_core, push_existing_rdp_core, reject_deduplication_set_existing_rdp_core, @@ -365,6 +366,34 @@ def test_create_rdp_core_success(mocker: MockerFixture, create_job: AsyncJob) -> assert out == {"rdp_id": create_job.rdp_id, "rdp_str": str(create_job.rdp)} +def test_create_and_push_rdp_core_success(mocker: MockerFixture) -> None: + job = mocker.MagicMock() + create_result = {"rdp_id": 42, "rdp_str": "RDP-42"} + push_result = {"errors": []} + create = mocker.patch(f"{MOD}.create_rdp_core", return_value=create_result) + push = mocker.patch(f"{MOD}.push_existing_rdp_core", return_value=push_result) + + assert create_and_push_rdp_core(job) == {**create_result, **push_result} + + create.assert_called_once_with(job) + job.refresh_from_db.assert_called_once_with() + assert job.config["rdp_id"] == 42 + push.assert_called_once_with(job) + + +def test_create_and_push_rdp_core_create_failure(mocker: MockerFixture) -> None: + job = mocker.MagicMock() + create = mocker.patch(f"{MOD}.create_rdp_core", side_effect=HopePushError({"errors": ["create failed"]})) + push = mocker.patch(f"{MOD}.push_existing_rdp_core") + + with pytest.raises(HopePushError): + create_and_push_rdp_core(job) + + create.assert_called_once_with(job) + job.refresh_from_db.assert_not_called() + push.assert_not_called() + + @pytest.mark.parametrize( ("set_id", "can_create", "expected_set_id", "expected_update_fields"), [ diff --git a/tests/workspace/actions/test_ws_create_rdp.py b/tests/workspace/actions/test_ws_create_rdp.py index bf279697e..f01366b25 100644 --- a/tests/workspace/actions/test_ws_create_rdp.py +++ b/tests/workspace/actions/test_ws_create_rdp.py @@ -5,7 +5,7 @@ from hope_flex_fields.models import DataChecker from strategy_field.utils import fqn -from country_workspace.contrib.hope.push import create_rdp_core +from country_workspace.contrib.hope.push import create_and_push_rdp_core, create_rdp_core from country_workspace.models import AsyncJob, Office from country_workspace.state import state from country_workspace.workspaces.models import CountryHousehold, CountryIndividual, CountryProgram @@ -98,3 +98,41 @@ def test_create_rdp_action(app: DjangoTestApp, program: CountryProgram, benefici assert cfg["country_office_id"] == program.country_office.id assert cfg["program_id"] == program.id assert cfg["pushed_by_id"] == app._user.id + + +def test_create_and_push_rdp_action(app: DjangoTestApp, program: CountryProgram, beneficiary_instance, mocker) -> None: + spy = mocker.patch.object(AsyncJob, "queue", autospec=True, return_value=None) + + beneficiary, url_name = beneficiary_instance + batch_name = "Test Batch Push" + + with select_office(app, program.country_office, program): + res = app.get(reverse(url_name)) + form = res.forms["changelist-form"] + form.set("_selected_action", [str(beneficiary.pk)]) + form["action"].select("create_rdp") + + res2 = form.submit() + create_form = res2.forms["create-rdp-form"] + create_form["batch_name"] = batch_name + create_form["push_to_hope"] = True + res3 = create_form.submit("_create") + assert res3.status_code == 302 + + job = program.jobs.order_by("-id").first() + assert job is not None + + assert spy.call_count == 1 + assert spy.call_args.args[0].pk == job.pk + + assert job.type == AsyncJob.JobType.TASK + assert job.action == fqn(create_and_push_rdp_core) + assert job.description == "Create RDP and push to HOPE #1" + + cfg = job.config + assert cfg["batch_name"] == batch_name + assert cfg["master_detail"] == program.beneficiary_group.master_detail + assert cfg["pks"] == [beneficiary.pk] + assert cfg["country_office_id"] == program.country_office.id + assert cfg["program_id"] == program.id + assert cfg["pushed_by_id"] == app._user.id diff --git a/tests/workspace/admin/test_actions.py b/tests/workspace/admin/test_actions.py index 846b4e88b..72e6a345b 100644 --- a/tests/workspace/admin/test_actions.py +++ b/tests/workspace/admin/test_actions.py @@ -164,6 +164,44 @@ def test_create_rdp_redirects_when_queryset_empty(mock_redirect, mock_admin, moc assert result == mock_redirect.return_value +def test_create_rdp_rejects_push_without_permission( + mocker: MockerFixture, + mock_admin, + mock_request, + non_empty_queryset, +): + mock_request.method = "POST" + mock_request.POST = {"_create": True} + mock_admin.has_push_rdp_to_hope_permission.return_value = False + program = MagicMock() + program.beneficiary_group.master_detail = True + program.country_office.id = 1 + program.id = 2 + mock_admin.get_selected_program.return_value = program + mock_render = mocker.patch( + "country_workspace.workspaces.admin.cleaners.actions.render", + return_value=HttpResponse(), + ) + mock_form = MagicMock() + mock_form.is_valid.return_value = True + mock_form.cleaned_data = {"batch_name": "Batch", "push_to_hope": True} + mocker.patch( + "country_workspace.workspaces.admin.cleaners.actions.CreateRDPForm", + return_value=mock_form, + ) + mock_async_job = mocker.patch("country_workspace.workspaces.admin.cleaners.actions.AsyncJob") + + result = create_rdp(mock_admin, mock_request, non_empty_queryset) + + assert result == mock_render.return_value + mock_admin.message_user.assert_called_once_with( + mock_request, + "You do not have permission to push RDP to HOPE.", + messages.ERROR, + ) + mock_async_job.objects.create.assert_not_called() + + def test_validate_records_returns_none_when_queryset_empty(mock_admin, mock_request): empty_queryset = MagicMock() empty_queryset.exists.return_value = False diff --git a/tests/workspace/test_forms/test_push_to_hope_form.py b/tests/workspace/test_forms/test_push_to_hope_form.py index 6c77f62e1..f51570ac3 100644 --- a/tests/workspace/test_forms/test_push_to_hope_form.py +++ b/tests/workspace/test_forms/test_push_to_hope_form.py @@ -4,7 +4,7 @@ HIDDEN_FORM_FIELDS = { - "action": "push_to_hope", + "action": "create_rdp", "select_across": "0", "_selected_action": "1", } @@ -24,3 +24,29 @@ def test_form_valid_and_cleaned(data: dict[str, Any], expected_valid: bool) -> N assert form.is_valid() is expected_valid if expected_valid: assert form.cleaned_data["batch_name"] == data["batch_name"] + + +def test_push_to_hope_field_hidden_by_default() -> None: + form = CreateRDPForm() + assert "push_to_hope" not in form.fields + + +def test_push_to_hope_field_visible_with_show_push_option() -> None: + form = CreateRDPForm(show_push_option=True) + assert "push_to_hope" in form.fields + + +@pytest.mark.parametrize( + ("push_to_hope", "expected"), + [(True, True), (False, False)], + ids=["checked", "unchecked"], +) +def test_push_to_hope_field_valid_when_shown(push_to_hope: bool, expected: bool) -> None: + post_data = { + **HIDDEN_FORM_FIELDS, + "batch_name": "test", + "push_to_hope": "on" if push_to_hope else "", + } + form = CreateRDPForm(data=post_data, show_push_option=True) + assert form.is_valid() + assert form.cleaned_data["push_to_hope"] is expected