Skip to content

Commit 0dbad8b

Browse files
arsen-vsArsen Pdomdinicola
authored
286744: Add concatenate field and generate full name actions (#275)
* 286744: Add concatenate field and generate full name actions - Introduced two new admin actions: `concatenate_field` and `generate_full_name` for the BeneficiaryBaseAdmin. - Implemented a form for concatenating fields based on a specified pattern. - Added a new template for the concatenate action to preview and apply changes. - Created a utility module for handling concatenation logic and updating records. * Enhance admin actions with new tests and implementations - Added unit tests for `concatenate_field`, `generate_full_name`, and related actions in the admin workspace. - Introduced mock fixtures to simulate request and queryset behaviors for testing. - Implemented logic for handling concatenation and full name generation, ensuring proper job scheduling and context management. - Improved test coverage for various scenarios, including form validation and checksum updates. * Add comprehensive tests for admin actions in `test_actions.py` - Implemented unit tests for various admin actions including `concatenate_field`, `generate_full_name`, `regex_update`, and `push_to_hope`. - Enhanced test coverage for scenarios involving empty querysets, invalid forms, and job scheduling. - Utilized mock objects to simulate request and queryset behaviors, ensuring robust validation and rendering logic. - Verified context management and response handling for different action outcomes. * Refactor queryset ordering in create_validation_jobs function to improve readability * Remove generate_full_name action from BeneficiaryBaseAdmin and associated tests to streamline admin actions. * Remove multiple test cases from `test_actions.py` to streamline the test suite. The removed tests include those for mass update, regex update, bulk update export, checksum calculation, and push to hope actions. This cleanup aims to enhance maintainability and focus on essential test coverage. * Add tests for handling None flex_fields and save=False in concatenate_field_impl - Implemented tests to verify that concatenate_field_impl correctly processes records with None flex_fields, ensuring proper initialization and updates. - Added a test case to confirm that no updates occur when save=False is specified, maintaining the integrity of the record's flex_fields. * lint --------- Signed-off-by: Domenico <ddinicola@unicef.org> Co-authored-by: Arsen P <arsen.pidhoretskyi@valor.com> Co-authored-by: Domenico <ddinicola@unicef.org> Co-authored-by: Domenico DiNicola <dom.dinicola@gmail.com>
1 parent a60e9f2 commit 0dbad8b

File tree

7 files changed

+590
-2
lines changed

7 files changed

+590
-2
lines changed

src/country_workspace/workspaces/admin/cleaners/actions.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from country_workspace.workspaces.admin.forms import BulkUpdateExportForm
1515
from .bulk_update import export_bulk_update_template
1616
from .calculate_checksum import calculate_checksum_impl
17+
from .concatenate import ConcatenateFieldForm, concatenate_field_impl
1718
from .mass_update import MassUpdateForm, mass_update_impl
1819
from .name_parser import NameParserForm, name_parser_impl
1920
from .regex import RegexUpdateForm, regex_update_impl
@@ -281,3 +282,53 @@ def push_to_hope(
281282
)
282283
ctx = model_admin.get_common_context(request, title=push_to_hope.short_description, form=form)
283284
return render(request, "workspace/actions/push_to_hope.html", ctx)
285+
286+
287+
@admin.action(description="Concatenate field action", permissions=["mass_update"])
288+
def concatenate_field(
289+
model_admin: "BeneficiaryBaseAdmin",
290+
request: "HttpRequest",
291+
queryset: "QuerySet[Beneficiary]",
292+
) -> HttpResponse:
293+
if model_admin._check_empty_queryset(request, queryset):
294+
return redirect(".")
295+
ctx = model_admin.get_common_context(request, title=_(concatenate_field.short_description))
296+
ctx["checker"] = checker = model_admin.get_checker(request)
297+
ctx["queryset"] = queryset
298+
ctx["opts"] = model_admin.model._meta
299+
ctx["preserved_filters"] = model_admin.get_preserved_filters(request)
300+
if "_preview" in request.POST:
301+
form = ConcatenateFieldForm(request.POST, checker=checker)
302+
if form.is_valid():
303+
changes = concatenate_field_impl(queryset.all()[:10], form.cleaned_data, save=False)
304+
ctx["changes"] = changes
305+
elif "_apply" in request.POST:
306+
form = ConcatenateFieldForm(request.POST, checker=checker)
307+
if form.is_valid():
308+
opts = queryset.model._meta
309+
job = AsyncJob.objects.create(
310+
description=concatenate_field.short_description,
311+
type=AsyncJob.JobType.ACTION,
312+
owner=state.request.user,
313+
action=fqn(concatenate_field_impl),
314+
program=state.program,
315+
config={
316+
"pks": list(queryset.values_list("pk", flat=True)),
317+
"model_name": opts.label,
318+
"kwargs": {"config": form.cleaned_data},
319+
},
320+
)
321+
job.queue()
322+
model_admin.message_user(request, "Task scheduled", messages.SUCCESS)
323+
else:
324+
form = ConcatenateFieldForm(
325+
checker=checker,
326+
initial={
327+
"action": request.POST["action"],
328+
"select_across": request.POST["select_across"],
329+
"_selected_action": request.POST.getlist("_selected_action"),
330+
},
331+
)
332+
333+
ctx["form"] = form
334+
return render(request, "workspace/actions/concatenate.html", ctx)
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import re
2+
from collections.abc import Iterable
3+
from typing import TYPE_CHECKING, Any, NamedTuple
4+
5+
from django import forms
6+
from django.db import transaction
7+
8+
from country_workspace.utils.flex_fields import get_checker_fields
9+
10+
from .base import BaseActionForm
11+
12+
if TYPE_CHECKING:
13+
from django.db.models import QuerySet
14+
from hope_flex_fields.models import DataChecker
15+
16+
from country_workspace.types import Beneficiary
17+
18+
19+
class ConcatenateFieldForm(BaseActionForm):
20+
destination_field = forms.ChoiceField(choices=[], help_text="Field to update with concatenated value")
21+
pattern = forms.CharField(
22+
help_text='Pattern with field placeholders, e.g., "{first_name} {middle_name} {last_name}"'
23+
)
24+
replace_only_empty = forms.BooleanField(
25+
required=False,
26+
initial=False,
27+
help_text="Only update the destination field if it is currently empty",
28+
)
29+
30+
def __init__(self, *args: Any, **kwargs: Any) -> None:
31+
checker: "DataChecker" = kwargs.pop("checker")
32+
super().__init__(*args, **kwargs)
33+
choices = list(get_checker_fields(checker, with_fs_prefix=True))
34+
self.fields["destination_field"].choices = choices
35+
36+
def clean_pattern(self) -> str:
37+
pattern = self.cleaned_data.get("pattern", "")
38+
# Validate that pattern contains at least one field placeholder
39+
if not re.search(r"\{[^}]+\}", pattern):
40+
raise forms.ValidationError("Pattern must contain at least one field placeholder like {field_name}")
41+
return pattern
42+
43+
44+
class ConcatenateResult(NamedTuple):
45+
record_id: str
46+
original: Any
47+
updated: str
48+
49+
50+
def normalize_whitespace(value: str) -> str:
51+
"""Trim value and remove multiple spaces."""
52+
if not isinstance(value, str):
53+
return value
54+
# Replace multiple spaces with single space and strip
55+
return re.sub(r"\s+", " ", value.strip())
56+
57+
58+
def extract_field_names(pattern: str) -> list[str]:
59+
"""Extract field names from pattern like {field_name}."""
60+
return re.findall(r"\{([^}]+)\}", pattern)
61+
62+
63+
def concatenate_value(record: "Beneficiary", pattern: str) -> str:
64+
"""Concatenate fields based on pattern."""
65+
field_names = extract_field_names(pattern)
66+
flex_fields = record.flex_fields or {}
67+
68+
# Build the result by replacing placeholders
69+
result = pattern
70+
for field_name in field_names:
71+
# Get value from flex_fields, default to empty string if not found
72+
value = flex_fields.get(field_name, "")
73+
if value is None:
74+
value = ""
75+
else:
76+
value = str(value)
77+
# Replace placeholder with actual value
78+
result = result.replace(f"{{{field_name}}}", value)
79+
80+
return normalize_whitespace(result)
81+
82+
83+
def update_checksum(records: "QuerySet[Beneficiary]", initial_fields: set[str]) -> Iterable[str]:
84+
"""Update checksum for all records in the queryset."""
85+
fields = initial_fields.copy()
86+
for record in records:
87+
if update_fields := record.update_checksum(initial_fields):
88+
fields.update(update_fields)
89+
return fields
90+
91+
92+
def concatenate_field_impl(
93+
records: "QuerySet[Beneficiary]",
94+
config: dict[str, Any],
95+
save: bool = True,
96+
) -> list[ConcatenateResult]:
97+
destination_field = config["destination_field"]
98+
pattern = config["pattern"]
99+
replace_only_empty = config.get("replace_only_empty", False)
100+
101+
ret: list[ConcatenateResult] = []
102+
records_to_save: list["Beneficiary"] = []
103+
with transaction.atomic():
104+
for record in records:
105+
flex_fields = record.flex_fields or {}
106+
original_value = flex_fields.get(destination_field)
107+
108+
# Check if we should skip (only replace empty values)
109+
if replace_only_empty and original_value:
110+
ret.append(ConcatenateResult(record.id, original_value, str(original_value)))
111+
continue
112+
113+
# Concatenate the value
114+
concatenated_value = concatenate_value(record, pattern)
115+
116+
# Update the flex_fields
117+
if flex_fields is None:
118+
flex_fields = {}
119+
flex_fields[destination_field] = concatenated_value
120+
record.flex_fields = flex_fields
121+
122+
ret.append(ConcatenateResult(record.id, original_value, concatenated_value))
123+
124+
if save:
125+
records_to_save.append(record)
126+
127+
if save and records_to_save:
128+
fields = update_checksum(records_to_save, {"flex_fields"})
129+
records.model.objects.bulk_update(records_to_save, list(fields), batch_size=1000)
130+
131+
return ret

src/country_workspace/workspaces/admin/cleaners/validate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def _validate_and_count(objs: Iterable[Model]) -> tuple[int, int]:
6666

6767
def create_validation_jobs(description: str, owner: str, program: Program, queryset: QuerySet) -> AsyncJob:
6868
opts = queryset.model._meta
69-
queryset = queryset.values_list("pk", flat=True).order_by("pk")
69+
queryset = queryset.order_by("pk").values_list("pk", flat=True)
7070
for chunk in batched(queryset, config.CHUNK_SIZE_FOR_VALIDATION_TASK):
7171
job = AsyncJob.objects.create(
7272
description=f"{description} (PKs {chunk[0]} - {chunk[-1]})",

src/country_workspace/workspaces/admin/hh_ind.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class BeneficiaryBaseAdmin(AdminAutoCompleteSearchMixin, SelectedProgramMixin, W
5757
actions = [
5858
actions.bulk_update_export,
5959
actions.calculate_checksum,
60+
actions.concatenate_field,
6061
actions.mass_update,
6162
actions.regex_update,
6263
actions.validate_records,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{% extends "workspace/actions/base.html" %}{% load i18n %}
2+
{% block action-content %}
3+
<div class="min-w-full py-5">
4+
All selected {{ opts.verbose_name_plural }} will be updated by concatenating fields based on the pattern.<br/>
5+
Use field placeholders like <code>{first_name}</code>, <code>{middle_name}</code>, <code>{last_name}</code> in the pattern.
6+
</div>
7+
<div class="m-2 bg-white">
8+
<form method="post" id="concatenate-field-form">
9+
{% csrf_token %}
10+
{{ form.non_fields_errors }}
11+
<table class="min-w-full">
12+
{{ form.as_table }}
13+
</table>
14+
<div></div>
15+
<div class="submit-row">
16+
<input type="submit" value="{% translate 'Preview' %}" class="default" name="_preview">
17+
</div>
18+
{% if changes %}
19+
<h3>Preview (showing first 10 records):</h3>
20+
<table class="min-w-full">
21+
<thead>
22+
<tr>
23+
<th>Record ID</th>
24+
<th>Original Value</th>
25+
<th>New Value</th>
26+
</tr>
27+
</thead>
28+
<tbody>
29+
{% for result in changes %}
30+
<tr>
31+
<td>{{ result.record_id }}</td>
32+
<td>{{ result.original|default:"(empty)" }}</td>
33+
<td>{{ result.updated }}</td>
34+
</tr>
35+
{% endfor %}
36+
</tbody>
37+
</table>
38+
<div class="submit-row">
39+
<input type="submit" value="{% translate 'Apply' %}" class="default" name="_apply">
40+
</div>
41+
{% endif %}
42+
</form>
43+
</div>
44+
{% endblock action-content %}

0 commit comments

Comments
 (0)