Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/country_workspace/contrib/hope/forms.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Any

from django import forms

from country_workspace.workspaces.admin.cleaners.base import BaseActionForm
Expand All @@ -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"]
2 changes: 2 additions & 0 deletions src/country_workspace/contrib/hope/push/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/country_workspace/contrib/hope/push/orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
61 changes: 40 additions & 21 deletions src/country_workspace/workspaces/admin/cleaners/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -248,34 +248,53 @@ 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={
"action": request.POST.get("action", ""),
"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)
Expand Down
3 changes: 3 additions & 0 deletions src/country_workspace/workspaces/admin/hh_ind.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
29 changes: 29 additions & 0 deletions tests/contrib/hope/push/test_orchestration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
[
Expand Down
40 changes: 39 additions & 1 deletion tests/workspace/actions/test_ws_create_rdp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
38 changes: 38 additions & 0 deletions tests/workspace/admin/test_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion tests/workspace/test_forms/test_push_to_hope_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


HIDDEN_FORM_FIELDS = {
"action": "push_to_hope",
"action": "create_rdp",
"select_across": "0",
"_selected_action": "1",
}
Expand All @@ -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