Skip to content

Commit 5d72a03

Browse files
add ! External Beneficiary Reference field
1 parent 49f6d67 commit 5d72a03

File tree

6 files changed

+436
-0
lines changed

6 files changed

+436
-0
lines changed

src/country_workspace/contrib/hope/apps.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .geo import Admin1Choice, Admin2Choice, Admin3Choice, Admin4Choice, CountryChoice
44
from .lookups import FinancialInstitutionChoice
55
from .phone_numbers import PhoneNumberField
6+
from .beneficiary_reference import BeneficiaryReferenceModelChoice
67

78

89
class Config(AppConfig):
@@ -23,6 +24,7 @@ def ready(self) -> None:
2324
field_registry.register(Admin4Choice)
2425
field_registry.register(FinancialInstitutionChoice)
2526
field_registry.register(PhoneNumberField)
27+
field_registry.register(BeneficiaryReferenceModelChoice)
2628

2729
from country_workspace.contrib.hope.validators import FullHouseholdValidator
2830
from country_workspace.validators.registry import beneficiary_validator_registry
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from typing import Any, TYPE_CHECKING
2+
from contextlib import suppress
3+
from django.core.exceptions import ValidationError
4+
from django.db.models import QuerySet
5+
from django.forms import ModelChoiceField
6+
from django.urls import resolve
7+
from django_select2.forms import ModelSelect2Widget
8+
from country_workspace.state import state
9+
10+
if TYPE_CHECKING:
11+
from country_workspace.models import Individual
12+
13+
14+
class BeneficiarySelect2Widget(ModelSelect2Widget):
15+
search_fields = ["name__icontains"]
16+
17+
def __init__(self, batch_id: int | None = None, *args: Any, **kwargs: Any) -> None:
18+
self.batch_id = batch_id
19+
kwargs.setdefault(
20+
"attrs",
21+
{
22+
"data-minimum-input-length": 0,
23+
"class": "form-control",
24+
},
25+
)
26+
super().__init__(*args, **kwargs)
27+
28+
def get_queryset(self) -> QuerySet["Individual"]:
29+
return _get_individuals_queryset(self.batch_id)
30+
31+
32+
class BeneficiaryReferenceModelChoice(ModelChoiceField):
33+
def __init__(self, *args: Any, **kwargs: Any) -> None:
34+
batch_id = _get_batch_id_from_request()
35+
queryset = _get_individuals_queryset(batch_id)
36+
37+
kwargs.setdefault("queryset", queryset)
38+
if batch_id:
39+
kwargs.setdefault("widget", BeneficiarySelect2Widget(batch_id=batch_id, model=queryset.model))
40+
41+
super().__init__(*args, **kwargs)
42+
43+
def to_python(self, value: Any) -> str | None:
44+
if value in self.empty_values:
45+
return None
46+
47+
match value:
48+
case obj if hasattr(obj, "name"):
49+
return obj.name
50+
case int() | str() if str(value).isdigit():
51+
with suppress(self.queryset.model.DoesNotExist):
52+
return self.queryset.get(pk=int(value)).name
53+
case str():
54+
with suppress(self.queryset.model.DoesNotExist):
55+
return self.queryset.get(flex_fields__individual_id=value).name
56+
with suppress(self.queryset.model.DoesNotExist):
57+
return self.queryset.get(name=value).name
58+
case _:
59+
pass
60+
61+
raise ValidationError(self.error_messages["invalid_choice"], code="invalid_choice")
62+
63+
def prepare_value(self, value: Any) -> int | None:
64+
if value in self.empty_values:
65+
return None
66+
67+
match value:
68+
case obj if hasattr(obj, "pk"):
69+
return obj.pk
70+
case str():
71+
with suppress(self.queryset.model.DoesNotExist):
72+
return self.queryset.get(flex_fields__individual_id=value).pk
73+
with suppress(self.queryset.model.DoesNotExist):
74+
return self.queryset.get(name=value).pk
75+
case _:
76+
pass
77+
78+
return None
79+
80+
81+
def _get_batch_id_from_request() -> int | None:
82+
"""Extract batch ID from current request context."""
83+
if not state.request:
84+
return None
85+
86+
resolved = resolve(state.request.path)
87+
if not (resolved and "object_id" in resolved.kwargs):
88+
return None
89+
if resolved.view_name != "workspace:workspaces_countryhousehold_change":
90+
return None
91+
92+
try:
93+
from django.apps import apps
94+
95+
Household = apps.get_model("country_workspace", "Household")
96+
household = Household.objects.select_related("batch").get(pk=resolved.kwargs["object_id"])
97+
except (Household.DoesNotExist, ValueError, KeyError):
98+
return None
99+
else:
100+
return household.batch.pk
101+
102+
103+
def _get_individuals_queryset(batch_id: int | None) -> QuerySet["Individual"]:
104+
"""Get filtered Individual queryset for given batch_id."""
105+
from django.apps import apps
106+
107+
Individual = apps.get_model("country_workspace", "Individual")
108+
109+
if not batch_id:
110+
return Individual.objects.none()
111+
112+
return Individual.objects.filter(batch_id=batch_id, removed=False)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 5.2.3 on 2025-08-01 13:16
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("country_workspace", "0021_dataserializer_program_serializer"),
9+
]
10+
11+
operations = [
12+
migrations.AddConstraint(
13+
model_name="individual",
14+
constraint=models.UniqueConstraint(
15+
fields=("batch", "name"),
16+
name="unique_name_per_batch_individual",
17+
violation_error_message="Name must be unique per batch.",
18+
),
19+
),
20+
]

src/country_workspace/models/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ class Meta:
7676
("export_beneficiary", "Can Export Beneficiary Records"),
7777
("push_beneficiary_to_hope", "Can Push Beneficiary Records To HOPE core"),
7878
)
79+
constraints = [
80+
models.UniqueConstraint(
81+
fields=["batch", "name"],
82+
name="unique_name_per_batch_%(class)s",
83+
violation_error_message=_("Name must be unique per batch."),
84+
),
85+
]
7986

8087
def __str__(self) -> str:
8188
return self.name or "%s %s" % (self._meta.verbose_name, self.id)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Generated by HCW 0.1.0 on 2025 07 29 07:39:07
2+
from typing import Any
3+
from packaging.version import Version
4+
from concurrency.utils import fqn
5+
from contextlib import suppress
6+
from django import forms
7+
from django.db import transaction
8+
from django.utils.text import slugify
9+
10+
from hope_flex_fields.models import FieldDefinition, Fieldset
11+
from hope_flex_fields.utils import get_kwargs_from_field_class, get_common_attrs
12+
from country_workspace.contrib.hope.beneficiary_reference import BeneficiaryReferenceModelChoice
13+
from country_workspace.contrib.hope.constants import HOUSEHOLD_FIELDSET_NAME, INDIVIDUAL_FIELDSET_NAME
14+
15+
_script_for_version = Version("0.1.0")
16+
17+
18+
attrs_default = lambda cls: get_kwargs_from_field_class(cls, get_common_attrs())
19+
20+
FIELDS: dict[str, dict[str, Any]] = {
21+
"alternate_collector_id": {
22+
"name": "Alternate Collector Reference ID",
23+
"defaults": {
24+
"field_type": fqn(BeneficiaryReferenceModelChoice),
25+
"attrs": {
26+
**attrs_default(BeneficiaryReferenceModelChoice),
27+
"help_text": "A unique beneficiary reference ID for an alternate collector.",
28+
},
29+
},
30+
},
31+
"primary_collector_id": {
32+
"name": "Primary Collector Reference ID",
33+
"defaults": {
34+
"field_type": fqn(BeneficiaryReferenceModelChoice),
35+
"attrs": {
36+
**attrs_default(BeneficiaryReferenceModelChoice),
37+
"help_text": "A unique beneficiary reference ID for a primary collector.",
38+
},
39+
},
40+
},
41+
"individual_id": {
42+
"name": "Individual ID",
43+
"defaults": {
44+
"field_type": fqn(forms.CharField),
45+
"attrs": {
46+
**attrs_default(forms.CharField),
47+
"help_text": "A unique individual ID.",
48+
},
49+
},
50+
},
51+
}
52+
53+
54+
FIELDSETS = {
55+
HOUSEHOLD_FIELDSET_NAME: ("alternate_collector_id", "primary_collector_id"),
56+
INDIVIDUAL_FIELDSET_NAME: ("individual_id",),
57+
}
58+
59+
60+
def forward() -> None:
61+
with transaction.atomic():
62+
for fieldset_name, field_names in FIELDSETS.items():
63+
fs, __ = Fieldset.objects.get_or_create(name=fieldset_name)
64+
for field_name in field_names:
65+
field_def = FIELDS[field_name]
66+
field_def["defaults"]["slug"] = slugify(field_def["name"])
67+
fd, __ = FieldDefinition.objects.update_or_create(
68+
name=field_def["name"], defaults=field_def["defaults"]
69+
)
70+
fs.fields.update_or_create(name=field_name, defaults={"definition": fd})
71+
72+
73+
def backward() -> None:
74+
with transaction.atomic():
75+
for fieldset_name, field_names in FIELDSETS.items():
76+
try:
77+
fs = Fieldset.objects.get(name=fieldset_name)
78+
except Fieldset.DoesNotExist:
79+
continue
80+
81+
for field_name in field_names:
82+
fs.fields.filter(name=field_name).delete()
83+
with suppress(FieldDefinition.DoesNotExist):
84+
fd = FieldDefinition.objects.get(name=FIELDS[field_name]["name"])
85+
fd.delete()
86+
87+
88+
class Scripts:
89+
requires = []
90+
operations = [(forward, backward)]

0 commit comments

Comments
 (0)