+ {transliterateLatinNames?.value ? (
+
+
+ {t('Latin names will be transliterated automatically')}
+
+
+ ) : null}
{entries?.length || entriesFlexFields?.length ? (
{
if (!_beneficiaryGroup || !text) {
@@ -28,7 +33,9 @@ export function isShowIssueType(category: string | number): boolean {
cat === GRIEVANCE_CATEGORIES.GRIEVANCE_COMPLAINT
);
}
-export const SYSTEM_GENERATED_ISSUE_TYPES = [GRIEVANCE_ISSUE_TYPES.BIOMETRIC_PHOTO_ERROR];
+export const SYSTEM_GENERATED_ISSUE_TYPES = [
+ GRIEVANCE_ISSUE_TYPES.BIOMETRIC_PHOTO_ERROR,
+];
export function isSystemGenerated(category: any, issueType?: number): boolean {
const cat = category?.toString();
@@ -96,7 +103,8 @@ export const roleDisplayMap = {
};
export function prepareExistingAccountValues(
- individualDataUpdateAccountsToEdit: Record[] | null | undefined,
+ individualDataUpdateAccountsToEdit:
+ Record[] | null | undefined,
) {
if (!individualDataUpdateAccountsToEdit) {
return [];
@@ -210,14 +218,19 @@ export function prepareRestVariables(values: any): CreateGrievanceTicket {
values.individualData?.identities,
);
+ const addIndividualData = values.transliterateLatinNames
+ ? removeLatinNameFields(values.individualData || {})
+ : values.individualData;
+
extras.issueType = {
addIndividualIssueTypeExtras: {
household: values.selectedHousehold?.id,
individualData: {
- ...values.individualData,
+ ...addIndividualData,
documents: newlyAddedDocumentsWithoutIds,
identities: newlyAddedIdentitiesWithoutIds,
flexFields,
+ transliterateLatinNames: Boolean(values.transliterateLatinNames),
},
},
};
@@ -240,7 +253,12 @@ export function prepareRestVariables(values: any): CreateGrievanceTicket {
} else if (
issueType === parseInt(GRIEVANCE_ISSUE_TYPES.EDIT_INDIVIDUAL, 10)
) {
- const individualData = values.individualDataUpdateFields
+ const transliterateLatinNames = transliterateUpdateRows(values);
+ const updateFields = transliterateLatinNames
+ ? removeLatinNameRows(values.individualDataUpdateFields)
+ : values.individualDataUpdateFields;
+
+ const individualData = updateFields
.filter((item) => item.fieldName && !item.isFlexField)
.reduce((prev, current) => {
prev[camelCase(current.fieldName)] = current.fieldValue;
@@ -285,6 +303,7 @@ export function prepareRestVariables(values: any): CreateGrievanceTicket {
accounts_to_edit: prepareExistingAccountValues(
values.individualDataUpdateAccountsToEdit,
),
+ transliterateLatinNames,
},
},
};
diff --git a/src/frontend/src/components/grievances/utils/editGrievanceUtils.ts b/src/frontend/src/components/grievances/utils/editGrievanceUtils.ts
index 6f63479a26a..fb6aa4f8dc3 100644
--- a/src/frontend/src/components/grievances/utils/editGrievanceUtils.ts
+++ b/src/frontend/src/components/grievances/utils/editGrievanceUtils.ts
@@ -10,6 +10,11 @@ import EditHouseholdDataChange from '../EditHouseholdDataChange/EditHouseholdDat
import EditIndividualDataChange from '../EditIndividualDataChange/EditIndividualDataChange';
import { GrievanceTicketDetail } from '@restgenerated/models/GrievanceTicketDetail';
import { PaymentDetail } from '@restgenerated/models/PaymentDetail';
+import {
+ removeLatinNameFields,
+ removeLatinNameRows,
+ transliterateUpdateRows,
+} from './latinNames';
interface EditValuesTypes {
priority?: number | string;
@@ -32,6 +37,8 @@ interface EditValuesTypes {
paymentRecord?: string;
selectedLinkedTickets: string[];
individualData?;
+ individualDataUpdateFields?;
+ transliterateLatinNames?: boolean;
householdDataUpdateFields?;
partner?;
comments?;
@@ -52,6 +59,10 @@ function prepareInitialValueAddIndividual(
};
const flexFields = individualData.flexFields;
delete individualData.flexFields;
+ // Not a real field - it is a top level Formik value driving the checkbox
+ initialValues.transliterateLatinNames =
+ individualData.transliterateLatinNames ?? true;
+ delete individualData.transliterateLatinNames;
initialValues.individualData = Object.entries(individualData).reduce(
(previousValue, currentValue: [string, { value: string }]) => {
previousValue[camelCase(currentValue[0])] = currentValue[1].value;
@@ -93,6 +104,7 @@ function prepareInitialValueEditIndividual(initialValues, ticket) {
identitiesToEdit,
accounts,
accountsToEdit,
+ transliterateLatinNames,
...rest
} = ticketDetails.individualData;
@@ -103,6 +115,7 @@ function prepareInitialValueEditIndividual(initialValues, ticket) {
return {
...initialValues,
selectedIndividual: individual,
+ transliterateLatinNames: transliterateLatinNames?.value ?? true,
individualDataUpdateFields: [...individualDataArray, ...flexFieldsArray],
individualDataUpdateFieldsDocuments: camelizeArrayObjects(documents),
individualDataUpdateDocumentsToRemove:
@@ -284,12 +297,20 @@ function prepareAddIndividualVariables(requiredVariables, values) {
}
}
}
+ const individualData = values.transliterateLatinNames
+ ? removeLatinNameFields(values.individualData || {})
+ : values.individualData;
+
return {
...requiredVariables,
linkedTickets: values.selectedLinkedTickets,
extras: {
addIndividualIssueTypeExtras: {
- individualData: { ...values.individualData, flexFields },
+ individualData: {
+ ...individualData,
+ flexFields,
+ transliterateLatinNames: Boolean(values.transliterateLatinNames),
+ },
},
},
};
@@ -303,7 +324,12 @@ function prepareDeleteIndividualVariables(requiredVariables, values) {
}
function prepareEditIndividualVariables(requiredVariables, values) {
- const individualData = values.individualDataUpdateFields
+ const transliterateLatinNames = transliterateUpdateRows(values);
+ const updateFields = transliterateLatinNames
+ ? removeLatinNameRows(values.individualDataUpdateFields)
+ : values.individualDataUpdateFields;
+
+ const individualData = updateFields
.filter((item) => item.fieldName && !item.isFlexField)
.reduce((prev, current) => {
prev[camelCase(current.fieldName)] = current.fieldValue;
@@ -352,6 +378,7 @@ function prepareEditIndividualVariables(requiredVariables, values) {
values.individualDataUpdateIdentitiesToEdit,
),
accountsToEdit: values.individualDataUpdateAccountsToEdit,
+ transliterateLatinNames,
},
},
},
diff --git a/src/frontend/src/components/grievances/utils/latinNames.test.ts b/src/frontend/src/components/grievances/utils/latinNames.test.ts
new file mode 100644
index 00000000000..647cbc1fdb0
--- /dev/null
+++ b/src/frontend/src/components/grievances/utils/latinNames.test.ts
@@ -0,0 +1,272 @@
+import { describe, expect, it } from 'vitest';
+import { GRIEVANCE_CATEGORIES, GRIEVANCE_ISSUE_TYPES } from '@utils/constants';
+import { prepareRestVariables } from './createGrievanceUtils';
+import { prepareInitialValues } from './editGrievanceUtils';
+import { validate, validateUsingSteps } from './validateGrievance';
+import { GrievanceSteps } from '@utils/constants';
+
+const editIndividualBase = {
+ category: GRIEVANCE_CATEGORIES.DATA_CHANGE,
+ issueType: GRIEVANCE_ISSUE_TYPES.EDIT_INDIVIDUAL,
+ selectedIndividual: { id: 'ind-1' },
+ selectedLinkedTickets: [],
+};
+
+const addIndividualBase = {
+ category: GRIEVANCE_CATEGORIES.DATA_CHANGE,
+ issueType: GRIEVANCE_ISSUE_TYPES.ADD_INDIVIDUAL,
+ selectedHousehold: { id: 'hh-1' },
+ selectedLinkedTickets: [],
+};
+
+const individualDataOf = (variables) =>
+ variables.extras.issueType.individualDataUpdateIssueTypeExtras.individualData;
+const addIndividualDataOf = (variables) =>
+ variables.extras.issueType.addIndividualIssueTypeExtras.individualData;
+
+describe('prepareRestVariables - transliterateLatinNames', () => {
+ // The backend rejects a name change that carries neither a *_latin twin nor the
+ // flag, so the flag has to travel inside individual_data on every submission.
+ it('sends the flag inside individualData for an individual data update', () => {
+ const variables = prepareRestVariables({
+ ...editIndividualBase,
+ transliterateLatinNames: true,
+ individualDataUpdateFields: [
+ { fieldName: 'given_name', fieldValue: 'Ivan' },
+ ],
+ });
+
+ expect(individualDataOf(variables).transliterateLatinNames).toBe(true);
+ expect(individualDataOf(variables).givenName).toBe('Ivan');
+ });
+
+ it('sends the flag inside individualData when adding an individual', () => {
+ const variables = prepareRestVariables({
+ ...addIndividualBase,
+ transliterateLatinNames: true,
+ individualData: { fullName: 'Иван Иванов' },
+ });
+
+ expect(addIndividualDataOf(variables).transliterateLatinNames).toBe(true);
+ expect(addIndividualDataOf(variables).fullName).toBe('Иван Иванов');
+ });
+
+ it('drops latin names when transliteration is on so they cannot override it', () => {
+ // Explicit latin values win over transliteration on the backend, so leaving a
+ // stale value behind after ticking the checkbox would silently be applied.
+ const variables = prepareRestVariables({
+ ...editIndividualBase,
+ transliterateLatinNames: true,
+ individualDataUpdateFields: [
+ { fieldName: 'given_name', fieldValue: 'Ivan' },
+ { fieldName: 'given_name_latin', fieldValue: 'Stale' },
+ ],
+ });
+
+ expect(individualDataOf(variables).givenNameLatin).toBeUndefined();
+ });
+
+ it('keeps a latin-only correction and turns the flag off when no name changes', () => {
+ // The checkbox is hidden (and defaults to on) while no name row is present, so
+ // without this a bare latin fix would be stripped and the ticket would carry
+ // nothing to apply.
+ const variables = prepareRestVariables({
+ ...editIndividualBase,
+ transliterateLatinNames: true,
+ individualDataUpdateFields: [
+ { fieldName: 'given_name_latin', fieldValue: 'Ivan' },
+ ],
+ });
+
+ expect(individualDataOf(variables).givenNameLatin).toBe('Ivan');
+ expect(individualDataOf(variables).transliterateLatinNames).toBe(false);
+ });
+
+ it('keeps latin names when transliteration is off', () => {
+ const variables = prepareRestVariables({
+ ...editIndividualBase,
+ transliterateLatinNames: false,
+ individualDataUpdateFields: [
+ { fieldName: 'given_name', fieldValue: 'Ivan' },
+ { fieldName: 'given_name_latin', fieldValue: 'Ivan' },
+ ],
+ });
+
+ expect(individualDataOf(variables).givenNameLatin).toBe('Ivan');
+ expect(individualDataOf(variables).transliterateLatinNames).toBe(false);
+ });
+
+ it('drops latin names from the add individual payload when transliteration is on', () => {
+ const variables = prepareRestVariables({
+ ...addIndividualBase,
+ transliterateLatinNames: true,
+ individualData: { fullName: 'Иван Иванов', fullNameLatin: 'Stale' },
+ });
+
+ expect(addIndividualDataOf(variables).fullNameLatin).toBeUndefined();
+ });
+});
+
+describe('validate - latin name rules', () => {
+ const runEditValidation = (values) =>
+ validate(
+ { ...editIndividualBase, ...values },
+ null,
+ {},
+ {},
+ { memberLabel: 'Individual', groupLabel: 'Household' },
+ );
+
+ it('requires the latin twin of a changed name when transliteration is off', () => {
+ const errors = runEditValidation({
+ transliterateLatinNames: false,
+ individualDataUpdateFields: [
+ { fieldName: 'full_name', fieldValue: 'Иван Иванов' },
+ ],
+ });
+
+ expect(errors.individualDataUpdateFields).toBe(
+ 'Provide full_name_latin or enable automatic transliteration',
+ );
+ });
+
+ it('accepts a changed name without its latin twin when transliteration is on', () => {
+ const errors = runEditValidation({
+ transliterateLatinNames: true,
+ individualDataUpdateFields: [
+ { fieldName: 'full_name', fieldValue: 'Иван Иванов' },
+ ],
+ });
+
+ expect(errors.individualDataUpdateFields).toBeUndefined();
+ });
+
+ it('accepts a changed name paired with its latin twin', () => {
+ const errors = runEditValidation({
+ transliterateLatinNames: false,
+ individualDataUpdateFields: [
+ { fieldName: 'full_name', fieldValue: 'Иван Иванов' },
+ { fieldName: 'full_name_latin', fieldValue: "Ivan O'Ivanov-Smith" },
+ ],
+ });
+
+ expect(errors.individualDataUpdateFields).toBeUndefined();
+ });
+
+ it('rejects latin values the backend regex would reject', () => {
+ // The model validator runs at close time, long after the ticket was created -
+ // catching it here keeps the ticket from being unclosable.
+ const errors = runEditValidation({
+ transliterateLatinNames: false,
+ individualDataUpdateFields: [
+ { fieldName: 'full_name', fieldValue: 'Иван Иванов' },
+ { fieldName: 'full_name_latin', fieldValue: 'Иван 123' },
+ ],
+ });
+
+ expect(errors.individualDataUpdateFields).toBe(
+ 'Only ASCII letters, spaces, hyphens and apostrophes are allowed',
+ );
+ });
+
+ it('rejects a malformed latin-only correction even with transliteration on', () => {
+ const errors = runEditValidation({
+ transliterateLatinNames: true,
+ individualDataUpdateFields: [
+ { fieldName: 'given_name_latin', fieldValue: 'Иван' },
+ ],
+ });
+
+ expect(errors.individualDataUpdateFields).toBe(
+ 'Only ASCII letters, spaces, hyphens and apostrophes are allowed',
+ );
+ });
+
+ it('requires each name to have its own latin twin', () => {
+ const errors = runEditValidation({
+ transliterateLatinNames: false,
+ individualDataUpdateFields: [
+ { fieldName: 'given_name', fieldValue: 'Иван' },
+ { fieldName: 'full_name_latin', fieldValue: 'Ivan Ivanov' },
+ ],
+ });
+
+ expect(errors.individualDataUpdateFields).toBe(
+ 'Provide given_name_latin or enable automatic transliteration',
+ );
+ });
+});
+
+describe('prepareInitialValues - transliterateLatinNames', () => {
+ const ticketBase = {
+ priority: 1,
+ urgency: 1,
+ programs: [{ id: 'prog-1' }],
+ linkedTickets: [],
+ category: Number(GRIEVANCE_CATEGORIES.DATA_CHANGE),
+ issueType: Number(GRIEVANCE_ISSUE_TYPES.EDIT_INDIVIDUAL),
+ individual: { id: 'ind-1' },
+ };
+
+ it('lifts the stored flag out of individualData instead of listing it as a field row', () => {
+ // The backend wraps every individual_data key as {value, approve_status}, so
+ // without this the flag would come back as an editable "transliterate latin names" row.
+ const initialValues = prepareInitialValues({
+ ...ticketBase,
+ ticketDetails: {
+ individualData: {
+ givenName: { value: 'Ivan' },
+ transliterateLatinNames: { value: false },
+ },
+ },
+ } as any);
+
+ expect(initialValues.transliterateLatinNames).toBe(false);
+ expect(
+ initialValues.individualDataUpdateFields.map((row) => row.fieldName),
+ ).toEqual(['givenName']);
+ });
+
+ it('defaults to enabled when the ticket predates the flag', () => {
+ const initialValues = prepareInitialValues({
+ ...ticketBase,
+ ticketDetails: { individualData: { givenName: { value: 'Ivan' } } },
+ } as any);
+
+ expect(initialValues.transliterateLatinNames).toBe(true);
+ });
+});
+
+describe('validateUsingSteps - latin name rules for add individual', () => {
+ const runAddValidation = (values) =>
+ validateUsingSteps(
+ { ...addIndividualBase, ...values },
+ [],
+ {},
+ {},
+ GrievanceSteps.Description,
+ () => {},
+ { memberLabel: 'Individual', groupLabel: 'Household' },
+ );
+
+ it('requires full_name_latin when transliteration is off', () => {
+ const errors = runAddValidation({
+ transliterateLatinNames: false,
+ individualData: { fullName: 'Иван Иванов' },
+ });
+
+ expect(errors.individualData).toEqual({
+ fullNameLatin:
+ 'Provide full_name_latin or enable automatic transliteration',
+ });
+ });
+
+ it('does not require latin names when transliteration is on', () => {
+ const errors = runAddValidation({
+ transliterateLatinNames: true,
+ individualData: { fullName: 'Иван Иванов' },
+ });
+
+ expect(errors.individualData).toBeUndefined();
+ });
+});
diff --git a/src/frontend/src/components/grievances/utils/latinNames.ts b/src/frontend/src/components/grievances/utils/latinNames.ts
new file mode 100644
index 00000000000..b1069e470a2
--- /dev/null
+++ b/src/frontend/src/components/grievances/utils/latinNames.ts
@@ -0,0 +1,56 @@
+import camelCase from 'lodash/camelCase';
+
+// Mirrors NAME_TO_LATIN_FIELDS in src/hope/apps/household/utils.py - the backend
+// requires the *_latin twin of every changed name unless transliteration is enabled.
+export const NAME_TO_LATIN_FIELDS = {
+ given_name: 'given_name_latin',
+ middle_name: 'middle_name_latin',
+ family_name: 'family_name_latin',
+ full_name: 'full_name_latin',
+};
+
+export const LATIN_NAME_FIELDS: string[] = Object.values(NAME_TO_LATIN_FIELDS);
+export const NAME_FIELDS: string[] = Object.keys(NAME_TO_LATIN_FIELDS);
+
+// Mirrors ascii_name_validator in src/hope/models/individual.py
+export const LATIN_NAME_REGEX = /^[A-Za-z]+(?:[ '-][A-Za-z]+)*$/;
+
+export const LATIN_NAME_FORMAT_ERROR =
+ 'Only ASCII letters, spaces, hyphens and apostrophes are allowed';
+
+export function latinNameMissingError(latinFieldName: string): string {
+ return `Provide ${latinFieldName} or enable automatic transliteration`;
+}
+
+// The backend transliterates on its own, so sending explicit latin values alongside
+// the flag would silently win over it - drop them instead.
+export function removeLatinNameFields>(
+ individualData: T,
+): T {
+ const result = { ...individualData };
+ for (const latinField of LATIN_NAME_FIELDS) {
+ delete result[camelCase(latinField)];
+ }
+ return result;
+}
+
+export function removeLatinNameRows(individualDataUpdateFields) {
+ return (individualDataUpdateFields || []).filter(
+ (item) => !LATIN_NAME_FIELDS.includes(item?.fieldName),
+ );
+}
+
+export function hasNameFieldRow(individualDataUpdateFields): boolean {
+ return (individualDataUpdateFields || []).some((item) =>
+ NAME_FIELDS.includes(item?.fieldName),
+ );
+}
+
+// Transliteration only applies when a name is actually being changed; otherwise a
+// latin-only correction would be stripped from the payload and silently lost.
+export function transliterateUpdateRows(values): boolean {
+ return (
+ Boolean(values.transliterateLatinNames) &&
+ hasNameFieldRow(values.individualDataUpdateFields)
+ );
+}
diff --git a/src/frontend/src/components/grievances/utils/validateGrievance.ts b/src/frontend/src/components/grievances/utils/validateGrievance.ts
index 55695d856aa..71e9ca5c7c7 100644
--- a/src/frontend/src/components/grievances/utils/validateGrievance.ts
+++ b/src/frontend/src/components/grievances/utils/validateGrievance.ts
@@ -4,10 +4,67 @@ import {
GRIEVANCE_ISSUE_TYPES,
GrievanceSteps,
} from '@utils/constants';
+import {
+ LATIN_NAME_FIELDS,
+ LATIN_NAME_FORMAT_ERROR,
+ LATIN_NAME_REGEX,
+ NAME_TO_LATIN_FIELDS,
+ latinNameMissingError,
+ transliterateUpdateRows,
+} from './latinNames';
export function isEmpty(value): boolean {
return value === undefined || value === null || value === '';
}
+// Mirrors the backend rule in AddIndividualDataSerializer / IndividualUpdateDataSerializer:
+// every changed name needs its *_latin twin unless transliteration is enabled. Checked
+// client-side so the form fails fast instead of surfacing a nested DRF 400.
+function validateLatinNameRows(values): string | undefined {
+ if (transliterateUpdateRows(values)) {
+ return undefined;
+ }
+ const rows = values.individualDataUpdateFields || [];
+ const valueOf = (fieldName: string) =>
+ rows.find((row) => row?.fieldName === fieldName)?.fieldValue;
+
+ for (const latinField of LATIN_NAME_FIELDS) {
+ const latinValue = valueOf(latinField);
+ if (!isEmpty(latinValue) && !LATIN_NAME_REGEX.test(String(latinValue))) {
+ return LATIN_NAME_FORMAT_ERROR;
+ }
+ }
+ for (const [nameField, latinField] of Object.entries(NAME_TO_LATIN_FIELDS)) {
+ if (!isEmpty(valueOf(nameField)) && isEmpty(valueOf(latinField))) {
+ return latinNameMissingError(latinField);
+ }
+ }
+ return undefined;
+}
+
+function validateLatinNameData(values): { [key: string]: string } {
+ const latinErrors: { [key: string]: string } = {};
+ if (values.transliterateLatinNames) {
+ return latinErrors;
+ }
+ const individualData = values.individualData || {};
+
+ for (const latinField of LATIN_NAME_FIELDS) {
+ const latinValue = individualData[camelCase(latinField)];
+ if (!isEmpty(latinValue) && !LATIN_NAME_REGEX.test(String(latinValue))) {
+ latinErrors[camelCase(latinField)] = LATIN_NAME_FORMAT_ERROR;
+ }
+ }
+ for (const [nameField, latinField] of Object.entries(NAME_TO_LATIN_FIELDS)) {
+ if (
+ !isEmpty(individualData[camelCase(nameField)]) &&
+ isEmpty(individualData[camelCase(latinField)])
+ ) {
+ latinErrors[camelCase(latinField)] = latinNameMissingError(latinField);
+ }
+ }
+ return latinErrors;
+}
+
export function validate(
values,
addIndividualFieldsData: Array | null,
@@ -113,6 +170,10 @@ export function validate(
}
});
}
+ const latinNameError = validateLatinNameRows(values);
+ if (latinNameError) {
+ errors.individualDataUpdateFields = latinNameError;
+ }
if (values.individualDataUpdateFieldsDocuments?.length) {
values.individualDataUpdateFieldsDocuments
@@ -177,7 +238,7 @@ export function validate(
category === GRIEVANCE_CATEGORIES.DATA_CHANGE &&
issueType === GRIEVANCE_ISSUE_TYPES.ADD_INDIVIDUAL
) {
- const individualDataErrors = {};
+ const individualDataErrors = validateLatinNameData(values);
const individualData = values.individualData || {};
if (addIndividualFieldsData) {
for (const field of addIndividualFieldsData) {
@@ -189,11 +250,11 @@ export function validate(
) {
individualDataErrors[fieldName] = 'Field Required';
}
- if (Object.keys(individualDataErrors).length > 0) {
- errors.individualData = individualDataErrors;
- }
}
}
+ if (Object.keys(individualDataErrors).length > 0) {
+ errors.individualData = individualDataErrors;
+ }
}
if (values.documentation?.length) {
@@ -317,6 +378,10 @@ export function validateUsingSteps(
}
});
}
+ const latinNameError = validateLatinNameRows(values);
+ if (latinNameError) {
+ errors.individualDataUpdateFields = latinNameError;
+ }
if (values.individualDataUpdateFieldsDocuments?.length) {
values.individualDataUpdateFieldsDocuments.forEach((el, index) => {
@@ -417,7 +482,7 @@ export function validateUsingSteps(
issueType === GRIEVANCE_ISSUE_TYPES.ADD_INDIVIDUAL &&
activeStep === GrievanceSteps.Description
) {
- const individualDataErrors = {};
+ const individualDataErrors = validateLatinNameData(values);
const individualData = values.individualData || {};
if (addIndividualFieldsData) {
@@ -430,11 +495,11 @@ export function validateUsingSteps(
) {
individualDataErrors[fieldName] = 'Field Required';
}
- if (Object.keys(individualDataErrors).length > 0) {
- errors.individualData = individualDataErrors;
- }
}
}
+ if (Object.keys(individualDataErrors).length > 0) {
+ errors.individualData = individualDataErrors;
+ }
if (individualData?.documents?.length) {
individualData.documents.forEach((_el, index) => {
diff --git a/src/frontend/src/components/payments/IndividualDetails.tsx b/src/frontend/src/components/payments/IndividualDetails.tsx
index 3703caf74b1..36aee283b0c 100644
--- a/src/frontend/src/components/payments/IndividualDetails.tsx
+++ b/src/frontend/src/components/payments/IndividualDetails.tsx
@@ -3,7 +3,7 @@ import { Overview } from '@components/payments/Overview';
import { Title } from '@core/Title';
import { Grid, Typography } from '@mui/material';
import { LabelizedField } from '@core/LabelizedField';
-import { getPhoneNoLabel } from '@utils/utils';
+import { displayNameWithLatin, getPhoneNoLabel } from '@utils/utils';
import { useProgramContext } from 'src/programContext';
import { ReactElement } from 'react';
@@ -29,7 +29,7 @@ export const IndividualDetails = ({ individual }): ReactElement => {
diff --git a/src/frontend/src/components/people/PeopleBioData/PeopleBioData.tsx b/src/frontend/src/components/people/PeopleBioData/PeopleBioData.tsx
index a6e1aa7dc20..5e5a32e5e00 100644
--- a/src/frontend/src/components/people/PeopleBioData/PeopleBioData.tsx
+++ b/src/frontend/src/components/people/PeopleBioData/PeopleBioData.tsx
@@ -6,6 +6,7 @@ import { DisabilityEnum } from '@restgenerated/models/DisabilityEnum';
import { IndividualDetail } from '@restgenerated/models/IndividualDetail';
import {
choicesToDict,
+ displayNameWithLatin,
formatAge,
getPhoneNoLabel,
renderBoolean,
@@ -276,22 +277,22 @@ export const PeopleBioData = ({
- {individual?.fullName}
+ {displayNameWithLatin(individual, 'fullName')}
- {individual?.givenName}
+ {displayNameWithLatin(individual, 'givenName')}
- {individual?.middleName}
+ {displayNameWithLatin(individual, 'middleName')}
- {individual?.familyName}
+ {displayNameWithLatin(individual, 'familyName')}
diff --git a/src/frontend/src/components/population/HouseholdDetails/HouseholdDetails.tsx b/src/frontend/src/components/population/HouseholdDetails/HouseholdDetails.tsx
index f1b760882d1..94d5a6091b6 100644
--- a/src/frontend/src/components/population/HouseholdDetails/HouseholdDetails.tsx
+++ b/src/frontend/src/components/population/HouseholdDetails/HouseholdDetails.tsx
@@ -5,7 +5,7 @@ import { Title } from '@core/Title';
import { Box, Grid, Theme, Typography } from '@mui/material';
import { GrievanceChoices } from '@restgenerated/models/GrievanceChoices';
import { HouseholdDetail } from '@restgenerated/models/HouseholdDetail';
-import { formatCurrencyWithSymbol } from '@utils/utils';
+import { displayNameWithLatin, formatCurrencyWithSymbol } from '@utils/utils';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@@ -72,7 +72,7 @@ export function HouseholdDetails({
- {household?.headOfHousehold?.fullName}
+ {displayNameWithLatin(household?.headOfHousehold, 'fullName')}
diff --git a/src/frontend/src/components/population/IndividualBioData/IndividualBioData.tsx b/src/frontend/src/components/population/IndividualBioData/IndividualBioData.tsx
index 09820984de1..9db562bf08c 100644
--- a/src/frontend/src/components/population/IndividualBioData/IndividualBioData.tsx
+++ b/src/frontend/src/components/population/IndividualBioData/IndividualBioData.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import {
choicesToDict,
+ displayNameWithLatin,
formatAge,
getPhoneNoLabel,
renderBoolean,
@@ -194,22 +195,22 @@ export const IndividualBioData = ({
- {individual?.fullName}
+ {displayNameWithLatin(individual, 'fullName')}
- {individual?.givenName}
+ {displayNameWithLatin(individual, 'givenName')}
- {individual?.middleName}
+ {displayNameWithLatin(individual, 'middleName')}
- {individual?.familyName}
+ {displayNameWithLatin(individual, 'familyName')}
diff --git a/src/frontend/src/components/rdi/details/individual/RegistrationIndividualBioData/RegistrationIndividualBioData.tsx b/src/frontend/src/components/rdi/details/individual/RegistrationIndividualBioData/RegistrationIndividualBioData.tsx
index ad404f7d88e..a16da9dbf54 100644
--- a/src/frontend/src/components/rdi/details/individual/RegistrationIndividualBioData/RegistrationIndividualBioData.tsx
+++ b/src/frontend/src/components/rdi/details/individual/RegistrationIndividualBioData/RegistrationIndividualBioData.tsx
@@ -8,6 +8,7 @@ import { IndividualChoices } from '@restgenerated/models/IndividualChoices';
import { IndividualDetail } from '@restgenerated/models/IndividualDetail';
import {
choicesToDict,
+ displayNameWithLatin,
formatAge,
getPhoneNoLabel,
renderBoolean,
@@ -117,22 +118,22 @@ export function RegistrationIndividualBioData({
- {individual.fullName}
+ {displayNameWithLatin(individual, 'fullName')}
- {individual.givenName}
+ {displayNameWithLatin(individual, 'givenName')}
- {individual.middleName}
+ {displayNameWithLatin(individual, 'middleName')}
- {individual.familyName}
+ {displayNameWithLatin(individual, 'familyName')}
diff --git a/src/frontend/src/containers/pages/grievances/CreateGrievancePage.tsx b/src/frontend/src/containers/pages/grievances/CreateGrievancePage.tsx
index 76ee5f29090..e825fe1af9d 100644
--- a/src/frontend/src/containers/pages/grievances/CreateGrievancePage.tsx
+++ b/src/frontend/src/containers/pages/grievances/CreateGrievancePage.tsx
@@ -323,6 +323,7 @@ const CreateGrievancePage = (): ReactElement => {
linkedFeedbackId: linkedFeedbackId || null,
documentation: [],
individualDataUpdateFields: [{ fieldName: null, fieldValue: null }],
+ transliterateLatinNames: true,
roles: [],
};
diff --git a/src/frontend/src/containers/tables/Communication/RecipientsTable/RecipientsTableRow.tsx b/src/frontend/src/containers/tables/Communication/RecipientsTable/RecipientsTableRow.tsx
index 064550eeb12..cef5cc9c124 100644
--- a/src/frontend/src/containers/tables/Communication/RecipientsTable/RecipientsTableRow.tsx
+++ b/src/frontend/src/containers/tables/Communication/RecipientsTable/RecipientsTableRow.tsx
@@ -7,7 +7,7 @@ import { useBaseUrl } from '@hooks/useBaseUrl';
import TableCell from '@mui/material/TableCell';
import { HeadOfHousehold } from '@restgenerated/models/HeadOfHousehold';
import { Recipient } from '@restgenerated/models/Recipient';
-import { householdStatusToColor } from '@utils/utils';
+import { displayNameWithLatin, householdStatusToColor } from '@utils/utils';
import { ReactElement } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -45,7 +45,7 @@ export const RecipientsTableRow = ({
statusToColor={householdStatusToColor}
/>
- {headOfHousehold.fullName}
+ {displayNameWithLatin(headOfHousehold, 'fullName')}
{household.size}
{household.admin2?.name || '-'}
{household.residenceStatus}
diff --git a/src/frontend/src/containers/tables/Surveys/RecipientsTable/RecipientsTableRow.tsx b/src/frontend/src/containers/tables/Surveys/RecipientsTable/RecipientsTableRow.tsx
index 373ab40d9d7..7353088b2c9 100644
--- a/src/frontend/src/containers/tables/Surveys/RecipientsTable/RecipientsTableRow.tsx
+++ b/src/frontend/src/containers/tables/Surveys/RecipientsTable/RecipientsTableRow.tsx
@@ -7,7 +7,7 @@ import { useBaseUrl } from '@hooks/useBaseUrl';
import TableCell from '@mui/material/TableCell';
import { HeadOfHousehold } from '@restgenerated/models/HeadOfHousehold';
import { Recipient } from '@restgenerated/models/Recipient';
-import { householdStatusToColor } from '@utils/utils';
+import { displayNameWithLatin, householdStatusToColor } from '@utils/utils';
import { ReactElement } from 'react';
import { useNavigate } from 'react-router-dom';
@@ -46,7 +46,7 @@ export const RecipientsTableRow = ({
statusToColor={householdStatusToColor}
/>
- {headOfHousehold.fullName}
+ {displayNameWithLatin(headOfHousehold, 'fullName')}
{household.size}
{household.admin2?.name || '-'}
{household.residenceStatus}
diff --git a/src/frontend/src/containers/tables/paymentmodule/PaymentsTable/PaymentsTableRow.tsx b/src/frontend/src/containers/tables/paymentmodule/PaymentsTable/PaymentsTableRow.tsx
index ac3c5df3450..b42545b5e57 100644
--- a/src/frontend/src/containers/tables/paymentmodule/PaymentsTable/PaymentsTableRow.tsx
+++ b/src/frontend/src/containers/tables/paymentmodule/PaymentsTable/PaymentsTableRow.tsx
@@ -8,6 +8,7 @@ import ErrorOutlineRoundedIcon from '@mui/icons-material/ErrorOutlineRounded';
import TableCell from '@mui/material/TableCell';
import { PaymentList } from '@restgenerated/models/PaymentList';
import {
+ displayNameWithLatin,
formatCurrencyWithSymbol,
opacityToHex,
paymentStatusDisplayMap,
@@ -152,10 +153,10 @@ export function PaymentsTableRow({
{canViewDetails ? (
- {payment.peopleIndividual?.fullName}
+ {displayNameWithLatin(payment.peopleIndividual, 'fullName')}
) : (
- payment.peopleIndividual?.fullName
+ displayNameWithLatin(payment.peopleIndividual, 'fullName')
)}
>
diff --git a/src/frontend/src/containers/tables/people/PeopleListTable/PeopleListTableRow.tsx b/src/frontend/src/containers/tables/people/PeopleListTable/PeopleListTableRow.tsx
index 44c104cdf8b..5aa21c79d56 100644
--- a/src/frontend/src/containers/tables/people/PeopleListTable/PeopleListTableRow.tsx
+++ b/src/frontend/src/containers/tables/people/PeopleListTable/PeopleListTableRow.tsx
@@ -7,7 +7,7 @@ import { useBaseUrl } from '@hooks/useBaseUrl';
import TableCell from '@mui/material/TableCell';
import { IndividualList } from '@restgenerated/models/IndividualList';
import { RelationshipEnum } from '@restgenerated/models/RelationshipEnum';
-import { individualStatusToColor, sexToCapitalize } from '@utils/utils';
+import { displayNameWithLatin, individualStatusToColor, sexToCapitalize } from '@utils/utils';
import { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
@@ -44,7 +44,7 @@ export const PeopleListTableRow = ({
{individual.unicefId}
- {individual.fullName}
+ {displayNameWithLatin(individual, 'fullName')}
handleClick(row)}
role="checkbox"
key={row.id}
+ data-cy="collectors-row"
>
{roleChoicesDict[row.role]}
- {row.fullName}
+
+ {displayNameWithLatin(row, 'fullName')}
+
{renderRelationship()}
);
diff --git a/src/frontend/src/containers/tables/population/HouseholdMembersTable/HouseholdMembersTable.tsx b/src/frontend/src/containers/tables/population/HouseholdMembersTable/HouseholdMembersTable.tsx
index 10e9f83bad3..fe21dd58923 100644
--- a/src/frontend/src/containers/tables/population/HouseholdMembersTable/HouseholdMembersTable.tsx
+++ b/src/frontend/src/containers/tables/population/HouseholdMembersTable/HouseholdMembersTable.tsx
@@ -18,6 +18,7 @@ import { useQuery } from '@tanstack/react-query';
import {
adjustHeadCells,
choicesToDict,
+ displayNameWithLatin,
populationStatusToColor,
sexToCapitalize,
} from '@utils/utils';
@@ -151,6 +152,7 @@ export const HouseholdMembersTable = ({
onClick={() => handleClick(row)}
role="checkbox"
key={row.id}
+ data-cy="household-members-row"
>
{renderTableCellContent(
@@ -160,7 +162,7 @@ export const HouseholdMembersTable = ({
)}
- {renderTableCellContent(row.fullName)}
+ {renderTableCellContent(displayNameWithLatin(row, 'fullName'))}
{individual.unicefId}
- {individual.fullName}
+ {displayNameWithLatin(individual, 'fullName')}
{individual.household ? individual.household.unicefId : ''}
diff --git a/src/frontend/src/containers/tables/rdi/ImportedIndividualsTable/ImportedIndividualsTableRow.tsx b/src/frontend/src/containers/tables/rdi/ImportedIndividualsTable/ImportedIndividualsTableRow.tsx
index 245e96d8b59..9cfffa36749 100644
--- a/src/frontend/src/containers/tables/rdi/ImportedIndividualsTable/ImportedIndividualsTableRow.tsx
+++ b/src/frontend/src/containers/tables/rdi/ImportedIndividualsTable/ImportedIndividualsTableRow.tsx
@@ -5,7 +5,7 @@ import { AnonTableCell } from '@components/core/Table/AnonTableCell';
import { ClickableTableRow } from '@components/core/Table/ClickableTableRow';
import { UniversalMoment } from '@components/core/UniversalMoment';
import { useBaseUrl } from '@hooks/useBaseUrl';
-import { sexToCapitalize } from '@utils/utils';
+import { displayNameWithLatin, sexToCapitalize } from '@utils/utils';
import { ReactElement } from 'react';
import { DedupeBiographicalBiometricResults } from '@components/rdi/details/DedupeBiographicalBiometricResults';
import { IndividualList } from '@restgenerated/models/IndividualList';
@@ -96,7 +96,7 @@ export function ImportedIndividualsTableRow({
{individual.unicefId}
- {individual.fullName}
+ {displayNameWithLatin(individual, 'fullName')}
{individual.role}
{individual.relationshipDisplay}
diff --git a/src/frontend/src/containers/tables/rdi/ImportedPeopleTable/ImportedPeopleTableRow.tsx b/src/frontend/src/containers/tables/rdi/ImportedPeopleTable/ImportedPeopleTableRow.tsx
index e16fb4160f6..29a3a96ec39 100644
--- a/src/frontend/src/containers/tables/rdi/ImportedPeopleTable/ImportedPeopleTableRow.tsx
+++ b/src/frontend/src/containers/tables/rdi/ImportedPeopleTable/ImportedPeopleTableRow.tsx
@@ -5,7 +5,7 @@ import { AnonTableCell } from '@components/core/Table/AnonTableCell';
import { ClickableTableRow } from '@components/core/Table/ClickableTableRow';
import { UniversalMoment } from '@components/core/UniversalMoment';
import { useBaseUrl } from '@hooks/useBaseUrl';
-import { choicesToDict, sexToCapitalize } from '@utils/utils';
+import { choicesToDict, displayNameWithLatin, sexToCapitalize } from '@utils/utils';
import { ReactElement } from 'react';
import { DedupeBiographicalBiometricResults } from '@components/rdi/details/DedupeBiographicalBiometricResults';
import { IndividualList } from '@restgenerated/models/IndividualList';
@@ -81,7 +81,7 @@ export function ImportedPeopleTableRow({
{individual.unicefId}
- {individual.fullName}
+ {displayNameWithLatin(individual, 'fullName')}
{roleChoicesDict[individual.role]}
{relationshipChoicesDict[individual.relationship]}
diff --git a/src/frontend/src/containers/tables/targeting/TargetPopulationHouseholdTable/TargetPopulationHouseholdRow.tsx b/src/frontend/src/containers/tables/targeting/TargetPopulationHouseholdTable/TargetPopulationHouseholdRow.tsx
index 943cb181b10..26db4e9aba7 100644
--- a/src/frontend/src/containers/tables/targeting/TargetPopulationHouseholdTable/TargetPopulationHouseholdRow.tsx
+++ b/src/frontend/src/containers/tables/targeting/TargetPopulationHouseholdTable/TargetPopulationHouseholdRow.tsx
@@ -5,6 +5,7 @@ import { BlackLink } from '@components/core/BlackLink';
import { useBaseUrl } from '@hooks/useBaseUrl';
import { ReactElement } from 'react';
import { PendingPayment } from '@restgenerated/models/PendingPayment';
+import { displayNameWithLatin } from '@utils/utils';
interface TargetPopulationHouseholdTableRowProps {
payment: PendingPayment;
@@ -41,7 +42,7 @@ export function TargetPopulationHouseholdTableRow({
payment.householdUnicefId
)}
- {payment.headOfHousehold.fullName || '-'}
+ {displayNameWithLatin(payment.headOfHousehold, 'fullName') || '-'}
{payment.householdSize}
{payment.householdAdmin2 || '-'}
diff --git a/src/frontend/src/containers/tables/targeting/TargetPopulationPeopleTable/TargetPopulationPeopleRow.tsx b/src/frontend/src/containers/tables/targeting/TargetPopulationPeopleTable/TargetPopulationPeopleRow.tsx
index 786699bf2f2..0dc0df39c55 100644
--- a/src/frontend/src/containers/tables/targeting/TargetPopulationPeopleTable/TargetPopulationPeopleRow.tsx
+++ b/src/frontend/src/containers/tables/targeting/TargetPopulationPeopleTable/TargetPopulationPeopleRow.tsx
@@ -5,6 +5,7 @@ import { BlackLink } from '@components/core/BlackLink';
import { useBaseUrl } from '@hooks/useBaseUrl';
import { ReactElement } from 'react';
import { PendingPayment } from '@restgenerated/models/PendingPayment';
+import { displayNameWithLatin } from '@utils/utils';
interface TargetPopulationPeopleTableRowProps {
payment: PendingPayment;
@@ -41,7 +42,7 @@ export function TargetPopulationPeopleTableRow({
payment?.headOfHousehold.unicefId
)}
- {payment?.headOfHousehold?.fullName || '-'}
+ {displayNameWithLatin(payment?.headOfHousehold, 'fullName') || '-'}
{payment?.householdAdmin2 || '-'}
{payment?.householdSize != null ? payment.householdSize : '-'}
diff --git a/src/frontend/src/utils/displayNameWithLatin.test.tsx b/src/frontend/src/utils/displayNameWithLatin.test.tsx
new file mode 100644
index 00000000000..237a52777da
--- /dev/null
+++ b/src/frontend/src/utils/displayNameWithLatin.test.tsx
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { render } from '@testing-library/react';
+import { displayNameWithLatin } from './utils';
+
+describe('displayNameWithLatin', () => {
+ it('returns null without an object or a name', () => {
+ expect(displayNameWithLatin(null, 'fullName')).toBeNull();
+ expect(
+ displayNameWithLatin({ fullNameLatin: 'Ivan' }, 'fullName'),
+ ).toBeNull();
+ });
+
+ it('returns the plain name when there is no latin twin', () => {
+ expect(displayNameWithLatin({ fullName: 'Ivan' }, 'fullName')).toBe('Ivan');
+ });
+
+ it('renders the latin twin under the name', () => {
+ const { container } = render(
+ <>
+ {displayNameWithLatin(
+ { fullName: 'Іван', fullNameLatin: 'Ivan' },
+ 'fullName',
+ )}
+ >,
+ );
+
+ expect(container.textContent).toBe('ІванIvan');
+ });
+});
diff --git a/src/frontend/src/utils/en.json b/src/frontend/src/utils/en.json
index 5530be0a9fc..6f74e319c87 100644
--- a/src/frontend/src/utils/en.json
+++ b/src/frontend/src/utils/en.json
@@ -955,5 +955,7 @@
"Sex": "Sex",
"Phone": "Phone",
"Withdraw": "Withdraw",
- "Not Duplicates": "Not Duplicates"
+ "Not Duplicates": "Not Duplicates",
+ "Transliterate Latin names automatically": "Transliterate Latin names automatically",
+ "Latin names will be transliterated automatically": "Latin names will be transliterated automatically"
}
diff --git a/src/frontend/src/utils/utils.ts b/src/frontend/src/utils/utils.tsx
similarity index 98%
rename from src/frontend/src/utils/utils.ts
rename to src/frontend/src/utils/utils.tsx
index 445785adf8d..3f49c0d0351 100644
--- a/src/frontend/src/utils/utils.ts
+++ b/src/frontend/src/utils/utils.tsx
@@ -1,4 +1,5 @@
import { HeadCell } from '@core/Table/EnhancedTableHead';
+import { Box, Typography } from '@mui/material';
import { PaymentPlanBackgroundActionStatusEnum } from '@restgenerated/models/PaymentPlanBackgroundActionStatusEnum';
import { BuildStatusEnum } from '@restgenerated/models/BuildStatusEnum';
import { DeduplicationEngineStatusEnum } from '@restgenerated/models/DeduplicationEngineStatusEnum';
@@ -11,10 +12,36 @@ import { ProgramStatusEnum } from '@restgenerated/models/ProgramStatusEnum';
import { RegistrationDataImportStatusEnum } from '@restgenerated/models/RegistrationDataImportStatusEnum';
import _, { camelCase, startCase } from 'lodash';
import moment from 'moment';
+import { ReactElement } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { theme as themeObj } from '../theme';
import { GRIEVANCE_CATEGORIES, PAYMENT_PLAN_STATES } from './constants';
+export function displayNameWithLatin(
+ obj: Record | null | undefined,
+ key: string,
+): string | ReactElement | null {
+ if (!obj) return null;
+ const name = obj[key];
+ if (!name) return null;
+ const latinName = obj[`${key}Latin`];
+ if (!latinName) return name;
+ return (
+
+ {name}
+
+ {latinName}
+
+
+ );
+}
const NEWLINE_RE = /[\r\n]/g;
// Formats a string or array value to Normal Case using lodash's startCase
diff --git a/src/hope/api/endpoints/rdi/lax.py b/src/hope/api/endpoints/rdi/lax.py
index d785060a948..2bd4c00bf1f 100644
--- a/src/hope/api/endpoints/rdi/lax.py
+++ b/src/hope/api/endpoints/rdi/lax.py
@@ -397,6 +397,7 @@ def _prepare_individual(
self.staging.saved_file_fields.append(ind.disability_certificate_picture)
calculate_phone_numbers_validity(ind)
+ ind.set_names_latin()
self.staging.valid_individuals.append(ind)
self.staging.individual_external_ids_by_pk[str(ind.id)] = external_individual_id
diff --git a/src/hope/api/endpoints/rdi/mixin.py b/src/hope/api/endpoints/rdi/mixin.py
index bb7235bf1a4..407e86b5d4a 100644
--- a/src/hope/api/endpoints/rdi/mixin.py
+++ b/src/hope/api/endpoints/rdi/mixin.py
@@ -124,7 +124,7 @@ def save_member(self, rdi: RegistrationDataImport, hh: PendingHousehold, member_
raise ValueError("RDI program must not be None")
member_data["flex_fields"] = populate_pdu_with_null_values(program, member_data.get("flex_fields"))
role = member_data.pop("role", None)
- ind = PendingIndividual.objects.create(
+ ind = PendingIndividual(
household=member_of,
program=rdi.program,
registration_data_import=rdi,
@@ -132,6 +132,8 @@ def save_member(self, rdi: RegistrationDataImport, hh: PendingHousehold, member_
photo=photo,
**member_data,
)
+ ind.set_names_latin()
+ ind.save()
for doc in documents:
doc["photo"] = self.get_photo(doc.pop("image", None))
self.save_document(ind, doc)
diff --git a/src/hope/api/endpoints/rdi/push_people.py b/src/hope/api/endpoints/rdi/push_people.py
index ea455954e27..5f2050136b7 100644
--- a/src/hope/api/endpoints/rdi/push_people.py
+++ b/src/hope/api/endpoints/rdi/push_people.py
@@ -33,6 +33,7 @@
RESIDENCE_STATUS_CHOICE,
ROLE_PRIMARY,
)
+from hope.apps.household.utils import NAME_TO_LATIN_FIELDS
from hope.apps.periodic_data_update.utils import populate_pdu_with_null_values
from hope.models import (
Area,
@@ -211,7 +212,8 @@ def _create_individual(
**individual_data,
)
ind.validate_phone_numbers()
- ind.save(update_fields=("phone_no_valid", "phone_no_alternative_valid"))
+ ind.set_names_latin()
+ ind.save(update_fields=("phone_no_valid", "phone_no_alternative_valid", *NAME_TO_LATIN_FIELDS.values()))
if person_type is not NON_BENEFICIARY:
hh.head_of_household = ind
diff --git a/src/hope/apps/core/field_attributes/core_fields_attributes.py b/src/hope/apps/core/field_attributes/core_fields_attributes.py
index 7dc558557c1..f25deccd1e9 100644
--- a/src/hope/apps/core/field_attributes/core_fields_attributes.py
+++ b/src/hope/apps/core/field_attributes/core_fields_attributes.py
@@ -461,6 +461,26 @@
Scope.PEOPLE_UPDATE,
],
},
+ {
+ "id": "248b358f-ea96-4794-941b-986af9d8ad5e",
+ "type": TYPE_STRING,
+ "name": "full_name_latin",
+ "lookup": "full_name_latin",
+ "required": False,
+ "label": {"English(EN)": "Full name latin"},
+ "hint": "",
+ "choices": [],
+ "associated_with": _INDIVIDUAL,
+ "xlsx_field": "full_name_latin_i_c",
+ "scope": [
+ Scope.GLOBAL,
+ Scope.TARGETING,
+ Scope.KOBO_IMPORT,
+ Scope.INDIVIDUAL_UPDATE,
+ Scope.XLSX_PEOPLE,
+ Scope.PEOPLE_UPDATE,
+ ],
+ },
{
"id": "b1f90314-b8b8-4bcb-9265-9d48d1fce5a4",
"type": TYPE_STRING,
@@ -481,6 +501,26 @@
Scope.PEOPLE_UPDATE,
],
},
+ {
+ "id": "ee311eb5-c0af-4d19-a74e-7ad504260034",
+ "type": TYPE_STRING,
+ "name": "given_name_latin",
+ "lookup": "given_name_latin",
+ "required": False,
+ "label": {"English(EN)": "Given name latin"},
+ "hint": "",
+ "choices": [],
+ "associated_with": _INDIVIDUAL,
+ "xlsx_field": "given_name_latin_i_c",
+ "scope": [
+ Scope.GLOBAL,
+ Scope.TARGETING,
+ Scope.KOBO_IMPORT,
+ Scope.INDIVIDUAL_UPDATE,
+ Scope.XLSX_PEOPLE,
+ Scope.PEOPLE_UPDATE,
+ ],
+ },
{
"id": "6f603107-bd88-4a8d-97cc-748a7238358d",
"type": TYPE_STRING,
@@ -501,6 +541,26 @@
Scope.PEOPLE_UPDATE,
],
},
+ {
+ "id": "f307f3ad-01ed-4465-87bf-b4952c85bdc0",
+ "type": TYPE_STRING,
+ "name": "middle_name_latin",
+ "lookup": "middle_name_latin",
+ "required": False,
+ "label": {"English(EN)": "Middle name(s) latin"},
+ "hint": "",
+ "choices": [],
+ "associated_with": _INDIVIDUAL,
+ "xlsx_field": "middle_name_latin_i_c",
+ "scope": [
+ Scope.GLOBAL,
+ Scope.TARGETING,
+ Scope.KOBO_IMPORT,
+ Scope.INDIVIDUAL_UPDATE,
+ Scope.XLSX_PEOPLE,
+ Scope.PEOPLE_UPDATE,
+ ],
+ },
{
"id": "3f74dd36-bfd2-4c84-bfc7-21f7adbff7f0",
"type": TYPE_STRING,
@@ -521,6 +581,26 @@
Scope.PEOPLE_UPDATE,
],
},
+ {
+ "id": "a88bcf60-4b3e-4bcb-bef5-7c8eb478e5e8",
+ "type": TYPE_STRING,
+ "name": "family_name_latin",
+ "lookup": "family_name_latin",
+ "required": False,
+ "label": {"English(EN)": "Family name latin"},
+ "hint": "",
+ "choices": [],
+ "associated_with": _INDIVIDUAL,
+ "xlsx_field": "family_name_latin_i_c",
+ "scope": [
+ Scope.GLOBAL,
+ Scope.TARGETING,
+ Scope.KOBO_IMPORT,
+ Scope.INDIVIDUAL_UPDATE,
+ Scope.XLSX_PEOPLE,
+ Scope.PEOPLE_UPDATE,
+ ],
+ },
{
"id": "da726870-dfc9-48dc-aba9-b9138b611c74",
"type": TYPE_SELECT_ONE,
diff --git a/src/hope/apps/grievance/api/serializers/grievance_ticket.py b/src/hope/apps/grievance/api/serializers/grievance_ticket.py
index aa606792b2b..ef4f4066b3e 100644
--- a/src/hope/apps/grievance/api/serializers/grievance_ticket.py
+++ b/src/hope/apps/grievance/api/serializers/grievance_ticket.py
@@ -21,6 +21,7 @@
IndividualSimpleSerializer,
)
from hope.apps.household.const import ROLE_CHOICE
+from hope.apps.household.utils import NAME_TO_LATIN_FIELDS
from hope.apps.payment.api.serializers import PaymentSmallSerializer
from hope.apps.program.api.serializers import ProgramSmallSerializer
from hope.models import (
@@ -425,11 +426,23 @@ def validate_roles(value: list[dict[str, str]]) -> list[dict[str, str]]:
return value
+def _validate_latin_names_provided(attrs: dict) -> None:
+ if attrs.get("transliterate_latin_names"):
+ return
+ missing = [latin for name, latin in NAME_TO_LATIN_FIELDS.items() if attrs.get(name) and not attrs.get(latin)]
+ if missing:
+ raise serializers.ValidationError(f"Provide {', '.join(missing)} or set transliterate_latin_names to true.")
+
+
class AddIndividualDataSerializer(serializers.Serializer):
full_name = serializers.CharField()
given_name = serializers.CharField(required=False)
middle_name = serializers.CharField(required=False)
family_name = serializers.CharField(required=False)
+ full_name_latin = serializers.CharField(required=False)
+ given_name_latin = serializers.CharField(required=False)
+ middle_name_latin = serializers.CharField(required=False)
+ family_name_latin = serializers.CharField(required=False)
sex = serializers.CharField()
birth_date = serializers.DateField()
estimated_birth_date = serializers.BooleanField()
@@ -459,6 +472,15 @@ class AddIndividualDataSerializer(serializers.Serializer):
flex_fields = serializers.JSONField(required=False)
payment_delivery_phone_no = serializers.CharField(required=False)
photo = serializers.ImageField(required=False, allow_null=True)
+ transliterate_latin_names = serializers.BooleanField(
+ required=False,
+ default=False,
+ help_text="Fill missing *_latin name fields automatically by transliteration",
+ )
+
+ def validate(self, attrs: dict) -> dict:
+ _validate_latin_names_provided(attrs)
+ return attrs
class IndividualUpdateDataSerializer(serializers.Serializer):
@@ -467,6 +489,10 @@ class IndividualUpdateDataSerializer(serializers.Serializer):
given_name = serializers.CharField(required=False)
middle_name = serializers.CharField(required=False)
family_name = serializers.CharField(required=False)
+ full_name_latin = serializers.CharField(required=False)
+ given_name_latin = serializers.CharField(required=False)
+ middle_name_latin = serializers.CharField(required=False)
+ family_name_latin = serializers.CharField(required=False)
sex = serializers.CharField(required=False)
birth_date = serializers.DateField(required=False)
estimated_birth_date = serializers.BooleanField(required=False)
@@ -520,6 +546,15 @@ class IndividualUpdateDataSerializer(serializers.Serializer):
org_name_enumerator = serializers.CharField(required=False, help_text="People update")
registration_method = serializers.CharField(required=False, help_text="People update")
admin_area_title = serializers.CharField(required=False, help_text="People update")
+ transliterate_latin_names = serializers.BooleanField(
+ required=False,
+ default=False,
+ help_text="Fill missing *_latin name fields automatically by transliteration",
+ )
+
+ def validate(self, attrs: dict) -> dict:
+ _validate_latin_names_provided(attrs)
+ return attrs
class PositiveFeedbackTicketExtras(serializers.Serializer):
diff --git a/src/hope/apps/grievance/api/serializers/ticket_detail.py b/src/hope/apps/grievance/api/serializers/ticket_detail.py
index aa20ef876fa..ab061d74f3c 100644
--- a/src/hope/apps/grievance/api/serializers/ticket_detail.py
+++ b/src/hope/apps/grievance/api/serializers/ticket_detail.py
@@ -255,6 +255,7 @@ class Meta:
"unicef_id",
"household",
"full_name",
+ "full_name_latin",
"birth_date",
"last_registration_date",
"sex",
@@ -283,6 +284,7 @@ class Meta:
"unicef_id",
"household",
"full_name",
+ "full_name_latin",
"given_name",
"family_name",
"phone_no",
diff --git a/src/hope/apps/grievance/filters.py b/src/hope/apps/grievance/filters.py
index 01e3513e3e7..63cd7441633 100644
--- a/src/hope/apps/grievance/filters.py
+++ b/src/hope/apps/grievance/filters.py
@@ -202,6 +202,7 @@ def search_filter(self, qs: QuerySet, name: str, value: str) -> QuerySet:
Individual.objects.filter(business_area=self.business_area, relationship=HEAD)
.filter(
Q(full_name__icontains=search)
+ | Q(full_name_latin__icontains=search)
| Q(detail_id__icontains=search)
| Q(program_registration_id__icontains=search)
| Q(phone_no__icontains=search)
@@ -328,6 +329,10 @@ def filter_by_individual_for_office_search(self, queryset: QuerySet, value: str)
"given_name",
"middle_name",
"family_name",
+ "full_name_latin",
+ "given_name_latin",
+ "middle_name_latin",
+ "family_name_latin",
]
# Search in ticket type lookups
diff --git a/src/hope/apps/grievance/services/data_change/add_individual_service.py b/src/hope/apps/grievance/services/data_change/add_individual_service.py
index 7ec3fe28415..eac947692ba 100644
--- a/src/hope/apps/grievance/services/data_change/add_individual_service.py
+++ b/src/hope/apps/grievance/services/data_change/add_individual_service.py
@@ -100,6 +100,7 @@ def close(self, user: AbstractUser) -> None:
details = self.grievance_ticket.add_individual_ticket_details
household = Household.objects.select_for_update().get(id=details.household.id)
individual_data = details.individual_data
+ individual_data.pop("transliterate_latin_names", None) # UX flag, not a model field
documents = individual_data.pop("documents", [])
identities = individual_data.pop("identities", [])
individual_data["flex_fields"] = populate_pdu_with_null_values( # type: ignore[index]
@@ -117,6 +118,7 @@ def close(self, user: AbstractUser) -> None:
**individual_data, # type: ignore[arg-type]
)
individual.refresh_from_db()
+ individual.set_names_latin()
documents_to_create = [handle_add_document(document, individual) for document in documents]
identities_to_create = [handle_add_identity(identity, individual) for identity in identities]
relationship_to_head_of_household = individual_data.get("relationship")
diff --git a/src/hope/apps/grievance/services/data_change/individual_data_update_service.py b/src/hope/apps/grievance/services/data_change/individual_data_update_service.py
index 3730d66eb10..4de821ddae4 100644
--- a/src/hope/apps/grievance/services/data_change/individual_data_update_service.py
+++ b/src/hope/apps/grievance/services/data_change/individual_data_update_service.py
@@ -49,6 +49,7 @@
from hope.apps.household.const import HEAD
from hope.apps.household.services.household_recalculate_data import recalculate_data
from hope.apps.household.services.locking import lock_household_then_individual
+from hope.apps.household.utils import NAME_TO_LATIN_FIELDS
from hope.apps.utils.phone import is_valid_phone_number
from hope.models import Account, Area, Country, Document, Household, Individual, IndividualIdentity, log_create
from hope.models.currency import Currency
@@ -276,6 +277,22 @@ def _process_accounts(
Account.validate_uniqueness(accounts_to_update) # type: ignore
Account.validate_uniqueness(accounts_to_create)
+ def _refresh_latin_names(self, individual: Individual, only_approved_data: dict) -> None:
+ # recompute stale latin twins of changed names; explicitly approved latin wins
+ changed = [field for field in NAME_TO_LATIN_FIELDS if field in only_approved_data]
+ if not changed:
+ return
+ for field in changed:
+ setattr(individual, field, only_approved_data[field])
+ latin_field = NAME_TO_LATIN_FIELDS[field]
+ if latin_field not in only_approved_data:
+ setattr(individual, latin_field, None)
+ individual.set_names_latin()
+ for field in changed:
+ latin_field = NAME_TO_LATIN_FIELDS[field]
+ if latin_field not in only_approved_data:
+ only_approved_data[latin_field] = getattr(individual, latin_field)
+
def _update_household_fields(self, household: Household, only_approved_data: dict) -> None:
hh_fields = [
"consent",
@@ -342,7 +359,9 @@ def close(self, user: AbstractUser) -> None:
only_approved_data = {
field: convert_to_empty_string_if_null(value_and_approve_status.get("value"))
for field, value_and_approve_status in individual_data.items()
- if is_approved(value_and_approve_status) and field != "previous_documents"
+ if is_approved(value_and_approve_status)
+ # not model fields: bookkeeping + the UX transliteration flag
+ and field not in ("previous_documents", "transliterate_latin_names")
}
old_individual = copy_model_object(individual)
merged_flex_fields = {}
@@ -354,6 +373,7 @@ def close(self, user: AbstractUser) -> None:
self._validate_phone_numbers(only_approved_data)
self._update_household_fields(household, only_approved_data) # type: ignore[arg-type]
+ self._refresh_latin_names(new_individual, only_approved_data)
# upd Individual
Individual.objects.filter(id=new_individual.id).update(
diff --git a/src/hope/apps/household/api/serializers/household.py b/src/hope/apps/household/api/serializers/household.py
index f6970ca36f7..37a29aa9675 100644
--- a/src/hope/apps/household/api/serializers/household.py
+++ b/src/hope/apps/household/api/serializers/household.py
@@ -92,6 +92,7 @@ class Meta:
fields = (
"id",
"full_name",
+ "full_name_latin",
)
@@ -165,6 +166,7 @@ class Meta:
"id",
"unicef_id",
"full_name",
+ "full_name_latin",
"role",
"relationship",
"status",
diff --git a/src/hope/apps/household/api/serializers/individual.py b/src/hope/apps/household/api/serializers/individual.py
index 3abc9c91d82..31373f447b3 100644
--- a/src/hope/apps/household/api/serializers/individual.py
+++ b/src/hope/apps/household/api/serializers/individual.py
@@ -96,6 +96,7 @@ class Meta:
"id",
"unicef_id",
"full_name",
+ "full_name_latin",
)
@@ -142,6 +143,7 @@ class Meta:
"id",
"unicef_id",
"full_name",
+ "full_name_latin",
"household",
"roles_in_households",
"relationship",
@@ -260,6 +262,7 @@ class Meta:
"id",
"unicef_id",
"full_name",
+ "full_name_latin",
"household",
"status",
"relationship",
@@ -348,6 +351,10 @@ class Meta:
"given_name",
"middle_name",
"family_name",
+ "full_name_latin",
+ "given_name_latin",
+ "middle_name_latin",
+ "family_name_latin",
"sex",
"age",
"birth_date",
@@ -464,6 +471,7 @@ class Meta:
"unicef_id",
"household",
"full_name",
+ "full_name_latin",
"birth_date",
"last_registration_date",
"sex",
diff --git a/src/hope/apps/household/documents.py b/src/hope/apps/household/documents.py
index 452a21f9b03..aa32db8db1a 100644
--- a/src/hope/apps/household/documents.py
+++ b/src/hope/apps/household/documents.py
@@ -51,6 +51,7 @@ class IndividualDocument(_PreparedFieldsFix, Document):
middle_name = fields.TextField(analyzer=phonetic_analyzer)
family_name = fields.TextField(fields={"phonetic": fields.TextField(analyzer=phonetic_analyzer)})
full_name = fields.TextField(analyzer=phonetic_analyzer)
+ full_name_latin = fields.TextField(analyzer=phonetic_analyzer)
birth_date = fields.DateField() # Before es 8, similarity parameter on DateField failed silently
phone_no = fields.KeywordField("phone_no.__str__", similarity="boolean")
phone_no_alternative = fields.KeywordField("phone_no_alternative.__str__", similarity="boolean")
@@ -139,6 +140,10 @@ class HouseholdDocument(_PreparedFieldsFix, Document):
"given_name": fields.TextField(index_prefixes={"min_chars": 1, "max_chars": 10}),
"middle_name": fields.TextField(index_prefixes={"min_chars": 1, "max_chars": 10}),
"family_name": fields.TextField(index_prefixes={"min_chars": 1, "max_chars": 10}),
+ "full_name_latin": fields.TextField(index_prefixes={"min_chars": 1, "max_chars": 10}),
+ "given_name_latin": fields.TextField(index_prefixes={"min_chars": 1, "max_chars": 10}),
+ "middle_name_latin": fields.TextField(index_prefixes={"min_chars": 1, "max_chars": 10}),
+ "family_name_latin": fields.TextField(index_prefixes={"min_chars": 1, "max_chars": 10}),
"phone_no_text": fields.TextField(index_prefixes={"min_chars": 1, "max_chars": 10}),
"phone_no_alternative_text": fields.TextField(index_prefixes={"min_chars": 1, "max_chars": 10}),
"documents": fields.ObjectField(
diff --git a/src/hope/apps/household/filters.py b/src/hope/apps/household/filters.py
index c8fe8f26490..da35e3439b6 100644
--- a/src/hope/apps/household/filters.py
+++ b/src/hope/apps/household/filters.py
@@ -179,6 +179,17 @@ def _search_es(self, qs: QuerySet, value: Any, program: Program) -> QuerySet:
def _get_elasticsearch_query_for_households(self, search: str, program: Program) -> dict:
business_area = self.request.parser_context["kwargs"]["business_area_slug"]
es_filters = [{"term": {"business_area": business_area}}, {"term": {"program_id": str(program.pk)}}]
+ should = [
+ {"match_phrase_prefix": {"unicef_id": {"query": search}}},
+ {"match_phrase_prefix": {"head_of_household.unicef_id": {"query": search}}},
+ {"match_phrase_prefix": {"head_of_household.full_name": {"query": search}}},
+ {"match_phrase_prefix": {"head_of_household.phone_no_text": {"query": search}}},
+ {"match_phrase_prefix": {"head_of_household.phone_no_alternative_text": {"query": search}}},
+ {"match_phrase_prefix": {"detail_id": {"query": search}}},
+ {"match_phrase_prefix": {"program_registration_id": {"query": search}}},
+ ]
+ if config.ES_USE_LATIN_NAMES: # flag off until the fleet reindex adds the field
+ should.append({"match_phrase_prefix": {"head_of_household.full_name_latin": {"query": search}}})
query: dict[str, Any] = {
"size": "100",
"_source": False,
@@ -186,15 +197,7 @@ def _get_elasticsearch_query_for_households(self, search: str, program: Program)
"bool": {
"minimum_should_match": 1,
"filter": es_filters,
- "should": [
- {"match_phrase_prefix": {"unicef_id": {"query": search}}},
- {"match_phrase_prefix": {"head_of_household.unicef_id": {"query": search}}},
- {"match_phrase_prefix": {"head_of_household.full_name": {"query": search}}},
- {"match_phrase_prefix": {"head_of_household.phone_no_text": {"query": search}}},
- {"match_phrase_prefix": {"head_of_household.phone_no_alternative_text": {"query": search}}},
- {"match_phrase_prefix": {"detail_id": {"query": search}}},
- {"match_phrase_prefix": {"program_registration_id": {"query": search}}},
- ],
+ "should": should,
}
},
}
@@ -220,6 +223,7 @@ def _search_db(self, qs: QuerySet[Household], value: str, program: Program | Non
Q(unicef_id__icontains=search)
| Q(head_of_household__unicef_id__icontains=search)
| Q(head_of_household__full_name__icontains=search)
+ | Q(head_of_household__full_name_latin__icontains=search)
| Q(phone_no_normalized__icontains=search)
| Q(phone_no_alt_normalized__icontains=search)
| Q(detail_id__icontains=search)
@@ -372,6 +376,19 @@ def _search_es(self, qs: QuerySet[Individual], value: str, program: Program) ->
def _get_elasticsearch_query_for_individuals(self, search: str, program: Program) -> dict:
business_area = self.request.parser_context["kwargs"]["business_area_slug"]
es_filters = [{"term": {"business_area": business_area}}, {"term": {"program_id": str(program.pk)}}]
+ should = [
+ {"match_phrase_prefix": {"unicef_id": {"query": search}}},
+ {"match_phrase_prefix": {"household.unicef_id": {"query": search}}},
+ {"match_phrase_prefix": {"full_name": {"query": search}}},
+ {"match_phrase_prefix": {"phone_no_text": {"query": search}}},
+ {"match_phrase_prefix": {"phone_no_alternative_text": {"query": search}}},
+ {"match_phrase_prefix": {"detail_id": {"query": search}}},
+ {"match_phrase_prefix": {"program_registration_id": {"query": search}}},
+ ]
+ # full_name_latin only exists in indexes rebuilt after the mapping change; on the others
+ # the clause silently matches nothing, so it stays off until the fleet reindex is done
+ if config.ES_USE_LATIN_NAMES:
+ should.append({"match_phrase_prefix": {"full_name_latin": {"query": search}}})
return {
"size": 100,
"_source": False,
@@ -379,15 +396,7 @@ def _get_elasticsearch_query_for_individuals(self, search: str, program: Program
"bool": {
"filter": es_filters,
"minimum_should_match": 1,
- "should": [
- {"match_phrase_prefix": {"unicef_id": {"query": search}}},
- {"match_phrase_prefix": {"household.unicef_id": {"query": search}}},
- {"match_phrase_prefix": {"full_name": {"query": search}}},
- {"match_phrase_prefix": {"phone_no_text": {"query": search}}},
- {"match_phrase_prefix": {"phone_no_alternative_text": {"query": search}}},
- {"match_phrase_prefix": {"detail_id": {"query": search}}},
- {"match_phrase_prefix": {"program_registration_id": {"query": search}}},
- ],
+ "should": should,
}
},
}
@@ -414,6 +423,7 @@ def _search_db(self, qs: QuerySet[Individual], value: str, program: Program | No
Q(unicef_id__icontains=search)
| Q(household__unicef_id__icontains=search)
| Q(full_name__icontains=search)
+ | Q(full_name_latin__icontains=search)
| Q(phone_no_normalized__icontains=search)
| Q(phone_no_alt_normalized__icontains=search)
| Q(detail_id__icontains=search)
@@ -556,6 +566,10 @@ def filter_by_individual_for_office_search(self, queryset: QuerySet, value: str)
| Q(individuals__given_name__icontains=value)
| Q(individuals__middle_name__icontains=value)
| Q(individuals__family_name__icontains=value)
+ | Q(individuals__full_name_latin__icontains=value)
+ | Q(individuals__given_name_latin__icontains=value)
+ | Q(individuals__middle_name_latin__icontains=value)
+ | Q(individuals__family_name_latin__icontains=value)
)
return queryset.filter(q_filters).distinct()
@@ -621,6 +635,10 @@ def filter_by_individual_for_office_search(self, queryset: QuerySet, value: str)
| Q(given_name__icontains=value)
| Q(middle_name__icontains=value)
| Q(family_name__icontains=value)
+ | Q(full_name_latin__icontains=value)
+ | Q(given_name_latin__icontains=value)
+ | Q(middle_name_latin__icontains=value)
+ | Q(family_name_latin__icontains=value)
)
return queryset.filter(q_filters).distinct()
diff --git a/src/hope/apps/household/migrations/0048_migration.py b/src/hope/apps/household/migrations/0048_migration.py
new file mode 100644
index 00000000000..ada0d2108d7
--- /dev/null
+++ b/src/hope/apps/household/migrations/0048_migration.py
@@ -0,0 +1,90 @@
+# Generated by Django 5.2.15 on 2026-07-01 10:42
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("household", "0047_migration"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="individual",
+ name="family_name_latin",
+ field=models.CharField(
+ blank=True,
+ db_collation="und-ci-det",
+ db_index=True,
+ help_text="Last name of the Beneficiary Latin",
+ max_length=150,
+ null=True,
+ validators=[
+ django.core.validators.RegexValidator(
+ code="invalid_name",
+ message="Only ASCII letters, spaces, hyphens, and apostrophes are allowed.",
+ regex="^[A-Za-z]+(?:[ '-][A-Za-z]+)*$",
+ )
+ ],
+ ),
+ ),
+ migrations.AddField(
+ model_name="individual",
+ name="full_name_latin",
+ field=models.CharField(
+ blank=True,
+ db_collation="und-ci-det",
+ db_index=True,
+ help_text="Full name of the Beneficiary Latin",
+ max_length=500,
+ null=True,
+ validators=[
+ django.core.validators.MinLengthValidator(2),
+ django.core.validators.RegexValidator(
+ code="invalid_name",
+ message="Only ASCII letters, spaces, hyphens, and apostrophes are allowed.",
+ regex="^[A-Za-z]+(?:[ '-][A-Za-z]+)*$",
+ ),
+ ],
+ ),
+ ),
+ migrations.AddField(
+ model_name="individual",
+ name="given_name_latin",
+ field=models.CharField(
+ blank=True,
+ db_collation="und-ci-det",
+ db_index=True,
+ help_text="First name of the Beneficiary Latin",
+ max_length=150,
+ null=True,
+ validators=[
+ django.core.validators.RegexValidator(
+ code="invalid_name",
+ message="Only ASCII letters, spaces, hyphens, and apostrophes are allowed.",
+ regex="^[A-Za-z]+(?:[ '-][A-Za-z]+)*$",
+ )
+ ],
+ ),
+ ),
+ migrations.AddField(
+ model_name="individual",
+ name="middle_name_latin",
+ field=models.CharField(
+ blank=True,
+ db_collation="und-ci-det",
+ db_index=True,
+ help_text="Middle name of the Beneficiary Latin",
+ max_length=150,
+ null=True,
+ validators=[
+ django.core.validators.RegexValidator(
+ code="invalid_name",
+ message="Only ASCII letters, spaces, hyphens, and apostrophes are allowed.",
+ regex="^[A-Za-z]+(?:[ '-][A-Za-z]+)*$",
+ )
+ ],
+ ),
+ ),
+ ]
diff --git a/src/hope/apps/household/utils.py b/src/hope/apps/household/utils.py
new file mode 100644
index 00000000000..cced2e874cd
--- /dev/null
+++ b/src/hope/apps/household/utils.py
@@ -0,0 +1,32 @@
+import re
+
+from transliterate import translit
+from transliterate.utils import LanguageDetectionError
+from unidecode import unidecode
+
+from hope.models.individual import ascii_name_validator
+
+NAME_TO_LATIN_FIELDS = {
+ "given_name": "given_name_latin",
+ "middle_name": "middle_name_latin",
+ "family_name": "family_name_latin",
+ "full_name": "full_name_latin",
+}
+
+
+def to_latin(text: str | None) -> str | None:
+ if text is None:
+ return None
+
+ try:
+ latin_text = translit(text, reversed=True)
+ except LanguageDetectionError:
+ latin_text = unidecode(text)
+
+ latin_text = re.sub(r"[`’‘]", "'", latin_text)
+ latin_text = re.sub(r"[^a-zA-Z\s\-']", " ", latin_text)
+ latin_text = re.sub(r"\s+", " ", latin_text).strip()
+ latin_text = latin_text.strip(" '-")
+ ascii_name_validator(latin_text)
+
+ return latin_text.title()
diff --git a/src/hope/apps/payment/api/filters.py b/src/hope/apps/payment/api/filters.py
index 7b389f7e7d3..f24a41e876c 100644
--- a/src/hope/apps/payment/api/filters.py
+++ b/src/hope/apps/payment/api/filters.py
@@ -136,6 +136,10 @@ def filter_by_individual_for_office_search(self, queryset: QuerySet, value: str)
| Q(payment_items__head_of_household__given_name__icontains=value)
| Q(payment_items__head_of_household__middle_name__icontains=value)
| Q(payment_items__head_of_household__family_name__icontains=value)
+ | Q(payment_items__head_of_household__full_name_latin__icontains=value)
+ | Q(payment_items__head_of_household__given_name_latin__icontains=value)
+ | Q(payment_items__head_of_household__middle_name_latin__icontains=value)
+ | Q(payment_items__head_of_household__family_name_latin__icontains=value)
)
return queryset.filter(q_filters).distinct()
@@ -169,6 +173,10 @@ def filter_by_individual_for_office_search(self, queryset: QuerySet, value: str)
| Q(head_of_household__given_name__icontains=value)
| Q(head_of_household__middle_name__icontains=value)
| Q(head_of_household__family_name__icontains=value)
+ | Q(head_of_household__full_name_latin__icontains=value)
+ | Q(head_of_household__given_name_latin__icontains=value)
+ | Q(head_of_household__middle_name_latin__icontains=value)
+ | Q(head_of_household__family_name_latin__icontains=value)
)
return queryset.filter(q_filters).distinct()
@@ -285,10 +293,7 @@ def filter(self, qs: QuerySet, value: Any) -> QuerySet:
class PaymentSearchFilter(FilterSet):
- collector_full_name = django_filters.CharFilter(
- field_name="collector__full_name",
- lookup_expr="istartswith",
- )
+ collector_full_name = django_filters.CharFilter(method="filter_collector_full_name")
household_unicef_id = django_filters.CharFilter(
field_name="household__unicef_id",
lookup_expr="istartswith",
@@ -323,6 +328,11 @@ class Meta:
model = Payment
fields = []
+ def filter_collector_full_name(self, queryset: QuerySet, name: str, value: str) -> QuerySet:
+ return queryset.filter(
+ Q(collector__full_name__istartswith=value) | Q(collector__full_name_latin__istartswith=value)
+ )
+
def filter_queryset(self, queryset: QuerySet) -> "QuerySet[Payment]":
queryset = queryset.annotate(
reconciliation_rank=Case(
diff --git a/src/hope/apps/payment/api/serializers.py b/src/hope/apps/payment/api/serializers.py
index 7a208c7ebac..7f496e67108 100644
--- a/src/hope/apps/payment/api/serializers.py
+++ b/src/hope/apps/payment/api/serializers.py
@@ -1319,6 +1319,12 @@ class PaymentListSerializer(serializers.ModelSerializer):
snapshot_alternate_collector_full_name = serializers.SerializerMethodField(
help_text="Get from Snapshot Alternate Collector Full Name"
)
+ snapshot_collector_full_name_latin = serializers.SerializerMethodField(
+ help_text="Get from Household Snapshot Latin Name"
+ )
+ snapshot_alternate_collector_full_name_latin = serializers.SerializerMethodField(
+ help_text="Get from Snapshot Alternate Collector Full Name Latin"
+ )
snapshot_alternate_collector_id = serializers.SerializerMethodField(
help_text="Get from Snapshot Alternate Collector ID"
)
@@ -1361,6 +1367,8 @@ class Meta:
"hoh_phone_no_alternative",
"snapshot_collector_full_name",
"snapshot_alternate_collector_full_name",
+ "snapshot_collector_full_name_latin",
+ "snapshot_alternate_collector_full_name_latin",
"snapshot_alternate_collector_id",
"fsp_name",
"entitlement_quantity",
@@ -1426,6 +1434,15 @@ def get_snapshot_collector_full_name(self, obj: Payment) -> Any:
def get_snapshot_alternate_collector_full_name(self, obj: Payment) -> Any:
return PaymentListSerializer.get_collector_field(obj, "full_name", ROLE_ALTERNATE)
+ def get_snapshot_collector_full_name_latin(self, obj: Payment) -> Any:
+ return PaymentListSerializer.get_collector_field(
+ obj,
+ "full_name_latin",
+ )
+
+ def get_snapshot_alternate_collector_full_name_latin(self, obj: Payment) -> Any:
+ return PaymentListSerializer.get_collector_field(obj, "full_name_latin", ROLE_ALTERNATE)
+
def get_snapshot_alternate_collector_id(self, obj: Payment) -> Any:
return PaymentListSerializer.get_collector_field(obj, "id", ROLE_ALTERNATE)
diff --git a/src/hope/apps/registration_data/tasks/rdi_kobo_create.py b/src/hope/apps/registration_data/tasks/rdi_kobo_create.py
index 932d40acacd..e51b34eeaf4 100644
--- a/src/hope/apps/registration_data/tasks/rdi_kobo_create.py
+++ b/src/hope/apps/registration_data/tasks/rdi_kobo_create.py
@@ -469,6 +469,7 @@ def handle_household( # noqa: PLR0912
if individual_obj.relationship == HEAD:
head_of_households_mapping[household_obj] = individual_obj
individuals_ids_hash_dict[individual_obj.get_hash_key] = individual_obj.id
+ individual_obj.set_names_latin()
individuals_to_create_list.append(individual_obj)
current_individuals.append(individual_obj)
documents_and_identities_to_create.append(current_individual_docs_and_identities)
diff --git a/src/hope/apps/registration_data/tasks/rdi_xlsx_create.py b/src/hope/apps/registration_data/tasks/rdi_xlsx_create.py
index 0efa2cd40df..a4800e80582 100644
--- a/src/hope/apps/registration_data/tasks/rdi_xlsx_create.py
+++ b/src/hope/apps/registration_data/tasks/rdi_xlsx_create.py
@@ -563,6 +563,8 @@ def _finalize_row_object(
)
populate_pdu_with_null_values(self.rdi.program, obj_to_create.flex_fields)
self.handle_pdu_fields(row, first_row, obj_to_create) # type: ignore[arg-type]
+
+ obj_to_create.set_names_latin()
self.individuals.append(obj_to_create)
def _bulk_save_and_finalize(self, households_to_update: list) -> None:
diff --git a/src/hope/apps/registration_data/tasks/rdi_xlsx_people_create.py b/src/hope/apps/registration_data/tasks/rdi_xlsx_people_create.py
index b3816e7da84..361d1d0889c 100644
--- a/src/hope/apps/registration_data/tasks/rdi_xlsx_people_create.py
+++ b/src/hope/apps/registration_data/tasks/rdi_xlsx_people_create.py
@@ -234,6 +234,7 @@ def _post_processing(
household.head_of_household = obj_to_create
self.households_to_update.append(household)
+ obj_to_create.set_names_latin()
self.individuals.append(obj_to_create)
def _process_admin_areas_and_country(
diff --git a/src/hope/apps/registration_data/validators.py b/src/hope/apps/registration_data/validators.py
index 5656cc587bc..e51a1c71f91 100644
--- a/src/hope/apps/registration_data/validators.py
+++ b/src/hope/apps/registration_data/validators.py
@@ -12,6 +12,7 @@
from dateutil import parser
from django.core import validators as django_core_validators
+from django.core.exceptions import ValidationError
import openpyxl
from openpyxl import Workbook, load_workbook
from openpyxl.cell import Cell
@@ -38,12 +39,14 @@
ROLE_ALTERNATE,
ROLE_PRIMARY,
)
+from hope.apps.household.utils import NAME_TO_LATIN_FIELDS
from hope.apps.registration_data.utils import (
calculate_hash_for_kobo_submission,
collectors_str_ids_to_list,
find_attachment_in_kobo,
)
from hope.models import Area, BusinessArea, FlexibleAttribute, KoboImportedSubmission, PeriodicFieldData, Program
+from hope.models.individual import ascii_name_validator
logger = logging.getLogger(__name__)
@@ -325,6 +328,8 @@ def __init__(self, program: Program) -> None:
self.facility_admin_area_header = (
"pp_facility_admin_area_h_c" if self.is_social_worker_program else "facility_admin_area_h_c"
)
+ prefix = "pp_" if self.is_social_worker_program else ""
+ self.latin_name_headers = [f"{prefix}{latin}_i_c" for latin in NAME_TO_LATIN_FIELDS.values()]
def get_combined_fields(self) -> dict:
core_fields = (
@@ -679,11 +684,26 @@ def rows_validator(self, sheet: Worksheet, business_area_slug: str | None = None
f"when '{self.facility_name_header}' is provided.",
}
)
+ if self.sheet_title in ("Individuals", "People"):
+ for field_name in self.latin_name_headers:
+ value = self.get_cell_value(first_row, row, field_name)
+ if not value:
+ continue
+ try:
+ ascii_name_validator(value)
+ except ValidationError as e:
+ invalid_rows.append(
+ {
+ "row_number": row_number,
+ "header": field_name,
+ "message": f"{e.code}, {e.message}, Value provided: {value}",
+ }
+ )
- if sheet.title == "Individuals":
+ if self.sheet_title == "Individuals":
invalid_rows.extend(self._validate_head_of_household())
- if sheet.title in ("Households", "People"):
+ if self.sheet_title in ("Households", "People"):
admin_area_invalid_rows = self.validate_admin_areas(admin_area_code_tuples, business_area_slug)
if admin_area_invalid_rows:
invalid_rows.extend(admin_area_invalid_rows)
@@ -1862,6 +1882,17 @@ def validate_facility(self, facility_data: dict[str, Any]) -> dict[str, str] | N
return {"header": "facility_admin_area_h_c", "message": f"Area with code: {area_p_code} does not exist"}
return None
+ def _validate_latin_fields(self, field: str, value: str, errors: list[dict[str, str]]) -> None:
+ try:
+ ascii_name_validator(value)
+ except ValidationError as e:
+ errors.append(
+ {
+ "header": field,
+ "message": f"{e.code}, {e.message}, Value provided: {value}",
+ }
+ )
+
def _validate_household(
self,
household: dict[str, Any],
@@ -1916,6 +1947,9 @@ def _validate_household(
if error:
errors.append(error)
+ if i_field.removesuffix("_i_c") in NAME_TO_LATIN_FIELDS.values():
+ self._validate_latin_fields(i_field, i_value, errors)
+
docs_and_identities_to_validate.append(current_individual_docs_and_identities)
i_expected_field_errors = [
diff --git a/src/hope/apps/universal_update_script/universal_individual_update_service/all_updatable_fields.py b/src/hope/apps/universal_update_script/universal_individual_update_service/all_updatable_fields.py
index 06ea4fa5798..7322a52256b 100644
--- a/src/hope/apps/universal_update_script/universal_individual_update_service/all_updatable_fields.py
+++ b/src/hope/apps/universal_update_script/universal_individual_update_service/all_updatable_fields.py
@@ -22,12 +22,17 @@
validate_string,
)
from hope.models import AccountType, DocumentType, FlexibleAttribute
+from hope.models.individual import ascii_name_validator
individual_fields: dict[str, tuple[str, Any, Any]] = {
"full_name": ("full_name", validate_string, handle_simple_field),
"given_name": ("given_name", validate_string, handle_simple_field),
"middle_name": ("middle_name", validate_string, handle_simple_field),
"family_name": ("family_name", validate_string, handle_simple_field),
+ "full_name_latin": ("full_name_latin", ascii_name_validator, handle_simple_field),
+ "given_name_latin": ("given_name_latin", ascii_name_validator, handle_simple_field),
+ "middle_name_latin": ("middle_name_latin", ascii_name_validator, handle_simple_field),
+ "family_name_latin": ("family_name_latin", ascii_name_validator, handle_simple_field),
"sex": ("sex", validate_choices, handle_simple_field),
"birth_date": ("birth_date", validate_date, handle_date_field),
"estimated_birth_date": (
diff --git a/src/hope/config/fragments/constance.py b/src/hope/config/fragments/constance.py
index 810572d8d57..807533feaa8 100644
--- a/src/hope/config/fragments/constance.py
+++ b/src/hope/config/fragments/constance.py
@@ -242,6 +242,11 @@
"Enable Elasticsearch usage",
bool,
),
+ "ES_USE_LATIN_NAMES": (
+ False,
+ "Search queries match the latin name fields (requires a fleet reindex - see docs/guide-dev/elasticsearch.md)",
+ bool,
+ ),
"SURPRISE_PAGE_HEADING": (
"🎉 You found a secret!",
"Heading shown on the surprise/easter-egg page.",
diff --git a/src/hope/contrib/aurora/services/base_flex_registration_service.py b/src/hope/contrib/aurora/services/base_flex_registration_service.py
index ebefa40b4db..b3971dfdf41 100644
--- a/src/hope/contrib/aurora/services/base_flex_registration_service.py
+++ b/src/hope/contrib/aurora/services/base_flex_registration_service.py
@@ -214,7 +214,13 @@ def _create_object_and_validate(self, data: dict, model_class: Any, model_form:
form = ModelClassForm(data=data, files=files) # type: ignore
if not form.is_valid():
raise ValidationError(form.errors)
- return form.save()
+ instance = form.save(commit=False)
+ if isinstance(instance, PendingIndividual):
+ # fills only empty latin fields, so explicitly provided *_latin values win
+ instance.set_names_latin()
+ instance.save()
+ form.save_m2m()
+ return instance
def _prepare_picture_from_base64(self, certificate_picture: Any, document_number: str) -> ContentFile | Any:
if certificate_picture:
diff --git a/src/hope/contrib/aurora/services/czech_republic_flex_registration_service.py b/src/hope/contrib/aurora/services/czech_republic_flex_registration_service.py
index 437add798f1..36d57d8da0c 100644
--- a/src/hope/contrib/aurora/services/czech_republic_flex_registration_service.py
+++ b/src/hope/contrib/aurora/services/czech_republic_flex_registration_service.py
@@ -43,6 +43,8 @@ class CzechRepublicFlexRegistration(BaseRegistrationService):
"phone_no": "phone_no_i_c",
"given_name": "given_name_i_c",
"family_name": "family_name_i_c",
+ "given_name_latin": "given_name_latin_i_c",
+ "family_name_latin": "family_name_latin_i_c",
"relationship": "relationship_i_c",
"preferred_language": "preferred_language_i_c",
}
diff --git a/src/hope/contrib/aurora/services/generic_registration_service.py b/src/hope/contrib/aurora/services/generic_registration_service.py
index 0ba22090854..80e1b59b75e 100644
--- a/src/hope/contrib/aurora/services/generic_registration_service.py
+++ b/src/hope/contrib/aurora/services/generic_registration_service.py
@@ -75,6 +75,10 @@ class GenericRegistrationService(BaseRegistrationService):
"middle_name_i_c": "individual.middle_name",
"family_name_i_c": "individual.family_name",
"full_name_i_c": "individual.full_name",
+ "given_name_latin_i_c": "individual.given_name_latin",
+ "middle_name_latin_i_c": "individual.middle_name_latin",
+ "family_name_latin_i_c": "individual.family_name_latin",
+ "full_name_latin_i_c": "individual.full_name_latin",
"birth_date_i_c": "individual.birth_date",
"gender_i_c": "individual.sex",
"disability_i_c": "individual.disability",
diff --git a/src/hope/contrib/aurora/services/nigeria_people_registration_service.py b/src/hope/contrib/aurora/services/nigeria_people_registration_service.py
index 56e2c9ba125..6758c7415a4 100644
--- a/src/hope/contrib/aurora/services/nigeria_people_registration_service.py
+++ b/src/hope/contrib/aurora/services/nigeria_people_registration_service.py
@@ -56,6 +56,9 @@ class NigeriaPeopleRegistrationService(GenericRegistrationService):
"given_name_i_c": "individual.given_name",
"family_name_i_c": "individual.family_name",
"middle_name_i_c": "individual.middle_name",
+ "given_name_latin_i_c": "individual.given_name_latin",
+ "middle_name_latin_i_c": "individual.middle_name_latin",
+ "family_name_latin_i_c": "individual.family_name_latin",
"birth_date_i_c": "individual.birth_date",
"gender_i_c": "individual.sex",
"email_i_c": "individual.email",
diff --git a/src/hope/contrib/aurora/services/sri_lanka_flex_registration_service.py b/src/hope/contrib/aurora/services/sri_lanka_flex_registration_service.py
index 8dc07e2909b..ffdd0f7d328 100644
--- a/src/hope/contrib/aurora/services/sri_lanka_flex_registration_service.py
+++ b/src/hope/contrib/aurora/services/sri_lanka_flex_registration_service.py
@@ -33,6 +33,7 @@
class SriLankaRegistrationService(BaseRegistrationService):
INDIVIDUAL_MAPPING_DICT = {
"full_name": "full_name_i_c",
+ "full_name_latin": "full_name_latin_i_c",
"birth_date": "birth_date_i_c",
"sex": "gender_i_c",
"who_answers_phone": "who_answers_phone_i_c",
@@ -188,20 +189,24 @@ def create_household_for_rdi_household(self, record: Any, registration_data_impo
"business_area": registration_data_import.business_area,
}
- head_of_household = PendingIndividual.objects.create(
+ head_of_household = PendingIndividual(
**base_individual_data_dict,
**self._prepare_individual_data(head_of_household_dict, registration_data_import),
relationship=HEAD,
)
+ head_of_household.set_names_latin()
+ head_of_household.save()
self._prepare_national_id(head_of_household_dict, head_of_household)
if should_use_hoh_as_collector:
primary_collector = head_of_household
else:
- primary_collector = PendingIndividual.objects.create(
+ primary_collector = PendingIndividual(
**base_individual_data_dict,
**self._prepare_individual_data(collector_dict, registration_data_import),
)
+ primary_collector.set_names_latin()
+ primary_collector.save()
self._prepare_national_id(collector_dict, primary_collector)
self._prepare_bank_statement_document(collector_dict, primary_collector)
@@ -224,6 +229,7 @@ def create_household_for_rdi_household(self, record: Any, registration_data_impo
)
for individual in individuals_to_create:
calculate_phone_numbers_validity(individual)
+ individual.set_names_latin()
PendingIndividual.objects.bulk_create(individuals_to_create)
for individual_data_dict, imported_individual in zip(individuals_list, individuals_to_create, strict=True):
self._prepare_birth_certificate(individual_data_dict, imported_individual)
diff --git a/src/hope/contrib/aurora/services/ukraine_flex_registration_service.py b/src/hope/contrib/aurora/services/ukraine_flex_registration_service.py
index 411bc4090b0..30ee51cee9e 100644
--- a/src/hope/contrib/aurora/services/ukraine_flex_registration_service.py
+++ b/src/hope/contrib/aurora/services/ukraine_flex_registration_service.py
@@ -50,6 +50,9 @@ class UkraineBaseRegistrationService(BaseRegistrationService):
"given_name": "given_name_i_c",
"family_name": "family_name_i_c",
"middle_name": "patronymic",
+ "given_name_latin": "given_name_latin_i_c",
+ "family_name_latin": "family_name_latin_i_c",
+ "middle_name_latin": "patronymic_local",
"birth_date": "birth_date",
"sex": "gender_i_c",
"relationship": "relationship_i_c",
diff --git a/src/hope/models/individual.py b/src/hope/models/individual.py
index b5407f685ab..794eb08a13a 100644
--- a/src/hope/models/individual.py
+++ b/src/hope/models/individual.py
@@ -5,7 +5,7 @@
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.core.cache import cache
-from django.core.validators import MinLengthValidator
+from django.core.validators import MinLengthValidator, RegexValidator
from django.db import IntegrityError, models
from django.db.models import JSONField, Q, QuerySet, UniqueConstraint
from django.utils import timezone
@@ -62,6 +62,12 @@
UnicefIdentifiedModel,
)
+ascii_name_validator = RegexValidator(
+ regex=r"^[A-Za-z]+(?:[ '-][A-Za-z]+)*$",
+ message="Only ASCII letters, spaces, hyphens, and apostrophes are allowed.",
+ code="invalid_name",
+)
+
class IndividualCollection(UnicefIdentifiedModel):
"""Collection of individual representations."""
@@ -99,6 +105,10 @@ class Individual(
"given_name",
"middle_name",
"family_name",
+ "full_name_latin",
+ "given_name_latin",
+ "middle_name_latin",
+ "family_name_latin",
"sex",
"birth_date",
"estimated_birth_date",
@@ -192,13 +202,61 @@ class Individual(
db_collation="und-ci-det",
)
given_name = models.CharField(
- max_length=85, blank=True, db_index=True, help_text="First name of the Beneficiary", db_collation="und-ci-det"
+ max_length=85,
+ blank=True,
+ db_index=True,
+ help_text="First name of the Beneficiary",
+ db_collation="und-ci-det",
)
middle_name = models.CharField(
- max_length=85, blank=True, db_index=True, help_text="Middle name of the Beneficiary", db_collation="und-ci-det"
+ max_length=85,
+ blank=True,
+ db_index=True,
+ help_text="Middle name of the Beneficiary",
+ db_collation="und-ci-det",
)
family_name = models.CharField(
- max_length=85, blank=True, db_index=True, help_text="Last name of the Beneficiary", db_collation="und-ci-det"
+ max_length=85,
+ blank=True,
+ db_index=True,
+ help_text="Last name of the Beneficiary",
+ db_collation="und-ci-det",
+ )
+ full_name_latin = models.CharField(
+ max_length=500,
+ validators=[MinLengthValidator(2), ascii_name_validator],
+ db_index=True,
+ help_text="Full name of the Beneficiary Latin",
+ db_collation="und-ci-det",
+ blank=True,
+ null=True,
+ )
+ given_name_latin = models.CharField(
+ max_length=150,
+ blank=True,
+ db_index=True,
+ help_text="First name of the Beneficiary Latin",
+ db_collation="und-ci-det",
+ null=True,
+ validators=[ascii_name_validator],
+ )
+ middle_name_latin = models.CharField(
+ max_length=150,
+ blank=True,
+ db_index=True,
+ help_text="Middle name of the Beneficiary Latin",
+ db_collation="und-ci-det",
+ null=True,
+ validators=[ascii_name_validator],
+ )
+ family_name_latin = models.CharField(
+ max_length=150,
+ blank=True,
+ db_index=True,
+ help_text="Last name of the Beneficiary Latin",
+ db_collation="und-ci-det",
+ null=True,
+ validators=[ascii_name_validator],
)
sex = models.CharField(
max_length=255,
@@ -521,6 +579,23 @@ def set_relationship_confirmed_flag(self, confirmed: bool) -> None:
self.relationship_confirmed = confirmed
self.save(update_fields=["relationship_confirmed"])
+ def set_names_latin(self) -> None:
+ from hope.apps.household.utils import NAME_TO_LATIN_FIELDS, to_latin
+
+ for local_name, latin_name in NAME_TO_LATIN_FIELDS.items():
+ if local_name == "full_name":
+ continue
+ value = getattr(self, local_name)
+ if value and not getattr(self, latin_name):
+ setattr(self, latin_name, to_latin(value))
+
+ if not self.full_name_latin:
+ self.full_name_latin = (
+ to_latin(self.full_name)
+ if self.full_name
+ else " ".join(filter(None, [self.given_name_latin, self.middle_name_latin, self.family_name_latin]))
+ )
+
def __str__(self) -> str:
return self.unicef_id or ""
@@ -668,6 +743,10 @@ def erase(self) -> None:
self.given_name = "GDPR REMOVED"
self.middle_name = "GDPR REMOVED"
self.family_name = "GDPR REMOVED"
+ self.full_name_latin = "GDPR REMOVED"
+ self.given_name_latin = "GDPR REMOVED"
+ self.middle_name_latin = "GDPR REMOVED"
+ self.family_name_latin = "GDPR REMOVED"
self.photo = ""
self.disability_certificate_picture = ""
self.phone_no = ""
diff --git a/src/hope/one_time_scripts/migrate_latin_names.py b/src/hope/one_time_scripts/migrate_latin_names.py
new file mode 100644
index 00000000000..eeb90892483
--- /dev/null
+++ b/src/hope/one_time_scripts/migrate_latin_names.py
@@ -0,0 +1,90 @@
+import json
+import time
+from typing import TextIO
+
+from django.db import transaction
+from django.db.models import Q
+
+from hope.apps.household.utils import NAME_TO_LATIN_FIELDS
+from hope.models import Individual, Program
+
+LATIN_FIELDS = list(NAME_TO_LATIN_FIELDS.values())
+NAME_FIELDS = list(NAME_TO_LATIN_FIELDS)
+MISSING_LATIN = (
+ Q(full_name_latin__isnull=True)
+ | Q(given_name_latin__isnull=True)
+ | Q(middle_name_latin__isnull=True)
+ | Q(family_name_latin__isnull=True)
+)
+
+
+def _report_failure(failures_file: TextIO, individual: Individual, program: Program, reason: Exception) -> None:
+ failures_file.write(
+ json.dumps(
+ {
+ "individual_id": str(individual.pk),
+ "unicef_id": individual.unicef_id,
+ "program_id": str(program.pk),
+ "program_name": program.name,
+ "business_area": program.business_area.slug,
+ "reason": str(reason),
+ "names": {field: getattr(individual, field) for field in NAME_FIELDS},
+ }
+ )
+ + "\n"
+ )
+ failures_file.flush()
+ print(f" FAILED {individual.unicef_id or individual.pk}: {reason}")
+
+
+def _migrate_program(program: Program, batch_size: int, failures_file: TextIO) -> tuple[int, int]:
+ """Keyset-paginated backfill of one program - short queries, fills only missing latin fields."""
+ updated = failed = batch_no = 0
+ last_pk = None
+ while True:
+ qs = Individual.all_objects.filter(MISSING_LATIN, program_id=program.pk)
+ if last_pk is not None:
+ qs = qs.filter(pk__gt=last_pk)
+ batch = list(qs.order_by("pk").only("pk", "unicef_id", *NAME_FIELDS, *LATIN_FIELDS)[:batch_size])
+ if not batch:
+ return updated, failed
+ batch_no += 1
+ batch_started_at = time.time()
+ # advance the keyset even if every row in the batch fails, so bad data
+ # cannot loop the migration forever
+ last_pk = batch[-1].pk
+ to_update = []
+ for individual in batch:
+ try:
+ individual.set_names_latin()
+ to_update.append(individual)
+ except Exception as e: # noqa: BLE001 # one bad record must never kill the run
+ failed += 1
+ _report_failure(failures_file, individual, program, e)
+ if to_update:
+ with transaction.atomic():
+ Individual.all_objects.bulk_update(to_update, LATIN_FIELDS)
+ updated += len(to_update)
+ print(
+ f" ba={program.business_area.slug} program={program.name} batch={batch_no} "
+ f"updated={updated} failed={failed} elapsed={time.time() - batch_started_at:.2f}s"
+ )
+
+
+def migrate_to_latin_names(batch_size: int = 1000, failures_path: str = "latin_migration_failures.jsonl") -> None:
+ """Resumable latin-names backfill, program by program grouped by business area; failures go to JSONL."""
+ started_at = time.time()
+ total_updated = total_failed = 0
+
+ programs = Program.objects.select_related("business_area").order_by("business_area__slug", "id")
+ with open(failures_path, "a") as failures_file:
+ for program in programs:
+ updated, failed = _migrate_program(program, batch_size, failures_file)
+ total_updated += updated
+ total_failed += failed
+ if updated or failed:
+ print(f"ba={program.business_area.slug} program={program.name} done: updated={updated} failed={failed}")
+
+ print(f"Done in {time.time() - started_at:.2f}s | updated={total_updated}, failed={total_failed}")
+ if total_failed:
+ print(f"Failed records written to {failures_path}")
diff --git a/tests/e2e/new_selenium/grievance/test_latin_names_data_change.py b/tests/e2e/new_selenium/grievance/test_latin_names_data_change.py
new file mode 100644
index 00000000000..9ebe1bfa537
--- /dev/null
+++ b/tests/e2e/new_selenium/grievance/test_latin_names_data_change.py
@@ -0,0 +1,149 @@
+import pytest
+
+from extras.test_utils.selenium import HopeTestBrowser
+from hope.models import Household, Individual, Program
+
+pytestmark = pytest.mark.django_db()
+
+CHECKBOX_TRANSLITERATE = '[data-cy="input-transliterateLatinNames"]'
+BUTTON_SUBMIT = 'button[data-cy="button-submit"]'
+FIRST_FIELD_NAME = "individualDataUpdateFields[0].fieldName"
+FIRST_FIELD_VALUE = '[data-cy="input-individualDataUpdateFields[0].fieldValue"]'
+
+
+@pytest.fixture
+def cyrillic_head(household_for_update: Household) -> Individual:
+ head = household_for_update.head_of_household
+ head.given_name = "Олександр"
+ head.family_name = "Шевченко"
+ head.full_name = "Олександр Шевченко"
+ head.set_names_latin()
+ head.save()
+ return head
+
+
+def _open_new_data_change_ticket(browser: HopeTestBrowser, program: Program, issue_type: str) -> None:
+ browser.open(f"/{program.business_area.slug}/programs/{program.code}/grievance/tickets/user-generated")
+ browser.wait_for_text("Grievance Tickets", 'h5[data-cy="page-header-title"]', timeout=60)
+ browser.click('a[data-cy="button-new-ticket"]')
+ browser.select_dropdown_option("category", "Data Change")
+ browser.select_dropdown_option("issueType", issue_type)
+ browser.click(BUTTON_SUBMIT)
+ browser.wait_for_ready_state_complete()
+
+
+def _pick_first_individual_and_consent(browser: HopeTestBrowser) -> None:
+ browser.wait_for_element_visible('button[data-cy="look-up-individual"]').click()
+ browser.wait_for_element_visible('tr[data-cy="individual-table-row"]').click()
+ browser.click(BUTTON_SUBMIT)
+ browser.wait_for_ready_state_complete()
+ browser.click('[data-cy="input-consent"]')
+ browser.click(BUTTON_SUBMIT)
+ browser.wait_for_element_visible(f'[data-cy="select-{FIRST_FIELD_NAME}"]')
+
+
+def _select_listbox(browser: HopeTestBrowser, field_name: str, option: str) -> None:
+ # choice options here carry codes (MALE, YES) in data-cy, so pick by visible text
+ browser.click(f'[data-cy="select-{field_name}"]')
+ browser.select_listbox_element(option)
+
+
+def _js_click(browser: HopeTestBrowser, selector: str) -> None:
+ # toolbar buttons sit under the sticky page header in headless Chrome
+ browser.wait_for_element_visible(selector)
+ browser.js_click(selector)
+
+
+def _send_for_approval(browser: HopeTestBrowser) -> None:
+ _js_click(browser, 'button[data-cy="button-assign-to-me"]')
+ _js_click(browser, 'button[data-cy="button-set-to-in-progress"]')
+ _js_click(browser, 'button[data-cy="button-send-for-approval"]')
+ browser.wait_for_element_clickable('button[data-cy="button-approve"]')
+
+
+def _approve_and_close(browser: HopeTestBrowser) -> None:
+ _js_click(browser, 'button[data-cy="button-approve"]')
+ _js_click(browser, 'button[data-cy="button-confirm"]')
+ _js_click(browser, 'button[data-cy="button-close-ticket"]')
+ _js_click(browser, 'button[data-cy="button-confirm"]')
+ browser.wait_for_text("Closed", 'div[data-cy="label-Status"]', timeout=60)
+
+
+@pytest.mark.usefixtures("cyrillic_head")
+def test_transliteration_checkbox_hidden_until_a_name_field_is_selected(
+ login: HopeTestBrowser,
+ household_update_program: Program,
+) -> None:
+ _open_new_data_change_ticket(login, household_update_program, "Individual Data Update")
+ _pick_first_individual_and_consent(login)
+
+ login.select_dropdown_option(FIRST_FIELD_NAME, "Phone number")
+ login.assert_element_absent(CHECKBOX_TRANSLITERATE)
+
+ login.click('button[data-cy="button-add-new-field"]')
+ login.select_dropdown_option("individualDataUpdateFields[1].fieldName", "Given name")
+ login.assert_element_visible(CHECKBOX_TRANSLITERATE)
+
+
+def test_name_change_without_latin_requires_transliteration(
+ login: HopeTestBrowser,
+ household_update_program: Program,
+ cyrillic_head: Individual,
+) -> None:
+ _open_new_data_change_ticket(login, household_update_program, "Individual Data Update")
+ _pick_first_individual_and_consent(login)
+ login.type('textarea[name="description"]', "Latin names via transliteration")
+
+ login.select_dropdown_option(FIRST_FIELD_NAME, "Given name")
+ login.type(FIRST_FIELD_VALUE, "Петро")
+ login.click(CHECKBOX_TRANSLITERATE) # default is on - turn it off
+ login.click(BUTTON_SUBMIT)
+ login.assert_text("Provide given_name_latin or enable automatic transliteration")
+
+ login.click(CHECKBOX_TRANSLITERATE)
+ login.click(BUTTON_SUBMIT)
+ login.wait_for_element_visible('[data-cy="table-cell-new-value"]', timeout=60)
+ login.assert_text("Петро")
+
+ _send_for_approval(login)
+ login.click('span[data-cy="checkbox-requested-data-change"]')
+ _approve_and_close(login)
+
+ program = household_update_program
+ login.open(f"/{program.business_area.slug}/programs/{program.code}/population/individuals/{cyrillic_head.pk}")
+ login.assert_text("Петро", 'div[data-cy="label-Given Name"]')
+ login.assert_text("Petro", 'div[data-cy="label-Given Name"]')
+
+
+def test_add_individual_with_transliteration_shows_latin_name_after_close(
+ login: HopeTestBrowser,
+ household_update_program: Program,
+ household_for_update: Household,
+) -> None:
+ _open_new_data_change_ticket(login, household_update_program, "Add Individual")
+ login.wait_for_element_visible('[data-cy="input-radio-household"]').click()
+ login.click(BUTTON_SUBMIT)
+ login.wait_for_ready_state_complete()
+ login.click('[data-cy="input-consent"]')
+ login.click(BUTTON_SUBMIT)
+
+ login.type('textarea[name="description"]', "Add member with Cyrillic name")
+ login.type('input[data-cy="input-individualData.fullName"]', "Дмитро Коваль")
+ login.fill_date('input[name="individualData.birthDate"]', "1986-05-01")
+ _select_listbox(login, "individualData.sex", "Male")
+ _select_listbox(login, "individualData.estimatedBirthDate", "Yes")
+ _select_listbox(login, "individualData.relationship", "Wife / Husband")
+ login.assert_element_visible(CHECKBOX_TRANSLITERATE)
+ login.click(BUTTON_SUBMIT)
+
+ login.wait_for_element_visible('[data-cy="transliterate-latin-names-info"]', timeout=60)
+ login.assert_text("Дмитро Коваль", 'div[data-cy="label-full name"]')
+
+ _send_for_approval(login)
+ _approve_and_close(login)
+
+ program = household_update_program
+ login.open(f"/{program.business_area.slug}/programs/{program.code}/population/household/{household_for_update.pk}")
+ added = Individual.objects.get(household=household_for_update, full_name="Дмитро Коваль")
+ login.assert_text("Дмитро Коваль")
+ login.assert_text(added.full_name_latin)
diff --git a/tests/e2e/new_selenium/programme_population/test_latin_names_display.py b/tests/e2e/new_selenium/programme_population/test_latin_names_display.py
new file mode 100644
index 00000000000..536367baa4e
--- /dev/null
+++ b/tests/e2e/new_selenium/programme_population/test_latin_names_display.py
@@ -0,0 +1,65 @@
+import pytest
+
+from extras.test_utils.factories import HouseholdFactory
+from extras.test_utils.selenium import HopeTestBrowser
+from hope.models import BusinessArea, Household, Program
+
+pytestmark = pytest.mark.django_db()
+
+
+@pytest.fixture
+def household_with_cyrillic_head(program: Program, business_area: BusinessArea) -> Household:
+ household = HouseholdFactory(business_area=business_area, program=program)
+ head = household.head_of_household
+ head.given_name = "Олександр"
+ head.middle_name = "Іванович"
+ head.family_name = "Шевченко"
+ head.full_name = "Олександр Іванович Шевченко"
+ head.set_names_latin()
+ head.save()
+ return household
+
+
+def test_individual_details_show_names_with_latin_twins(
+ login: HopeTestBrowser,
+ program: Program,
+ household_with_cyrillic_head: Household,
+) -> None:
+ head = household_with_cyrillic_head.head_of_household
+ login.open(f"/{program.business_area.slug}/programs/{program.code}/population/individuals/{head.pk}")
+
+ login.assert_text("Олександр Іванович Шевченко", 'div[data-cy="label-Full Name"]')
+ login.assert_text(head.full_name_latin, 'div[data-cy="label-Full Name"]')
+ login.assert_text("Олександр", 'div[data-cy="label-Given Name"]')
+ login.assert_text(head.given_name_latin, 'div[data-cy="label-Given Name"]')
+ login.assert_text("Шевченко", 'div[data-cy="label-Family Name"]')
+ login.assert_text(head.family_name_latin, 'div[data-cy="label-Family Name"]')
+
+
+def test_household_details_show_head_name_with_latin_twin(
+ login: HopeTestBrowser,
+ program: Program,
+ household_with_cyrillic_head: Household,
+) -> None:
+ head = household_with_cyrillic_head.head_of_household
+ label = f'div[data-cy="label-Head of {program.beneficiary_group.group_label}"]'
+ login.open(
+ f"/{program.business_area.slug}/programs/{program.code}/population/household/{household_with_cyrillic_head.pk}"
+ )
+
+ login.assert_text("Олександр Іванович Шевченко", label)
+ login.assert_text(head.full_name_latin, label)
+ login.assert_text(head.full_name_latin, 'tr[data-cy="household-members-row"]')
+ login.assert_text(head.full_name_latin, 'tr[data-cy="collectors-row"]')
+
+
+def test_individuals_list_shows_names_with_latin_twins(
+ login: HopeTestBrowser,
+ program: Program,
+ household_with_cyrillic_head: Household,
+) -> None:
+ head = household_with_cyrillic_head.head_of_household
+ login.open(f"/{program.business_area.slug}/programs/{program.code}/population/individuals")
+
+ login.assert_text("Олександр Іванович Шевченко", 'tr[data-cy="individual-table-row"]')
+ login.assert_text(head.full_name_latin, 'tr[data-cy="individual-table-row"]')
diff --git a/tests/e2e/new_selenium/registration_data_import/test_usdc_import.py b/tests/e2e/new_selenium/registration_data_import/test_usdc_import.py
index 7e721949d2a..216e73db306 100644
--- a/tests/e2e/new_selenium/registration_data_import/test_usdc_import.py
+++ b/tests/e2e/new_selenium/registration_data_import/test_usdc_import.py
@@ -141,6 +141,7 @@ def test_usdc_import_detail_pages_render_and_rdi_merges(
browser.open(f"/{business_area.slug}/programs/{program.code}/population/individuals/{individual.id}")
browser.wait_for_element_visible('h5[data-cy="page-header-title"]')
browser.assert_text(individual.full_name, 'div[data-cy="label-Full Name"]')
+ browser.assert_text(individual.full_name_latin, 'div[data-cy="label-Full Name"]')
browser.open(f"/{business_area.slug}/programs/{program.code}/population/household/{household.id}")
browser.wait_for_element_visible('h5[data-cy="page-header-title"]')
diff --git a/tests/unit/api/test_rdi.py b/tests/unit/api/test_rdi.py
index 035c87a9de5..f380b142dae 100644
--- a/tests/unit/api/test_rdi.py
+++ b/tests/unit/api/test_rdi.py
@@ -134,7 +134,7 @@ def test_push_creates_household_and_individuals(
"members": [
{
"relationship": HEAD,
- "full_name": "James Head #1",
+ "full_name": "James Head One",
"birth_date": "2000-01-01",
"sex": "MALE",
"photo": base64_image,
@@ -163,7 +163,7 @@ def test_push_creates_household_and_individuals(
},
{
"relationship": NON_BENEFICIARY,
- "full_name": "Mary Primary #1",
+ "full_name": "Mary Primary One",
"birth_date": "2000-01-01",
"role": ROLE_PRIMARY,
"sex": "FEMALE",
@@ -185,8 +185,8 @@ def test_push_creates_household_and_individuals(
assert hh.primary_collector is not None
assert hh.alternate_collector is None
assert hh.program_id == program.id
- assert hh.primary_collector.full_name == "Mary Primary #1"
- assert hh.head_of_household.full_name == "James Head #1"
+ assert hh.primary_collector.full_name == "Mary Primary One"
+ assert hh.head_of_household.full_name == "James Head One"
assert hh.head_of_household.photo is not None
account_1 = PendingAccount.objects.filter(individual=hh.head_of_household).order_by("number").first()
account_2 = PendingAccount.objects.filter(individual=hh.head_of_household).order_by("number").last()
diff --git a/tests/unit/api/test_soft.py b/tests/unit/api/test_soft.py
index 38992d861db..7fb988ed8bf 100644
--- a/tests/unit/api/test_soft.py
+++ b/tests/unit/api/test_soft.py
@@ -89,6 +89,7 @@ def test_push_lax_creates_households_and_reports_errors(
{
"relationship": HEAD,
"full_name": "James Head #1",
+ "full_name_latin": "James Head One",
"birth_date": "2000-01-01",
"sex": "MALE",
"role": "",
@@ -264,15 +265,48 @@ def test_push_lax_creates_households_and_reports_errors(
},
],
},
+ {
+ "residence_status": "",
+ "village": "village555",
+ "country": "AF",
+ "admin1": "AF01",
+ "admin2": None,
+ "members": [
+ {
+ "relationship": HEAD,
+ "full_name": "New Test #555",
+ "full_name_latin": "New Test #555",
+ "birth_date": "2000-01-01",
+ "sex": "MALE",
+ "role": "",
+ "documents": [
+ {
+ "document_number": 10,
+ "image": base64_encoded_data,
+ "country": "AF",
+ "type": birth_cert_key,
+ }
+ ],
+ },
+ {
+ "relationship": NON_BENEFICIARY,
+ "full_name": "Mary Primary #555",
+ "birth_date": "2000-01-01",
+ "role": ROLE_PRIMARY,
+ "sex": "FEMALE",
+ },
+ ],
+ "size": 1,
+ },
]
response = token_api_client.post(url, input_data, format="json")
assert response.status_code == status.HTTP_201_CREATED, str(response.json())
data = response.json()
- assert len(data["households"]) == 6
- assert data["processed"] == 6
- assert data["errors"] == 2
+ assert len(data["households"]) == 7
+ assert data["processed"] == 7
+ assert data["errors"] == 3
assert data["accepted"] == 4
rdi = RegistrationDataImport.objects.filter(id=data["id"]).first()
assert rdi is not None
@@ -285,6 +319,7 @@ def test_push_lax_creates_households_and_reports_errors(
hh = PendingHousehold.objects.get(pk=pk1)
assert hh.program_id == program.id
assert hh.head_of_household.full_name == "James Head #1"
+ assert hh.head_of_household.full_name_latin == "James Head One"
assert hh.primary_collector.full_name == "Mary Primary #1"
assert hh.head_of_household.program_id == program.id
assert hh.primary_collector.program_id == program.id
@@ -292,6 +327,6 @@ def test_push_lax_creates_households_and_reports_errors(
hh = PendingHousehold.objects.get(pk=pk2)
assert hh.program_id == program.id
assert hh.head_of_household.full_name == "James Head #1"
- assert hh.primary_collector.full_name == "James Head #1"
+ assert hh.primary_collector.full_name_latin == "James Head"
assert hh.head_of_household.program_id == program.id
assert hh.primary_collector.program_id == program.id
diff --git a/tests/unit/api/test_upload.py b/tests/unit/api/test_upload.py
index 7cbbce8fdb9..4a88e266dfb 100644
--- a/tests/unit/api/test_upload.py
+++ b/tests/unit/api/test_upload.py
@@ -272,21 +272,21 @@ def test_upload_multiple_households(
"members": [
{
"relationship": NON_BENEFICIARY,
- "full_name": "Jhon Primary #1",
+ "full_name": "Jhon Primary One",
"birth_date": "2000-01-01",
"role": ROLE_PRIMARY,
"sex": "FEMALE",
},
{
"relationship": NON_BENEFICIARY,
- "full_name": "Mary Alternate #1",
+ "full_name": "Mary Alternate One",
"birth_date": "2000-01-01",
"role": ROLE_ALTERNATE,
"sex": "MALE",
},
{
"relationship": HEAD,
- "full_name": "James Head #1",
+ "full_name": "James Head One",
"birth_date": "2000-01-01",
"sex": "MALE",
"role": "",
@@ -300,7 +300,7 @@ def test_upload_multiple_households(
},
{
"relationship": SON_DAUGHTER,
- "full_name": "Mary Son #1",
+ "full_name": "Mary Son One",
"birth_date": "2000-01-01",
"role": "",
"sex": "MALE",
@@ -315,7 +315,7 @@ def test_upload_multiple_households(
"members": [
{
"relationship": HEAD,
- "full_name": "John Head #2",
+ "full_name": "John Head Two",
"birth_date": "2000-01-01",
"sex": "MALE",
"role": "",
@@ -330,7 +330,7 @@ def test_upload_multiple_households(
},
{
"relationship": NON_BENEFICIARY,
- "full_name": "Mary Primary #2",
+ "full_name": "Mary Primary Two",
"birth_date": "2000-01-01",
"role": ROLE_PRIMARY,
"sex": "FEMALE",
@@ -382,8 +382,8 @@ def test_upload_multiple_households(
assert hh.head_of_household is not None
assert hh.primary_collector is not None
assert hh.alternate_collector is not None
- assert hh.primary_collector.full_name == "Jhon Primary #1"
- assert hh.head_of_household.full_name == "James Head #1"
+ assert hh.primary_collector.full_name == "Jhon Primary One"
+ assert hh.head_of_household.full_name == "James Head One"
assert data["households"] == 3
assert data["individuals"] == 8
@@ -447,21 +447,21 @@ def test_upload_error_missing_primary_collector(
"members": [
{
"relationship": NON_BENEFICIARY,
- "full_name": "Jhon Primary #1",
+ "full_name": "Jhon Primary One",
"birth_date": "2000-01-01",
"role": ROLE_PRIMARY,
"sex": "FEMALE",
},
{
"relationship": NON_BENEFICIARY,
- "full_name": "Mary Alternate #1",
+ "full_name": "Mary Alternate One",
"birth_date": "2000-01-01",
"role": ROLE_ALTERNATE,
"sex": "MALE",
},
{
"relationship": HEAD,
- "full_name": "James Head #1",
+ "full_name": "James Head One",
"birth_date": "2000-01-01",
"sex": "MALE",
"role": "",
@@ -475,7 +475,7 @@ def test_upload_error_missing_primary_collector(
},
{
"relationship": SON_DAUGHTER,
- "full_name": "Mary Son #1",
+ "full_name": "Mary Son One",
"birth_date": "2000-01-01",
"role": "",
"sex": "MALE",
@@ -529,21 +529,21 @@ def test_upload_multiple_validation_errors(
"members": [
{
"relationship": NON_BENEFICIARY,
- "full_name": "Jhon Primary #1",
+ "full_name": "Jhon Primary One",
"birth_date": "2000-01-01",
"role": "",
"sex": "FEMALE",
},
{
"relationship": NON_BENEFICIARY,
- "full_name": "Mary Alternate #1",
+ "full_name": "Mary Alternate One",
"birth_date": "2000-01-01",
"role": ROLE_ALTERNATE,
"sex": "MALE",
},
{
"relationship": SON_DAUGHTER,
- "full_name": "James Head #1",
+ "full_name": "James Head One",
"birth_date": "2000-01-01",
"sex": "MALE",
"role": ROLE_ALTERNATE,
@@ -557,7 +557,7 @@ def test_upload_multiple_validation_errors(
},
{
"relationship": SON_DAUGHTER,
- "full_name": "Mary Son #1",
+ "full_name": "Mary Son One",
"birth_date": "2000-01-01",
"role": "",
"sex": "MALE",
diff --git a/tests/unit/api_contract/_api_checker/test_households/_api_rest_business-areas_business-area-0_programs_kpci_households_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_households/_api_rest_business-areas_business-area-0_programs_kpci_households_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
new file mode 100644
index 00000000000..c0bea99dbf0
--- /dev/null
+++ b/tests/unit/api_contract/_api_checker/test_households/_api_rest_business-areas_business-area-0_programs_kpci_households_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -0,0 +1,43 @@
+{
+ "status_code": 200,
+ "headers": {
+ "Content-Type": "application/json",
+ "Vary": "Accept, Cookie, origin",
+ "Allow": "GET, HEAD, OPTIONS",
+ "ETag": "bc97fd478927dd36328c00f9fa5ac6d4",
+ "Cache-Control": "private, no-cache",
+ "X-Hope-Backend-Version": "None",
+ "X-Content-Type-Options": "nosniff",
+ "Referrer-Policy": "same-origin",
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Content-Length": "702"
+ },
+ "data": {
+ "next": "http://testserver/api/rest/business-areas/business-area-0/programs/kpci/households/?limit=50&offset=50",
+ "previous": null,
+ "results": [
+ {
+ "id": "16ecd2da-9299-4ff6-a964-8a51e57daaa3",
+ "unicef_id": "HH-25-0000.0226",
+ "head_of_household": "Person 0",
+ "admin1": null,
+ "admin2": null,
+ "status": "ACTIVE",
+ "size": null,
+ "residence_status": "None",
+ "total_cash_received": null,
+ "total_cash_received_usd": null,
+ "last_registration_date": "2025-01-01T00:00:00Z",
+ "first_registration_date": "2025-01-01T00:00:00Z",
+ "currency": null,
+ "has_duplicates": false,
+ "sanction_list_possible_match": false,
+ "sanction_list_confirmed_match": false,
+ "program_id": "99a99d1e-d46c-4ca6-8a5b-3c79ae47804a",
+ "program_name": "Program 0",
+ "program_code": "kpci"
+ }
+ ]
+ },
+ "content_type": "application/json"
+}
\ No newline at end of file
diff --git a/tests/unit/api_contract/_api_checker/test_households/_api_rest_business-areas_business-area-0_programs_ky0b_households_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_households/_api_rest_business-areas_business-area-0_programs_ky0b_households_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
index e706e4d0763..c87ddb75887 100644
--- a/tests/unit/api_contract/_api_checker/test_households/_api_rest_business-areas_business-area-0_programs_ky0b_households_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
+++ b/tests/unit/api_contract/_api_checker/test_households/_api_rest_business-areas_business-area-0_programs_ky0b_households_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -4,7 +4,7 @@
"Content-Type": "application/json",
"Vary": "Accept, Cookie, origin",
"Allow": "GET, HEAD, OPTIONS",
- "ETag": "93c4f9751741e4cf8b79f02509363147",
+ "ETag": "3fdbbb4b05e5b196b5c47b2e86e86564",
"Cache-Control": "private, no-cache",
"X-Hope-Backend-Version": "None",
"X-Content-Type-Options": "nosniff",
diff --git a/tests/unit/api_contract/_api_checker/test_households/_api_rest_business-areas_business-area-0_programs_ky0b_households_78070ee2-dfac-4ae5-a897-ed1d708df869_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_households/_api_rest_business-areas_business-area-0_programs_ky0b_households_78070ee2-dfac-4ae5-a897-ed1d708df869_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
index 2eb14a6d19a..565c19b16ee 100644
--- a/tests/unit/api_contract/_api_checker/test_households/_api_rest_business-areas_business-area-0_programs_ky0b_households_78070ee2-dfac-4ae5-a897-ed1d708df869_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
+++ b/tests/unit/api_contract/_api_checker/test_households/_api_rest_business-areas_business-area-0_programs_ky0b_households_78070ee2-dfac-4ae5-a897-ed1d708df869_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -8,14 +8,15 @@
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
"Cross-Origin-Opener-Policy": "same-origin",
- "Content-Length": "3796"
+ "Content-Length": "3819"
},
"data": {
"id": "78070ee2-dfac-4ae5-a897-ed1d708df869",
"unicef_id": "HH-25-0000.0001",
"head_of_household": {
"id": "df79b7cf-c60a-4048-a6e6-ea9e8e5d7544",
- "full_name": "Person 0"
+ "full_name": "Person 0",
+ "full_name_latin": null
},
"admin1": null,
"admin2": null,
diff --git a/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_54le_individuals_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_54le_individuals_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
new file mode 100644
index 00000000000..f0f42acb46c
--- /dev/null
+++ b/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_54le_individuals_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -0,0 +1,54 @@
+{
+ "status_code": 200,
+ "headers": {
+ "Content-Type": "application/json",
+ "Vary": "Accept, Cookie, origin",
+ "Allow": "GET, HEAD, OPTIONS",
+ "ETag": "77705015795384526ff404b0c4fe110a",
+ "Cache-Control": "private, no-cache",
+ "X-Hope-Backend-Version": "None",
+ "X-Content-Type-Options": "nosniff",
+ "Referrer-Policy": "same-origin",
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Content-Length": "1153"
+ },
+ "data": {
+ "next": "http://testserver/api/rest/business-areas/business-area-0/programs/54le/individuals/?limit=50&offset=50",
+ "previous": null,
+ "results": [
+ {
+ "id": "54ae7175-bbc2-4878-a37f-989aaa24e628",
+ "unicef_id": "IND-25-0000.0326",
+ "full_name": "Person 0",
+ "full_name_latin": null,
+ "household": null,
+ "status": "ACTIVE",
+ "relationship": "",
+ "age": 35,
+ "sex": "MALE",
+ "relationship_display": "",
+ "birth_date": "1990-01-01",
+ "deduplication_batch_status": "UNIQUE_IN_BATCH",
+ "deduplication_batch_status_display": "Unique in RDI",
+ "biometric_deduplication_batch_status": "NOT_PROCESSED",
+ "biometric_deduplication_batch_status_display": "Not Processed",
+ "deduplication_batch_results": [],
+ "biometric_deduplication_batch_results": [],
+ "deduplication_golden_record_status": "UNIQUE",
+ "deduplication_golden_record_status_display": "Unique",
+ "biometric_deduplication_golden_record_status": "NOT_PROCESSED",
+ "biometric_deduplication_golden_record_status_display": "Not Processed",
+ "deduplication_golden_record_results": [],
+ "biometric_deduplication_golden_record_results": [],
+ "program": {
+ "id": "dd03ebf1-02b0-4354-95d6-c71cc963a7d8",
+ "name": "Program 0",
+ "code": "54le"
+ },
+ "last_registration_date": "2025-01-01",
+ "role": null
+ }
+ ]
+ },
+ "content_type": "application/json"
+}
\ No newline at end of file
diff --git a/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_54le_individuals_54ae7175-bbc2-4878-a37f-989aaa24e628_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_54le_individuals_54ae7175-bbc2-4878-a37f-989aaa24e628_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
new file mode 100644
index 00000000000..ebec4646251
--- /dev/null
+++ b/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_54le_individuals_54ae7175-bbc2-4878-a37f-989aaa24e628_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -0,0 +1,88 @@
+{
+ "status_code": 200,
+ "headers": {
+ "Content-Type": "application/json",
+ "Vary": "Accept, Cookie, origin",
+ "Allow": "GET, HEAD, OPTIONS",
+ "X-Hope-Backend-Version": "None",
+ "X-Content-Type-Options": "nosniff",
+ "Referrer-Policy": "same-origin",
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Content-Length": "1803"
+ },
+ "data": {
+ "id": "54ae7175-bbc2-4878-a37f-989aaa24e628",
+ "unicef_id": "IND-25-0000.0326",
+ "full_name": "Person 0",
+ "given_name": "",
+ "middle_name": "",
+ "family_name": "",
+ "full_name_latin": null,
+ "given_name_latin": null,
+ "middle_name_latin": null,
+ "family_name_latin": null,
+ "sex": "MALE",
+ "age": 35,
+ "birth_date": "1990-01-01",
+ "estimated_birth_date": false,
+ "marital_status": "",
+ "work_status": "NOT_PROVIDED",
+ "pregnant": null,
+ "household": null,
+ "role": null,
+ "relationship": "",
+ "registration_data_import": {
+ "id": "fb1e72cc-9626-4df4-b750-38332e284ade",
+ "name": "RDI 0",
+ "status": "MERGED",
+ "import_date": "2025-01-01T00:00:00Z",
+ "number_of_individuals": 1,
+ "number_of_households": 1,
+ "imported_by": {
+ "id": "be90fdf6-6577-4133-9eaa-dcb59c5ec112",
+ "first_name": "",
+ "last_name": "",
+ "email": "user_1@example.com",
+ "username": "user_1"
+ },
+ "data_source": "XLS"
+ },
+ "import_id": "IND-25-0000.0326",
+ "admin_url": "/api/unicorn/household/individual/54ae7175-bbc2-4878-a37f-989aaa24e628/change/",
+ "preferred_language": null,
+ "roles_in_households": [],
+ "observed_disability": [
+ "NONE"
+ ],
+ "seeing_disability": "",
+ "hearing_disability": "",
+ "physical_disability": "",
+ "memory_disability": "",
+ "selfcare_disability": "",
+ "comms_disability": "",
+ "disability": "not disabled",
+ "documents": [],
+ "identities": [],
+ "accounts": [],
+ "email": "",
+ "phone_no": "",
+ "phone_no_valid": false,
+ "phone_no_alternative": "",
+ "phone_no_alternative_valid": false,
+ "sanction_list_last_check": null,
+ "status": "ACTIVE",
+ "deduplication_golden_record_status": "UNIQUE",
+ "flex_fields": {},
+ "linked_grievances": [],
+ "photo": null,
+ "biometric_deduplication_golden_record_status": "Not Processed",
+ "linked_grievances_biometrics": [],
+ "enrolled_in_nutrition_programme": null,
+ "who_answers_phone": "",
+ "who_answers_alt_phone": "",
+ "payment_delivery_phone_no": null,
+ "identification_key": null,
+ "identification_key_label": null
+ },
+ "content_type": "application/json"
+}
\ No newline at end of file
diff --git a/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_jzh5_individuals_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_jzh5_individuals_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
index 648e14467e8..7a22992839b 100644
--- a/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_jzh5_individuals_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
+++ b/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_jzh5_individuals_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -4,13 +4,13 @@
"Content-Type": "application/json",
"Vary": "Accept, Cookie, origin",
"Allow": "GET, HEAD, OPTIONS",
- "ETag": "f5cf6c00292d61d5fa037021e4245d00",
+ "ETag": "6ebc5a9ad2c9b0462136da230ac686a2",
"Cache-Control": "private, no-cache",
"X-Hope-Backend-Version": "None",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
"Cross-Origin-Opener-Policy": "same-origin",
- "Content-Length": "1130"
+ "Content-Length": "1151"
},
"data": {
"next": "http://testserver/api/rest/business-areas/business-area-0/programs/jzh5/individuals/?limit=50&offset=50",
@@ -20,6 +20,7 @@
"id": "39153afe-db31-418c-bc62-5da4f46a9efc",
"unicef_id": "IND-25-0000.0004",
"full_name": "Person 0",
+ "full_name_latin": null,
"household": null,
"status": "ACTIVE",
"relationship": "",
diff --git a/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_jzh5_individuals_39153afe-db31-418c-bc62-5da4f46a9efc_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_jzh5_individuals_39153afe-db31-418c-bc62-5da4f46a9efc_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
index bec25aabd3b..33ef11ef7b9 100644
--- a/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_jzh5_individuals_39153afe-db31-418c-bc62-5da4f46a9efc_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
+++ b/tests/unit/api_contract/_api_checker/test_individuals/_api_rest_business-areas_business-area-0_programs_jzh5_individuals_39153afe-db31-418c-bc62-5da4f46a9efc_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -8,7 +8,7 @@
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
"Cross-Origin-Opener-Policy": "same-origin",
- "Content-Length": "1706"
+ "Content-Length": "1745"
},
"data": {
"id": "39153afe-db31-418c-bc62-5da4f46a9efc",
@@ -17,6 +17,10 @@
"given_name": "",
"middle_name": "",
"family_name": "",
+ "full_name_latin": null,
+ "given_name_latin": null,
+ "middle_name_latin": null,
+ "family_name_latin": null,
"sex": "MALE",
"age": 35,
"birth_date": "1990-01-01",
diff --git a/tests/unit/api_contract/_api_checker/test_individuals_global/_api_rest_business-areas_business-area-0_individuals_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_individuals_global/_api_rest_business-areas_business-area-0_individuals_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
index 3bd6afad878..b035887d5ae 100644
--- a/tests/unit/api_contract/_api_checker/test_individuals_global/_api_rest_business-areas_business-area-0_individuals_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
+++ b/tests/unit/api_contract/_api_checker/test_individuals_global/_api_rest_business-areas_business-area-0_individuals_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -8,7 +8,7 @@
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
"Cross-Origin-Opener-Policy": "same-origin",
- "Content-Length": "1116"
+ "Content-Length": "1137"
},
"data": {
"next": "http://testserver/api/rest/business-areas/business-area-0/individuals/?limit=50&offset=50",
@@ -18,6 +18,7 @@
"id": "2950def4-eacc-4680-b0b3-ec97a8687a03",
"unicef_id": "IND-25-0000.0003",
"full_name": "Person 0",
+ "full_name_latin": null,
"household": null,
"status": "ACTIVE",
"relationship": "",
diff --git a/tests/unit/api_contract/_api_checker/test_payments/_api_rest_business-areas_business-area-0_programs_pd2p_payment-plans_ad89a016-ec17-43c6-81ab-729de501c8d8_payments_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_payments/_api_rest_business-areas_business-area-0_programs_pd2p_payment-plans_ad89a016-ec17-43c6-81ab-729de501c8d8_payments_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
index f8811839183..7eb7a204133 100644
--- a/tests/unit/api_contract/_api_checker/test_payments/_api_rest_business-areas_business-area-0_programs_pd2p_payment-plans_ad89a016-ec17-43c6-81ab-729de501c8d8_payments_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
+++ b/tests/unit/api_contract/_api_checker/test_payments/_api_rest_business-areas_business-area-0_programs_pd2p_payment-plans_ad89a016-ec17-43c6-81ab-729de501c8d8_payments_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -28,6 +28,8 @@
"hoh_phone_no_alternative": "None",
"snapshot_collector_full_name": null,
"snapshot_alternate_collector_full_name": null,
+ "snapshot_collector_full_name_latin": null,
+ "snapshot_alternate_collector_full_name_latin": null,
"snapshot_alternate_collector_id": null,
"fsp_name": "",
"entitlement_quantity": null,
diff --git a/tests/unit/api_contract/_api_checker/test_payments/_api_rest_business-areas_business-area-0_programs_pd2p_payment-plans_ad89a016-ec17-43c6-81ab-729de501c8d8_payments_8a21fe13-2603-4ac3-9878-0acbf714cf73_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_payments/_api_rest_business-areas_business-area-0_programs_pd2p_payment-plans_ad89a016-ec17-43c6-81ab-729de501c8d8_payments_8a21fe13-2603-4ac3-9878-0acbf714cf73_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
index b138e4084c7..d2e3424bb05 100644
--- a/tests/unit/api_contract/_api_checker/test_payments/_api_rest_business-areas_business-area-0_programs_pd2p_payment-plans_ad89a016-ec17-43c6-81ab-729de501c8d8_payments_8a21fe13-2603-4ac3-9878-0acbf714cf73_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
+++ b/tests/unit/api_contract/_api_checker/test_payments/_api_rest_business-areas_business-area-0_programs_pd2p_payment-plans_ad89a016-ec17-43c6-81ab-729de501c8d8_payments_8a21fe13-2603-4ac3-9878-0acbf714cf73_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -24,6 +24,8 @@
"hoh_phone_no_alternative": "None",
"snapshot_collector_full_name": null,
"snapshot_alternate_collector_full_name": null,
+ "snapshot_collector_full_name_latin": null,
+ "snapshot_alternate_collector_full_name_latin": null,
"snapshot_alternate_collector_id": null,
"fsp_name": "",
"entitlement_quantity": null,
@@ -79,7 +81,8 @@
"unicef_id": "HH-25-0000.0002",
"head_of_household": {
"id": "e00a84e9-be7a-42ec-9452-c83d5954d092",
- "full_name": "Person 0"
+ "full_name": "Person 0",
+ "full_name_latin": null
},
"admin1": null,
"admin2": null,
@@ -222,6 +225,10 @@
"given_name": "",
"middle_name": "",
"family_name": "",
+ "full_name_latin": null,
+ "given_name_latin": null,
+ "middle_name_latin": null,
+ "family_name_latin": null,
"sex": "MALE",
"age": 35,
"birth_date": "1990-01-01",
@@ -364,4 +371,4 @@
"sent_to_fsp_date": null
},
"content_type": "application/json"
-}
+}
\ No newline at end of file
diff --git a/tests/unit/api_contract/_api_checker/test_payments_global/_api_rest_business-areas_business-area-0_payments_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_payments_global/_api_rest_business-areas_business-area-0_payments_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
index 6c35bbe2627..fc31f3cf4e2 100644
--- a/tests/unit/api_contract/_api_checker/test_payments_global/_api_rest_business-areas_business-area-0_payments_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
+++ b/tests/unit/api_contract/_api_checker/test_payments_global/_api_rest_business-areas_business-area-0_payments_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -28,6 +28,8 @@
"hoh_phone_no_alternative": "None",
"snapshot_collector_full_name": null,
"snapshot_alternate_collector_full_name": null,
+ "snapshot_collector_full_name_latin": null,
+ "snapshot_alternate_collector_full_name_latin": null,
"snapshot_alternate_collector_id": null,
"fsp_name": "",
"entitlement_quantity": null,
diff --git a/tests/unit/api_contract/_api_checker/test_verification_records/_api_rest_business-areas_business-area-0_programs_1cpl_payment-verifications_cb35a199-c561-4206-a989-065b41e8f319_verifications_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_verification_records/_api_rest_business-areas_business-area-0_programs_1cpl_payment-verifications_cb35a199-c561-4206-a989-065b41e8f319_verifications_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
index 06797c59ac1..4c027e00c42 100644
--- a/tests/unit/api_contract/_api_checker/test_verification_records/_api_rest_business-areas_business-area-0_programs_1cpl_payment-verifications_cb35a199-c561-4206-a989-065b41e8f319_verifications_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
+++ b/tests/unit/api_contract/_api_checker/test_verification_records/_api_rest_business-areas_business-area-0_programs_1cpl_payment-verifications_cb35a199-c561-4206-a989-065b41e8f319_verifications_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -28,6 +28,8 @@
"hoh_phone_no_alternative": "None",
"snapshot_collector_full_name": null,
"snapshot_alternate_collector_full_name": null,
+ "snapshot_collector_full_name_latin": null,
+ "snapshot_alternate_collector_full_name_latin": null,
"snapshot_alternate_collector_id": null,
"fsp_name": "",
"entitlement_quantity": null,
@@ -50,7 +52,7 @@
"id": "6456ce98-dfb6-4a9b-9483-5777f9e4428c",
"received_amount": null,
"status": "PENDING",
- "payment_verification_plan_unicef_id": "PVP-2",
+ "payment_verification_plan_unicef_id": "PVP-1",
"verification_channel": "MANUAL",
"admin_url": null,
"version": 1735688653315200,
diff --git a/tests/unit/api_contract/_api_checker/test_verification_records/_api_rest_business-areas_business-area-0_programs_1cpl_payment-verifications_cb35a199-c561-4206-a989-065b41e8f319_verifications_f6730d02-ba17-4532-8d32-a3d456eb2768_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json b/tests/unit/api_contract/_api_checker/test_verification_records/_api_rest_business-areas_business-area-0_programs_1cpl_payment-verifications_cb35a199-c561-4206-a989-065b41e8f319_verifications_f6730d02-ba17-4532-8d32-a3d456eb2768_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
index 21d2e8521d2..72b204620b9 100644
--- a/tests/unit/api_contract/_api_checker/test_verification_records/_api_rest_business-areas_business-area-0_programs_1cpl_payment-verifications_cb35a199-c561-4206-a989-065b41e8f319_verifications_f6730d02-ba17-4532-8d32-a3d456eb2768_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
+++ b/tests/unit/api_contract/_api_checker/test_verification_records/_api_rest_business-areas_business-area-0_programs_1cpl_payment-verifications_cb35a199-c561-4206-a989-065b41e8f319_verifications_f6730d02-ba17-4532-8d32-a3d456eb2768_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json
@@ -24,6 +24,8 @@
"hoh_phone_no_alternative": "None",
"snapshot_collector_full_name": null,
"snapshot_alternate_collector_full_name": null,
+ "snapshot_collector_full_name_latin": null,
+ "snapshot_alternate_collector_full_name_latin": null,
"snapshot_alternate_collector_id": null,
"fsp_name": "",
"entitlement_quantity": null,
@@ -111,7 +113,8 @@
"unicef_id": "HH-25-0000.0001",
"head_of_household": {
"id": "93818285-317e-4789-adbe-2aed85095f26",
- "full_name": "Person 0"
+ "full_name": "Person 0",
+ "full_name_latin": null
},
"admin1": null,
"admin2": null,
@@ -254,6 +257,10 @@
"given_name": "",
"middle_name": "",
"family_name": "",
+ "full_name_latin": null,
+ "given_name_latin": null,
+ "middle_name_latin": null,
+ "family_name_latin": null,
"sex": "MALE",
"age": 35,
"birth_date": "1990-01-01",
diff --git a/tests/unit/api_contract/test_payments.py b/tests/unit/api_contract/test_payments.py
index 8b8de575942..946ef6a895f 100644
--- a/tests/unit/api_contract/test_payments.py
+++ b/tests/unit/api_contract/test_payments.py
@@ -4,11 +4,18 @@
import pytest
from unit.api_contract._helpers import HopeRecorder
-from extras.test_utils.factories.account import RoleAssignmentFactory, RoleFactory, UserFactory
-from extras.test_utils.factories.core import BusinessAreaFactory
-from extras.test_utils.factories.household import HouseholdFactory, IndividualRoleInHouseholdFactory
-from extras.test_utils.factories.payment import PaymentFactory, PaymentPlanFactory
-from extras.test_utils.factories.program import ProgramFactory
+from extras.test_utils.factories import (
+ BusinessAreaFactory,
+ HouseholdFactory,
+ IndividualRoleInHouseholdFactory,
+ PaymentFactory,
+ PaymentPlanFactory,
+ ProgramCycleFactory,
+ ProgramFactory,
+ RoleAssignmentFactory,
+ RoleFactory,
+ UserFactory,
+)
pytestmark = pytest.mark.django_db
@@ -45,6 +52,8 @@ def role_assignment(request, db, superuser, business_area, role):
@frozenfixture()
def payment_plan(request, db, business_area, program, superuser):
cycle = program.cycles.first()
+ if not cycle:
+ cycle = ProgramCycleFactory(program=program)
return PaymentPlanFactory(business_area=business_area, program_cycle=cycle, created_by=superuser)
diff --git a/tests/unit/api_contract/test_verification_records.py b/tests/unit/api_contract/test_verification_records.py
index e8cfd71c69b..d9d3c136f4a 100644
--- a/tests/unit/api_contract/test_verification_records.py
+++ b/tests/unit/api_contract/test_verification_records.py
@@ -5,6 +5,7 @@
import pytest
from unit.api_contract._helpers import HopeRecorder
+from extras.test_utils.factories import ProgramCycleFactory
from extras.test_utils.factories.account import RoleAssignmentFactory, RoleFactory, UserFactory
from extras.test_utils.factories.core import BusinessAreaFactory
from extras.test_utils.factories.household import HouseholdFactory, IndividualRoleInHouseholdFactory
@@ -52,6 +53,8 @@ def role_assignment(request, db, superuser, business_area, role):
@frozenfixture()
def payment_plan(request, db, business_area, program, superuser):
cycle = program.cycles.first()
+ if not cycle:
+ cycle = ProgramCycleFactory(program=program)
return PaymentPlanFactory(
business_area=business_area,
program_cycle=cycle,
diff --git a/tests/unit/apps/aurora/test_generic_registration_service.py b/tests/unit/apps/aurora/test_generic_registration_service.py
index b62596485de..22143a30743 100644
--- a/tests/unit/apps/aurora/test_generic_registration_service.py
+++ b/tests/unit/apps/aurora/test_generic_registration_service.py
@@ -160,7 +160,8 @@ def individual_with_bank_account_and_tax_and_disability() -> dict:
"bank_account_h_f": "y",
"relationship_i_c": "head",
"given_name_i_c": "Jan",
- "family_name_i_c": "Romaniak",
+ "given_name_latin_i_c": "Joniak",
+ "family_name_i_c": "Romaniąk",
"patronymic": "Roman",
"birth_date": "1991-11-18",
"gender_i_c": "male",
@@ -180,7 +181,7 @@ def individual_with_bank_account_and_tax() -> dict:
"bank_account_h_f": "y",
"relationship_i_c": "head",
"given_name_i_c": "Wiktor",
- "family_name_i_c": "Lamiący",
+ "family_name_i_c": "Lamiacy",
"patronymic": "Stefan",
"birth_date": "1991-11-18",
"gender_i_c": "male",
@@ -196,7 +197,8 @@ def individual_with_no_tax() -> dict:
"bank_account_h_f": "y",
"relationship_i_c": "head",
"given_name_i_c": "Michał",
- "family_name_i_c": "Brzęczący",
+ "given_name_latin_i_c": "Michal",
+ "family_name_i_c": "Brzeczacy",
"patronymic": "Janusz",
"birth_date": "1991-11-18",
"gender_i_c": "male",
@@ -215,7 +217,7 @@ def individual_without_bank_account() -> dict:
"relationship_i_c": "head",
"given_name_i_c": "Aleksiej",
"family_name_i_c": "Prysznicow",
- "patronymic": "Paweł",
+ "patronymic": "Pawel",
"birth_date": "1991-11-18",
"gender_i_c": "male",
"phone_no_i_c": "+393451212123",
@@ -232,7 +234,7 @@ def individual_with_tax_id_which_is_too_long() -> dict:
"relationship_i_c": "head",
"given_name_i_c": "Aleksiej",
"family_name_i_c": "Prysznicow",
- "patronymic": "Paweł",
+ "patronymic": "Pawel",
"birth_date": "1991-11-18",
"gender_i_c": "male",
"phone_no_i_c": "+393451214623",
@@ -399,8 +401,9 @@ def test_import_data_to_datahub_household_individual(
assert PendingDocument.objects.get(document_number="xyz", type__key="disability_certificate")
assert PendingIndividual.objects.get(
given_name="Jan",
+ given_name_latin="Joniak",
middle_name="Roman",
- family_name="Romaniak",
+ family_name="Romaniąk",
relationship="HEAD",
sex="MALE",
email="email123@mail.com",
@@ -410,6 +413,34 @@ def test_import_data_to_datahub_household_individual(
assert PendingIndividualRoleInHousehold.objects.filter(role=ROLE_PRIMARY).count() == 1
+def test_import_fills_latin_names_when_not_provided(
+ ukraine_admin_areas: dict,
+ document_types: dict,
+ registration: object,
+ user: object,
+ record_defaults: dict,
+ base_household: list[dict],
+ individual_with_bank_account_and_tax: dict,
+) -> None:
+ assert ukraine_admin_areas
+ assert document_types
+ record = RecordFactory(
+ **record_defaults,
+ source_id=20,
+ fields={"household": base_household, "individuals": [individual_with_bank_account_and_tax]},
+ files=json.dumps({}).encode(),
+ )
+ service = GenericRegistrationService(registration)
+ rdi = service.create_rdi(user, f"generic rdi latin {datetime.datetime.now()}")
+ service.process_records(rdi.id, [record.id])
+
+ individual = PendingIndividual.objects.get(given_name="Wiktor")
+ assert individual.given_name_latin == "Wiktor"
+ assert individual.middle_name_latin == "Stefan"
+ assert individual.family_name_latin == "Lamiacy"
+ assert individual.full_name_latin == "Wiktor Stefan Lamiacy"
+
+
def test_phone_number_validation_flags(
ukraine_admin_areas: dict,
document_types: dict,
diff --git a/tests/unit/apps/aurora/test_sri_lanka_registration_service.py b/tests/unit/apps/aurora/test_sri_lanka_registration_service.py
index b4878507464..cdd07cfd347 100644
--- a/tests/unit/apps/aurora/test_sri_lanka_registration_service.py
+++ b/tests/unit/apps/aurora/test_sri_lanka_registration_service.py
@@ -221,6 +221,27 @@ def test_import_data_to_datahub(
assert PendingIndividual.objects.filter(full_name="Dome", program=rdi.program).first().age_at_registration == 43
+@freeze_time("2023-12-12")
+def test_import_fills_latin_names(
+ registration: Any,
+ user: Any,
+ program: Program,
+ sri_lanka_country: Any,
+ national_id_document_type: Any,
+ sri_lanka_records: list[Record],
+) -> None:
+ service = SriLankaRegistrationService(registration)
+ rdi = service.create_rdi(user, f"sri_lanka rdi latin {datetime.datetime.now()}")
+ service.process_records(rdi.id, [record.id for record in sri_lanka_records])
+
+ head = PendingIndividual.objects.filter(relationship="HEAD").first()
+ assert head.full_name_latin == "Alexis"
+ collector = PendingIndividual.objects.get(full_name="Dome")
+ assert collector.full_name_latin == "Dome"
+ child = PendingIndividual.objects.get(relationship="SON_DAUGHTER")
+ assert child.full_name_latin == "Alexis"
+
+
def test_import_record_twice(
registration: Any,
user: Any,
diff --git a/tests/unit/apps/core/test_files/kobo-template-valid.xlsx b/tests/unit/apps/core/test_files/kobo-template-valid.xlsx
index 0f65e3556f1..e7e4eaa82e8 100644
Binary files a/tests/unit/apps/core/test_files/kobo-template-valid.xlsx and b/tests/unit/apps/core/test_files/kobo-template-valid.xlsx differ
diff --git a/tests/unit/apps/core/test_kobo_template_upload.py b/tests/unit/apps/core/test_kobo_template_upload.py
index 98011272baf..5ce38650eeb 100644
--- a/tests/unit/apps/core/test_kobo_template_upload.py
+++ b/tests/unit/apps/core/test_kobo_template_upload.py
@@ -101,6 +101,10 @@ def test_upload_invalid_template_returns_expected_errors(
"Field: relationship_i_c - Choice: OTHER is not present in the file",
"Field: relationship_i_c - Choice: FOSTER_CHILD is not present in the file",
"Field: relationship_i_c - Choice: FREE_UNION is not present in the file",
+ "Field: full_name_latin_i_c - Field is missing",
+ "Field: given_name_latin_i_c - Field is missing",
+ "Field: middle_name_latin_i_c - Field is missing",
+ "Field: family_name_latin_i_c - Field is missing",
"Field: marital_status_i_c - Choice: MARRIED is not present in the file",
"Field: marital_status_i_c - Choice: WRONG_CHOICE is not present in HOPE",
"Field: currency_h_c - Choice: BOV is not present in the file",
@@ -129,7 +133,8 @@ def test_upload_invalid_template_returns_expected_errors(
"Field: program_registration_id_h_c - Field is missing",
]
}
- assert form.errors == expected_errors
+ form_errors = {field: [e["message"] for e in errs] for field, errs in form.errors.get_json_data().items()}
+ assert form_errors == expected_errors
@patch(
diff --git a/tests/unit/apps/grievance/services/data_change/test_add_individual_service.py b/tests/unit/apps/grievance/services/data_change/test_add_individual_service.py
index 75c9b149246..30fb0d310b5 100644
--- a/tests/unit/apps/grievance/services/data_change/test_add_individual_service.py
+++ b/tests/unit/apps/grievance/services/data_change/test_add_individual_service.py
@@ -287,6 +287,21 @@ def test_handle_add_identity(add_individual_context: dict[str, Any], program: Pr
assert identity_obj.country == poland
+def test_close_sets_latin_names_on_created_individual(add_individual_context: dict[str, Any], user: User) -> None:
+ ticket = add_individual_context["ticket"]
+ ticket_details = add_individual_context["ticket_details"]
+ ticket_details.individual_data["transliterate_latin_names"] = True
+ ticket_details.save()
+
+ service = AddIndividualService(ticket, {})
+ service.close(user)
+
+ individual = Individual.objects.get(household=add_individual_context["household"], full_name="Test Example")
+ assert individual.full_name_latin == "Test Example"
+ assert individual.given_name_latin == "Test"
+ assert individual.family_name_latin == "Example"
+
+
def test_close_without_approval_creates_no_individual(
unapproved_add_individual_context: dict[str, Any], user: User
) -> None:
diff --git a/tests/unit/apps/grievance/services/data_change/test_update_individual_data_service.py b/tests/unit/apps/grievance/services/data_change/test_update_individual_data_service.py
index dfd3e75d8fd..36675191940 100644
--- a/tests/unit/apps/grievance/services/data_change/test_update_individual_data_service.py
+++ b/tests/unit/apps/grievance/services/data_change/test_update_individual_data_service.py
@@ -640,6 +640,61 @@ def test_update_phone_no_data(update_context: dict[str, Any]) -> None:
assert update_context["individual"].phone_no_alternative == "+485544334455"
+def test_close_recomputes_latin_name_on_name_change(update_context: dict[str, Any]) -> None:
+ individual = update_context["individual"]
+ individual.full_name_latin = "Stale Latin"
+ individual.save(update_fields=["full_name_latin"])
+ update_context["ticket"].individual_data_update_ticket_details.individual_data = {
+ "full_name": {"approve_status": True, "previous_value": individual.full_name, "value": "John Smith"},
+ }
+ update_context["ticket"].individual_data_update_ticket_details.save()
+ service = IndividualDataUpdateService(
+ update_context["ticket"], update_context["ticket"].individual_data_update_ticket_details
+ )
+
+ service.close(update_context["user"])
+
+ individual.refresh_from_db()
+ assert individual.full_name == "John Smith"
+ assert individual.full_name_latin == "John Smith"
+
+
+def test_close_ignores_transliterate_flag_entry(update_context: dict[str, Any]) -> None:
+ individual = update_context["individual"]
+ update_context["ticket"].individual_data_update_ticket_details.individual_data = {
+ "full_name": {"approve_status": True, "previous_value": individual.full_name, "value": "John Smith"},
+ "transliterate_latin_names": {"approve_status": True, "previous_value": None, "value": True},
+ }
+ update_context["ticket"].individual_data_update_ticket_details.save()
+ service = IndividualDataUpdateService(
+ update_context["ticket"], update_context["ticket"].individual_data_update_ticket_details
+ )
+
+ service.close(update_context["user"])
+
+ individual.refresh_from_db()
+ assert individual.full_name == "John Smith"
+ assert individual.full_name_latin == "John Smith"
+
+
+def test_close_keeps_explicitly_approved_latin_name(update_context: dict[str, Any]) -> None:
+ individual = update_context["individual"]
+ update_context["ticket"].individual_data_update_ticket_details.individual_data = {
+ "full_name": {"approve_status": True, "previous_value": individual.full_name, "value": "Анна Ковальська"},
+ "full_name_latin": {"approve_status": True, "previous_value": None, "value": "Anna Kovalska"},
+ }
+ update_context["ticket"].individual_data_update_ticket_details.save()
+ service = IndividualDataUpdateService(
+ update_context["ticket"], update_context["ticket"].individual_data_update_ticket_details
+ )
+
+ service.close(update_context["user"])
+
+ individual.refresh_from_db()
+ assert individual.full_name == "Анна Ковальська"
+ assert individual.full_name_latin == "Anna Kovalska"
+
+
def test_close_individual_update_without_household(update_context: dict[str, Any]) -> None:
individual_without_household = IndividualFactory(
business_area=update_context["business_area"],
diff --git a/tests/unit/apps/grievance/test_grievance_create_data_change.py b/tests/unit/apps/grievance/test_grievance_create_data_change.py
index 2cba4eb3137..423beb18c90 100644
--- a/tests/unit/apps/grievance/test_grievance_create_data_change.py
+++ b/tests/unit/apps/grievance/test_grievance_create_data_change.py
@@ -231,6 +231,7 @@ def test_grievance_create_individual_data_change(
f"{extra_path}individual_data.given_name": "Test",
f"{extra_path}individual_data.full_name": "Test Test",
f"{extra_path}individual_data.family_name": "Romaniak",
+ f"{extra_path}individual_data.transliterate_latin_names": True,
f"{extra_path}individual_data.sex": "MALE",
f"{extra_path}individual_data.birth_date": "1980-02-01",
f"{extra_path}individual_data.marital_status": SINGLE,
@@ -270,6 +271,7 @@ def test_grievance_update_individual_data_change(
f"{extra_path}individual": str(grievance_context["individual"].id),
f"{extra_path}individual_data.given_name": "Test",
f"{extra_path}individual_data.full_name": "Test Test",
+ f"{extra_path}individual_data.transliterate_latin_names": True,
f"{extra_path}individual_data.sex": "MALE",
f"{extra_path}individual_data.birth_date": date(year=1980, month=2, day=1).isoformat(),
f"{extra_path}individual_data.marital_status": SINGLE,
diff --git a/tests/unit/apps/grievance/test_grievance_detail.py b/tests/unit/apps/grievance/test_grievance_detail.py
index 3352ab89322..962abc67502 100644
--- a/tests/unit/apps/grievance/test_grievance_detail.py
+++ b/tests/unit/apps/grievance/test_grievance_detail.py
@@ -355,6 +355,7 @@ def assert_base_grievance_data(
"head_of_household": {
"id": str(household.head_of_household.id),
"full_name": household.head_of_household.full_name,
+ "full_name_latin": None,
},
"active_individuals_count": household.active_individuals.count(),
}
@@ -433,6 +434,7 @@ def assert_base_grievance_data(
"id": str(individual.id),
"unicef_id": individual.unicef_id,
"full_name": individual.full_name,
+ "full_name_latin": None,
"program_code": individual.program.code,
"household": {
"id": str(individual.household.id),
@@ -1344,6 +1346,7 @@ def test_grievance_detail_system_flagging(
"id": str(golden_records_individual.id),
"unicef_id": golden_records_individual.unicef_id,
"full_name": golden_records_individual.full_name,
+ "full_name_latin": None,
"birth_date": f"{golden_records_individual.birth_date:%Y-%m-%d}",
"last_registration_date": f"{golden_records_individual.last_registration_date:%Y-%m-%d}",
"sex": golden_records_individual.sex,
@@ -1698,6 +1701,7 @@ def test_grievance_detail_needs_adjudication(
"id": str(golden_records_individual.id),
"unicef_id": golden_records_individual.unicef_id,
"full_name": golden_records_individual.full_name,
+ "full_name_latin": None,
"given_name": golden_records_individual.given_name,
"family_name": golden_records_individual.family_name,
"phone_no": str(golden_records_individual.phone_no),
@@ -1810,6 +1814,7 @@ def test_grievance_detail_needs_adjudication(
"id": str(individuals2[0].id),
"unicef_id": individuals2[0].unicef_id,
"full_name": individuals2[0].full_name,
+ "full_name_latin": None,
"given_name": individuals2[0].given_name,
"family_name": individuals2[0].family_name,
"phone_no": str(individuals2[0].phone_no),
@@ -1890,6 +1895,7 @@ def test_grievance_detail_needs_adjudication(
"id": str(individuals2[0].id),
"unicef_id": individuals2[0].unicef_id,
"full_name": individuals2[0].full_name,
+ "full_name_latin": None,
"given_name": individuals2[0].given_name,
"family_name": individuals2[0].family_name,
"phone_no": str(individuals2[0].phone_no),
@@ -1986,6 +1992,7 @@ def test_grievance_detail_needs_adjudication(
"head_of_household": {
"id": str(duplicate.household.head_of_household.id),
"full_name": duplicate.household.head_of_household.full_name,
+ "full_name_latin": duplicate.household.head_of_household.full_name_latin,
},
"residence_status": duplicate.household.get_residence_status_display(),
"size": duplicate.household.size,
@@ -2035,6 +2042,7 @@ def test_grievance_detail_needs_adjudication(
"id": str(duplicate.id),
"unicef_id": duplicate.unicef_id,
"full_name": duplicate.full_name,
+ "full_name_latin": None,
"birth_date": f"{duplicate.birth_date:%Y-%m-%d}",
"last_registration_date": f"{duplicate.last_registration_date:%Y-%m-%d}",
"sex": duplicate.sex,
diff --git a/tests/unit/apps/grievance/test_grievance_filters.py b/tests/unit/apps/grievance/test_grievance_filters.py
index f861466fb6a..fb82f1e9401 100644
--- a/tests/unit/apps/grievance/test_grievance_filters.py
+++ b/tests/unit/apps/grievance/test_grievance_filters.py
@@ -151,6 +151,7 @@ def households_and_individuals(
household=None,
preferred_language="pl",
full_name="Tom Smith",
+ full_name_latin="Tomas Kovalsky",
relationship=HEAD,
)
household1 = HouseholdFactory(
@@ -1594,6 +1595,7 @@ def test_filter_by_cross_area(
("HH-0001", 4, 4),
("IND-0002", 2, 5),
("Tom", 4, 4),
+ ("Kovalsky", 4, 4),
("GRV-9918515", 0, 0),
("", 6, 9),
],
diff --git a/tests/unit/apps/grievance/test_grievance_list_global.py b/tests/unit/apps/grievance/test_grievance_list_global.py
index 92b8f58b485..251264042d4 100644
--- a/tests/unit/apps/grievance/test_grievance_list_global.py
+++ b/tests/unit/apps/grievance/test_grievance_list_global.py
@@ -885,7 +885,7 @@ def test_all_edit_people_fields_attributes(
data = response.json()
assert isinstance(data, list)
- assert len(data) == 43
+ assert len(data) == 47
first_field = data[0]
assert "id" in first_field
@@ -928,7 +928,7 @@ def test_all_add_individuals_fields_attributes(
data = response.json()
assert isinstance(data, list)
- assert len(data) == 27
+ assert len(data) == 31
first_field = data[0]
assert "id" in first_field
diff --git a/tests/unit/apps/grievance/test_grievance_list_global_office_search.py b/tests/unit/apps/grievance/test_grievance_list_global_office_search.py
index 463aac978f0..0809fe92c21 100644
--- a/tests/unit/apps/grievance/test_grievance_list_global_office_search.py
+++ b/tests/unit/apps/grievance/test_grievance_list_global_office_search.py
@@ -958,6 +958,42 @@ def test_search_by_individual_name(
assert response.data["results"][0]["id"] == str(complaint_ticket2.id)
+def test_search_by_individual_latin_name(
+ api_client: Any,
+ user: User,
+ afghanistan: BusinessArea,
+ program: Program,
+ tickets: dict,
+ households_and_individuals: dict,
+ create_user_role_with_permissions: Callable,
+) -> None:
+ create_user_role_with_permissions(
+ user,
+ [Permissions.GRIEVANCES_VIEW_LIST_EXCLUDING_SENSITIVE],
+ afghanistan,
+ program=program,
+ )
+
+ individuals3 = households_and_individuals["individuals3"]
+ complaint_ticket2 = tickets["complaint_ticket2"]
+
+ individuals3[0].full_name = "Анна Ковальська"
+ individuals3[0].full_name_latin = "Anna Kovalska"
+ individuals3[0].save()
+
+ client = api_client(user)
+ response = client.get(
+ reverse(
+ "api:grievance:grievance-tickets-global-list",
+ kwargs={"business_area_slug": afghanistan.slug},
+ ),
+ {"office_search": "Anna Kovalska"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data["results"]) == 1
+ assert response.data["results"][0]["id"] == str(complaint_ticket2.id)
+
+
def test_search_with_active_programs_filter(
api_client: Any,
user: User,
diff --git a/tests/unit/apps/grievance/test_grievance_ticket_create.py b/tests/unit/apps/grievance/test_grievance_ticket_create.py
index 7422fe756f8..596ed97d266 100644
--- a/tests/unit/apps/grievance/test_grievance_ticket_create.py
+++ b/tests/unit/apps/grievance/test_grievance_ticket_create.py
@@ -191,6 +191,7 @@ def test_create_grievance_ticket_add_individual(
"given_name": "Test",
"full_name": "Test Test",
"family_name": "Romaniak",
+ "transliterate_latin_names": True,
"sex": "MALE",
"birth_date": date(year=1980, month=2, day=1).isoformat(),
"marital_status": SINGLE,
@@ -309,6 +310,7 @@ def test_create_grievance_ticket_update_individual(
"individual": str(household_with_individuals["individuals"][0].pk),
"individual_data": {
"full_name": "New full_name",
+ "transliterate_latin_names": True,
"accounts_to_edit": [
{
"id": str(account_data["account"].pk),
@@ -362,6 +364,7 @@ def test_create_grievance_ticket_update_individual_with_document(
household_with_individuals["individuals"][0].pk
),
"extras.issue_type.individual_data_update_issue_type_extras.individual_data.full_name": "New full_name",
+ "extras.issue_type.individual_data_update_issue_type_extras.individual_data.transliterate_latin_names": True,
"documentation[0].file": fake_file,
"documentation[0].name": fake_file.name,
}
@@ -788,6 +791,7 @@ def test_create_grievance_ticket_add_individual_with_photo(
"extras.issue_type.add_individual_issue_type_extras.individual_data.given_name": "PhotoTest",
"extras.issue_type.add_individual_issue_type_extras.individual_data.full_name": "PhotoTest Test",
"extras.issue_type.add_individual_issue_type_extras.individual_data.family_name": "Test",
+ "extras.issue_type.add_individual_issue_type_extras.individual_data.transliterate_latin_names": True,
"extras.issue_type.add_individual_issue_type_extras.individual_data.sex": "MALE",
"extras.issue_type.add_individual_issue_type_extras.individual_data.birth_date": date(
year=1980, month=2, day=1
diff --git a/tests/unit/apps/grievance/test_latin_names_ticket_validation.py b/tests/unit/apps/grievance/test_latin_names_ticket_validation.py
new file mode 100644
index 00000000000..530747becce
--- /dev/null
+++ b/tests/unit/apps/grievance/test_latin_names_ticket_validation.py
@@ -0,0 +1,57 @@
+from typing import Any
+
+import pytest
+
+from hope.apps.grievance.api.serializers.grievance_ticket import (
+ AddIndividualDataSerializer,
+ IndividualUpdateDataSerializer,
+)
+
+
+@pytest.fixture
+def add_individual_payload() -> dict[str, Any]:
+ return {
+ "full_name": "Анна Ковальська",
+ "sex": "FEMALE",
+ "birth_date": "1990-01-01",
+ "estimated_birth_date": False,
+ "relationship": "HEAD",
+ }
+
+
+@pytest.mark.parametrize(
+ ("extra", "is_valid"),
+ [
+ ({"transliterate_latin_names": True}, True),
+ ({"full_name_latin": "Anna Kovalska"}, True),
+ ({}, False),
+ ({"transliterate_latin_names": False}, False),
+ ],
+)
+def test_add_individual_requires_latin_or_transliteration_flag(
+ add_individual_payload: dict[str, Any], extra: dict[str, Any], is_valid: bool
+) -> None:
+ serializer = AddIndividualDataSerializer(data={**add_individual_payload, **extra})
+
+ assert serializer.is_valid() is is_valid
+ if not is_valid:
+ assert "full_name_latin" in str(serializer.errors)
+
+
+@pytest.mark.parametrize(
+ ("data", "is_valid"),
+ [
+ ({"given_name": "Анна", "transliterate_latin_names": True}, True),
+ ({"given_name": "Анна", "given_name_latin": "Anna"}, True),
+ ({"given_name": "Анна"}, False),
+ ({"given_name": "Анна", "full_name_latin": "Anna"}, False),
+ ({"phone_no": "+48123123123"}, True),
+ ({}, True),
+ ],
+)
+def test_individual_update_requires_latin_or_transliteration_flag(data: dict[str, Any], is_valid: bool) -> None:
+ serializer = IndividualUpdateDataSerializer(data=data)
+
+ assert serializer.is_valid() is is_valid
+ if not is_valid:
+ assert "given_name_latin" in str(serializer.errors)
diff --git a/tests/unit/apps/household/test_household_filter_views.py b/tests/unit/apps/household/test_household_filter_views.py
index 808ca99d53a..9c07c0feb7d 100644
--- a/tests/unit/apps/household/test_household_filter_views.py
+++ b/tests/unit/apps/household/test_household_filter_views.py
@@ -1065,6 +1065,62 @@ def test_search(
assert response_data[0]["id"] == str(expected_results[0].id)
+@pytest.mark.xdist_group(name="elasticsearch")
+@override_config(IS_ELASTICSEARCH_ENABLED=True, ES_USE_LATIN_NAMES=True)
+@pytest.mark.elasticsearch
+@pytest.mark.usefixtures("django_elasticsearch_setup")
+def test_search_matches_hoh_latin_name_when_flag_is_on(
+ household_filter_search_context: dict[str, Any],
+) -> None:
+ response_data, expected_results = _test_search(
+ filters={"search": "Anna Kovalska"},
+ household1_data={},
+ household2_data={},
+ hoh_1_data={"full_name": "Анна Ковальська", "full_name_latin": "Anna Kovalska"},
+ hoh_2_data={"full_name": "Юрій Шевченко", "full_name_latin": "Yuriy Shevchenko"},
+ household_filter_search_context=household_filter_search_context,
+ )
+ assert len(response_data) == 1
+ assert response_data[0]["id"] == str(expected_results[0].id)
+
+
+@pytest.mark.xdist_group(name="elasticsearch")
+@override_config(IS_ELASTICSEARCH_ENABLED=True, ES_USE_LATIN_NAMES=False)
+@pytest.mark.elasticsearch
+@pytest.mark.usefixtures("django_elasticsearch_setup")
+def test_search_ignores_hoh_latin_name_when_flag_is_off(
+ household_filter_search_context: dict[str, Any],
+) -> None:
+ response_data, _ = _test_search(
+ filters={"search": "Anna Kovalska"},
+ household1_data={},
+ household2_data={},
+ hoh_1_data={"full_name": "Анна Ковальська", "full_name_latin": "Anna Kovalska"},
+ hoh_2_data={"full_name": "Юрій Шевченко", "full_name_latin": "Yuriy Shevchenko"},
+ household_filter_search_context=household_filter_search_context,
+ )
+ assert response_data == []
+
+
+def test_search_db_matches_hoh_latin_name(
+ household_filter_search_context: dict[str, Any],
+) -> None:
+ program = household_filter_search_context["program"]
+ program.status = Program.FINISHED
+ program.save()
+
+ response_data, expected_results = _test_search(
+ filters={"search": "Anna Kovalska"},
+ household1_data={},
+ household2_data={},
+ hoh_1_data={"full_name": "Анна Ковальська", "full_name_latin": "Anna Kovalska"},
+ hoh_2_data={"full_name": "Юрій Шевченко", "full_name_latin": "Yuriy Shevchenko"},
+ household_filter_search_context=household_filter_search_context,
+ )
+ assert len(response_data) == 1
+ assert response_data[0]["id"] == str(expected_results[0].id)
+
+
@pytest.mark.parametrize(*parametrize_search_context)
def test_search_db(
filters: Dict,
diff --git a/tests/unit/apps/household/test_household_global_views.py b/tests/unit/apps/household/test_household_global_views.py
index 1b16fbb9dcd..4b632acc0a8 100644
--- a/tests/unit/apps/household/test_household_global_views.py
+++ b/tests/unit/apps/household/test_household_global_views.py
@@ -822,6 +822,32 @@ def test_search_by_member_given_name(
assert response.data["results"][0]["id"] == str(household_office_search_context["household4"].id)
+def test_search_by_member_latin_name(
+ create_user_role_with_permissions: Any, household_office_search_context: dict[str, Any]
+) -> None:
+ create_user_role_with_permissions(
+ user=household_office_search_context["user"],
+ permissions=[Permissions.POPULATION_VIEW_HOUSEHOLDS_LIST],
+ business_area=household_office_search_context["afghanistan"],
+ program=household_office_search_context["program"],
+ )
+
+ household_office_search_context["individuals3"][0].full_name = "Анна Ковальська"
+ household_office_search_context["individuals3"][0].full_name_latin = "Anna Kovalska"
+ household_office_search_context["individuals3"][0].save(update_fields=["full_name", "full_name_latin"])
+
+ response = household_office_search_context["api_client"].get(
+ reverse(
+ household_office_search_context["global_url_name"],
+ kwargs={"business_area_slug": household_office_search_context["afghanistan"].slug},
+ ),
+ {"office_search": "Anna Kovalska"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data["results"]) == 1
+ assert response.data["results"][0]["id"] == str(household_office_search_context["household3"].id)
+
+
def test_search_with_active_programs_filter(
create_user_role_with_permissions: Any, household_office_search_context: dict[str, Any]
) -> None:
diff --git a/tests/unit/apps/household/test_household_list_views.py b/tests/unit/apps/household/test_household_list_views.py
index 8cf04b90c42..3ce8b683356 100644
--- a/tests/unit/apps/household/test_household_list_views.py
+++ b/tests/unit/apps/household/test_household_list_views.py
@@ -491,6 +491,7 @@ def test_household_all_accountability_communication_message_recipients(
"head_of_household": {
"id": str(household_list_context["household1"].head_of_household.pk),
"full_name": household_list_context["household1"].head_of_household.full_name,
+ "full_name_latin": None,
},
"admin2": {
"id": str(household_list_context["household1"].admin2.pk),
@@ -676,6 +677,7 @@ def test_household_detail_with_permissions(
assert data["head_of_household"] == {
"id": str(individuals[0].id),
"full_name": individuals[0].full_name,
+ "full_name_latin": None,
}
assert data["admin1"] == {
"id": str(household.admin1.id),
diff --git a/tests/unit/apps/household/test_household_members_views.py b/tests/unit/apps/household/test_household_members_views.py
index 75e64c3e2ca..010c9eb92b5 100644
--- a/tests/unit/apps/household/test_household_members_views.py
+++ b/tests/unit/apps/household/test_household_members_views.py
@@ -167,6 +167,7 @@ def test_household_members(create_user_role_with_permissions: Any, household_mem
"id": str(individual1_1.id),
"unicef_id": individual1_1.unicef_id,
"full_name": individual1_1.full_name,
+ "full_name_latin": None,
"role": "PRIMARY",
"relationship": individual1_1.relationship,
"status": individual1_1.status,
@@ -200,6 +201,7 @@ def test_household_members(create_user_role_with_permissions: Any, household_mem
"id": str(individual1_2.id),
"unicef_id": individual1_2.unicef_id,
"full_name": individual1_2.full_name,
+ "full_name_latin": None,
"role": None,
"relationship": individual1_2.relationship,
"status": individual1_2.status,
@@ -233,6 +235,7 @@ def test_household_members(create_user_role_with_permissions: Any, household_mem
"id": str(individual2_1.id),
"unicef_id": individual2_1.unicef_id,
"full_name": individual2_1.full_name,
+ "full_name_latin": None,
"role": "ALTERNATE",
"relationship": individual2_1.relationship,
"status": individual2_1.status,
diff --git a/tests/unit/apps/household/test_individual_filter_views.py b/tests/unit/apps/household/test_individual_filter_views.py
index ba6a92d8241..a40868f98a8 100644
--- a/tests/unit/apps/household/test_individual_filter_views.py
+++ b/tests/unit/apps/household/test_individual_filter_views.py
@@ -560,6 +560,57 @@ def test_search(
assert response_data[0]["id"] == str(individuals[1].id)
+@pytest.mark.xdist_group(name="elasticsearch")
+@override_config(IS_ELASTICSEARCH_ENABLED=True, ES_USE_LATIN_NAMES=True)
+@pytest.mark.elasticsearch
+@pytest.mark.usefixtures("django_elasticsearch_setup")
+def test_search_matches_latin_name_when_flag_is_on(
+ search_client: Any,
+ list_url: str,
+ afghanistan: BusinessArea,
+ program: Program,
+) -> None:
+ response_data, individuals = _test_search(
+ search_client,
+ list_url,
+ afghanistan,
+ program,
+ {"search": "Yuriy Shevchenko"},
+ {"full_name": "Анна Ковальська", "full_name_latin": "Anna Kovalska"},
+ {"full_name": "Юрій Шевченко", "full_name_latin": "Yuriy Shevchenko"},
+ {},
+ {},
+ is_elasticsearch_enabled=True,
+ )
+ assert len(response_data) == 1
+ assert response_data[0]["id"] == str(individuals[1].id)
+
+
+@pytest.mark.xdist_group(name="elasticsearch")
+@override_config(IS_ELASTICSEARCH_ENABLED=True, ES_USE_LATIN_NAMES=False)
+@pytest.mark.elasticsearch
+@pytest.mark.usefixtures("django_elasticsearch_setup")
+def test_search_ignores_latin_name_when_flag_is_off(
+ search_client: Any,
+ list_url: str,
+ afghanistan: BusinessArea,
+ program: Program,
+) -> None:
+ response_data, _ = _test_search(
+ search_client,
+ list_url,
+ afghanistan,
+ program,
+ {"search": "Yuriy Shevchenko"},
+ {"full_name": "Анна Ковальська", "full_name_latin": "Anna Kovalska"},
+ {"full_name": "Юрій Шевченко", "full_name_latin": "Yuriy Shevchenko"},
+ {},
+ {},
+ is_elasticsearch_enabled=True,
+ )
+ assert response_data == []
+
+
@pytest.mark.parametrize(
("filters", "individual1_data", "individual2_data", "household1_data", "household2_data"),
[
diff --git a/tests/unit/apps/household/test_individual_global_views.py b/tests/unit/apps/household/test_individual_global_views.py
index b96fa2f356d..763a1f21cc8 100644
--- a/tests/unit/apps/household/test_individual_global_views.py
+++ b/tests/unit/apps/household/test_individual_global_views.py
@@ -480,6 +480,27 @@ def test_search_by_individual_unicef_id(
assert response.data["results"][0]["id"] == str(ctx["individuals1"][0].id)
+def test_search_by_individual_latin_name(
+ office_search_context: dict, create_user_role_with_permissions: Callable
+) -> None:
+ ctx = office_search_context
+ create_user_role_with_permissions(
+ user=ctx["user"],
+ permissions=[Permissions.POPULATION_VIEW_INDIVIDUALS_LIST],
+ business_area=ctx["afghanistan"],
+ program=ctx["program"],
+ )
+ individual = ctx["individuals1"][0]
+ individual.full_name = "Анна Ковальська"
+ individual.full_name_latin = "Anna Kovalska"
+ individual.save(update_fields=["full_name", "full_name_latin"])
+
+ response = ctx["client"].get(_global_url(ctx["afghanistan"]), {"office_search": "Anna Kovalska"})
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data["results"]) == 1
+ assert response.data["results"][0]["id"] == str(individual.id)
+
+
def test_search_by_household_unicef_id(
office_search_context: dict, create_user_role_with_permissions: Callable
) -> None:
diff --git a/tests/unit/apps/household/test_models.py b/tests/unit/apps/household/test_models.py
index c98f21647cd..3e8056102e3 100644
--- a/tests/unit/apps/household/test_models.py
+++ b/tests/unit/apps/household/test_models.py
@@ -1,5 +1,6 @@
from datetime import timedelta
+from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.utils import timezone
import pytest
@@ -389,6 +390,73 @@ def test_facility_str(business_area: BusinessArea, area_hierarchy: tuple[Area, A
assert str(facility) == "TEST FACILITY"
+def test_individual_erase(business_area: BusinessArea) -> None:
+ individual = IndividualFactory(
+ business_area=business_area,
+ full_name="FullName",
+ given_name="G_Name",
+ middle_name="M_Name",
+ family_name="F_Name",
+ full_name_latin="LatinFull",
+ given_name_latin="LatinGiven",
+ middle_name_latin="MLatin",
+ family_name_latin="Family latin",
+ )
+ individual.erase()
+ assert individual.full_name == "GDPR REMOVED"
+ assert individual.given_name == "GDPR REMOVED"
+ assert individual.middle_name == "GDPR REMOVED"
+ assert individual.family_name == "GDPR REMOVED"
+ assert individual.full_name_latin == "GDPR REMOVED"
+ assert individual.given_name_latin == "GDPR REMOVED"
+ assert individual.middle_name_latin == "GDPR REMOVED"
+ assert individual.family_name_latin == "GDPR REMOVED"
+
+
+def test_individual_set_latin_names(business_area: BusinessArea) -> None:
+ individual = IndividualFactory(
+ business_area=business_area, full_name="甜的 針 昏迷", given_name="甜的", middle_name="針", family_name="昏迷"
+ )
+ individual.set_names_latin()
+ assert individual.full_name_latin == "Tian De Zhen Hun Mi"
+ assert individual.given_name_latin == "Tian De"
+ assert individual.middle_name_latin == "Zhen"
+ assert individual.family_name_latin == "Hun Mi"
+
+
+def test_individual_set_latin_names_full_name(business_area: BusinessArea) -> None:
+ individual = IndividualFactory(
+ business_area=business_area, given_name="عبد الملك", middle_name="جولر", family_name="الفرامل"
+ )
+ # calculate based on first, middle, last names
+ individual.full_name = None
+ individual.set_names_latin()
+ assert individual.full_name_latin == "Bd Lmlk Jwlr Lfrml"
+ assert individual.given_name_latin == "Bd Lmlk"
+ assert individual.middle_name_latin == "Jwlr"
+ assert individual.family_name_latin == "Lfrml"
+
+ # provide full name latin
+ individual_2 = IndividualFactory(
+ business_area=business_area,
+ full_name_latin="Provided Latin Name",
+ given_name="عبد الملك",
+ middle_name="جولر",
+ family_name="الفرامل",
+ )
+ individual_2.set_names_latin()
+ assert individual_2.full_name_latin == "Provided Latin Name"
+
+
+def test_individual_set_latin_names_validation_error(business_area: BusinessArea) -> None:
+ individual = IndividualFactory(business_area=business_area, full_name="2222222")
+ with pytest.raises(ValidationError) as error:
+ individual.set_names_latin()
+
+ assert individual.full_name_latin is None
+ assert "Only ASCII letters, spaces, hyphens, and apostrophes are allowed." in str(error.value)
+
+
# --- DocumentType ---
diff --git a/tests/unit/apps/one_time_scripts/test_migrate_latin_names.py b/tests/unit/apps/one_time_scripts/test_migrate_latin_names.py
new file mode 100644
index 00000000000..8952575698d
--- /dev/null
+++ b/tests/unit/apps/one_time_scripts/test_migrate_latin_names.py
@@ -0,0 +1,82 @@
+import json
+from typing import Any
+
+import pytest
+
+from extras.test_utils.factories import IndividualFactory, ProgramFactory
+from hope.models import Individual, Program
+from hope.one_time_scripts.migrate_latin_names import migrate_to_latin_names
+
+pytestmark = pytest.mark.django_db
+
+
+@pytest.fixture
+def program() -> Program:
+ return ProgramFactory()
+
+
+@pytest.fixture
+def individual_missing_latin(program: Program) -> Individual:
+ individual = IndividualFactory(
+ program=program,
+ business_area=program.business_area,
+ given_name="Анна",
+ family_name="Ковальська",
+ full_name="Анна Ковальська",
+ )
+ Individual.all_objects.filter(pk=individual.pk).update(
+ full_name_latin=None,
+ given_name_latin=None,
+ middle_name_latin=None,
+ family_name_latin="Keep Me",
+ )
+ return individual
+
+
+@pytest.fixture
+def individual_with_broken_name(program: Program) -> Individual:
+ individual = IndividualFactory(
+ program=program,
+ business_area=program.business_area,
+ given_name="12345",
+ family_name="!!!",
+ full_name="12345 !!!",
+ )
+ Individual.all_objects.filter(pk=individual.pk).update(
+ full_name_latin=None,
+ given_name_latin=None,
+ middle_name_latin=None,
+ family_name_latin=None,
+ )
+ return individual
+
+
+def test_migrate_fills_missing_latin_and_keeps_existing(individual_missing_latin: Individual, tmp_path: Any) -> None:
+ migrate_to_latin_names(failures_path=str(tmp_path / "failures.jsonl"))
+
+ individual_missing_latin.refresh_from_db()
+ assert individual_missing_latin.full_name_latin
+ assert individual_missing_latin.full_name_latin.isascii()
+ assert individual_missing_latin.given_name_latin
+ assert individual_missing_latin.given_name_latin.isascii()
+ assert individual_missing_latin.family_name_latin == "Keep Me"
+
+
+def test_migrate_skips_broken_record_and_reports_it(
+ individual_missing_latin: Individual,
+ individual_with_broken_name: Individual,
+ tmp_path: Any,
+) -> None:
+ failures_path = tmp_path / "failures.jsonl"
+
+ migrate_to_latin_names(failures_path=str(failures_path))
+
+ individual_missing_latin.refresh_from_db()
+ individual_with_broken_name.refresh_from_db()
+ assert individual_missing_latin.full_name_latin
+ assert individual_with_broken_name.given_name_latin is None
+ failures = [json.loads(line) for line in failures_path.read_text().splitlines()]
+ assert len(failures) == 1
+ assert failures[0]["individual_id"] == str(individual_with_broken_name.pk)
+ assert failures[0]["business_area"] == individual_with_broken_name.program.business_area.slug
+ assert failures[0]["reason"]
diff --git a/tests/unit/apps/payment/test_payment_global_view.py b/tests/unit/apps/payment/test_payment_global_view.py
index 3217fe75ac2..7872416b80a 100644
--- a/tests/unit/apps/payment/test_payment_global_view.py
+++ b/tests/unit/apps/payment/test_payment_global_view.py
@@ -595,6 +595,30 @@ def test_search_by_individual_name(
assert response.data["results"][0]["id"] == str(office_search_setup["payment3"].id)
+def test_search_by_individual_latin_name(
+ create_user_role_with_permissions: Any,
+ office_search_setup: dict,
+) -> None:
+ create_user_role_with_permissions(
+ user=office_search_setup["user"],
+ permissions=[Permissions.PM_VIEW_DETAILS],
+ business_area=office_search_setup["business_area"],
+ program=office_search_setup["program"],
+ )
+
+ office_search_setup["individuals3"][0].full_name = "Анна Ковальська"
+ office_search_setup["individuals3"][0].full_name_latin = "Anna Kovalska"
+ office_search_setup["individuals3"][0].save()
+
+ response = office_search_setup["client"].get(
+ office_search_setup["global_url"],
+ {"office_search": "Anna Kovalska"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data["results"]) == 1
+ assert response.data["results"][0]["id"] == str(office_search_setup["payment3"].id)
+
+
def test_search_with_active_programs_filter(
create_user_role_with_permissions: Any,
office_search_setup: dict,
diff --git a/tests/unit/apps/payment/test_payment_plan_global_viewset.py b/tests/unit/apps/payment/test_payment_plan_global_viewset.py
index 5aa91771466..59e8c0fa7d2 100644
--- a/tests/unit/apps/payment/test_payment_plan_global_viewset.py
+++ b/tests/unit/apps/payment/test_payment_plan_global_viewset.py
@@ -587,6 +587,30 @@ def test_search_by_individual_name(
assert response.data["results"][0]["id"] == str(office_search_setup["payment_plan3"].id)
+def test_search_by_individual_latin_name(
+ create_user_role_with_permissions: Any,
+ office_search_setup: dict,
+) -> None:
+ create_user_role_with_permissions(
+ user=office_search_setup["user"],
+ permissions=[Permissions.PM_VIEW_LIST],
+ business_area=office_search_setup["business_area"],
+ whole_business_area_access=True,
+ )
+
+ office_search_setup["individuals3"][0].full_name = "Анна Ковальська"
+ office_search_setup["individuals3"][0].full_name_latin = "Anna Kovalska"
+ office_search_setup["individuals3"][0].save()
+
+ response = office_search_setup["client"].get(
+ office_search_setup["global_url"],
+ {"office_search": "Anna Kovalska"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert len(response.data["results"]) == 1
+ assert response.data["results"][0]["id"] == str(office_search_setup["payment_plan3"].id)
+
+
def test_search_with_active_programs_filter(
create_user_role_with_permissions: Any,
office_search_setup: dict,
diff --git a/tests/unit/apps/payment/test_payment_view.py b/tests/unit/apps/payment/test_payment_view.py
index 7f4d3cd502b..590dac9f874 100644
--- a/tests/unit/apps/payment/test_payment_view.py
+++ b/tests/unit/apps/payment/test_payment_view.py
@@ -379,6 +379,29 @@ def test_filter_by_collector_full_name(
)
+def test_filter_by_collector_latin_full_name(
+ payment_context: dict[str, Any],
+ create_user_role_with_permissions: Any,
+) -> None:
+ create_user_role_with_permissions(
+ payment_context["user"],
+ [Permissions.PM_VIEW_DETAILS],
+ payment_context["business_area"],
+ payment_context["program_active"],
+ )
+ collector = payment_context["payment"].collector
+ collector.full_name = "Анна Ковальська"
+ collector.full_name_latin = "Anna Kovalska"
+ collector.save(update_fields=["full_name", "full_name_latin"])
+
+ response = payment_context["client"].get(payment_context["url_list"] + "?collector_full_name=Anna Kov")
+
+ assert response.status_code == status.HTTP_200_OK
+ resp_data = response.json()
+ assert len(resp_data["results"]) == 1
+ assert resp_data["results"][0]["household_unicef_id"] == payment_context["payment"].household.unicef_id
+
+
def test_filter_by_payment_unicef_id(
payment_context: dict[str, Any],
create_user_role_with_permissions: Any,
diff --git a/tests/unit/apps/payment/test_serializers.py b/tests/unit/apps/payment/test_serializers.py
index afd806d6fdf..d71e7f0f87a 100644
--- a/tests/unit/apps/payment/test_serializers.py
+++ b/tests/unit/apps/payment/test_serializers.py
@@ -240,6 +240,7 @@ def test_pending_payment_serializer_all_data(pending_payment_context: dict[str,
assert data["head_of_household"] == {
"id": str(payment.head_of_household.id),
"full_name": f"{payment.head_of_household.full_name}",
+ "full_name_latin": None,
"unicef_id": payment.head_of_household.unicef_id,
}
assert data["household_size"] == 2
diff --git a/tests/unit/apps/registration_data/test_file/error-xlsx.xlsx b/tests/unit/apps/registration_data/test_file/error-xlsx.xlsx
index a473b0954b1..0280d2aa31a 100644
Binary files a/tests/unit/apps/registration_data/test_file/error-xlsx.xlsx and b/tests/unit/apps/registration_data/test_file/error-xlsx.xlsx differ
diff --git a/tests/unit/apps/registration_data/test_file/invalid_area.xlsx b/tests/unit/apps/registration_data/test_file/invalid_area.xlsx
index 7ce8785b3ae..a0e21e43ec5 100644
Binary files a/tests/unit/apps/registration_data/test_file/invalid_area.xlsx and b/tests/unit/apps/registration_data/test_file/invalid_area.xlsx differ
diff --git a/tests/unit/apps/registration_data/test_file/invalid_rows.xlsx b/tests/unit/apps/registration_data/test_file/invalid_rows.xlsx
index b4cccbac956..1b79ab2a19b 100644
Binary files a/tests/unit/apps/registration_data/test_file/invalid_rows.xlsx and b/tests/unit/apps/registration_data/test_file/invalid_rows.xlsx differ
diff --git a/tests/unit/apps/registration_data/test_file/kobo_submissions.json b/tests/unit/apps/registration_data/test_file/kobo_submissions.json
index 46f17dadc79..71b659dc188 100644
--- a/tests/unit/apps/registration_data/test_file/kobo_submissions.json
+++ b/tests/unit/apps/registration_data/test_file/kobo_submissions.json
@@ -21,6 +21,7 @@
"individual_questions/role_i_c": "primary",
"individual_questions/age": "60",
"individual_questions/given_name_i_c": "Test",
+ "individual_questions/given_name_latin_i_c": "TestLatin",
"individual_questions/first_registration_date_i_c": "2020-08-11",
"individual_questions/gender_i_c": "male",
"individual_questions/more_information/marital_status_i_c": "married",
@@ -31,7 +32,7 @@
"individual_questions/full_name_i_c": "Test Testowski",
"individual_questions/relationship_i_c": "head",
"individual_questions/individual_vulnerabilities/work_status_i_c": "1",
- "individual_questions/family_name_i_c": "Testowski",
+ "individual_questions/family_name_i_c": "Testowśki",
"individual_questions/individual_vulnerabilities/disability_i_c": "not disabled",
"individual_questions/more_information/birth_certificate_no_i_c": "123123123",
"individual_questions/more_information/birth_certificate_issuer_i_c": "AFG",
@@ -44,7 +45,11 @@
"individual_questions/bank_info/bank_branch_name_i_c": "Privat 2",
"individual_questions/account__mobile__service_provider_i_c": "ABD",
"individual_questions/account__mobile__delivery_phone_number_i_c": "+48880110457",
- "individual_questions/account__mobile__provider_i_c": "ALFA"
+ "individual_questions/account__mobile__provider_i_c": "ALFA",
+ "individual_questions/full_name_local_i_c": "Full_local",
+ "individual_questions/given_name_local_i_c": "Given_local",
+ "individual_questions/middle_name_local_i_c": "Middle_local",
+ "individual_questions/family_name_local_i_c": "Family_local"
},
{
"individual_questions/role_i_c": "alternate",
diff --git a/tests/unit/apps/registration_data/test_file/new_reg_data_import.xlsx b/tests/unit/apps/registration_data/test_file/new_reg_data_import.xlsx
index 47c1c562171..ebdc799e18b 100644
Binary files a/tests/unit/apps/registration_data/test_file/new_reg_data_import.xlsx and b/tests/unit/apps/registration_data/test_file/new_reg_data_import.xlsx differ
diff --git a/tests/unit/apps/registration_data/test_file/rdi_people_test.xlsx b/tests/unit/apps/registration_data/test_file/rdi_people_test.xlsx
index 589af071d32..c14b4414968 100644
Binary files a/tests/unit/apps/registration_data/test_file/rdi_people_test.xlsx and b/tests/unit/apps/registration_data/test_file/rdi_people_test.xlsx differ
diff --git a/tests/unit/apps/registration_data/test_file/rdi_people_test_invalid.xlsx b/tests/unit/apps/registration_data/test_file/rdi_people_test_invalid.xlsx
index ed08fb052c3..bab7fbf478e 100644
Binary files a/tests/unit/apps/registration_data/test_file/rdi_people_test_invalid.xlsx and b/tests/unit/apps/registration_data/test_file/rdi_people_test_invalid.xlsx differ
diff --git a/tests/unit/apps/registration_data/test_kobo_validators_methods.py b/tests/unit/apps/registration_data/test_kobo_validators_methods.py
index 06b13d8a84b..9e117ed8ea6 100644
--- a/tests/unit/apps/registration_data/test_kobo_validators_methods.py
+++ b/tests/unit/apps/registration_data/test_kobo_validators_methods.py
@@ -168,6 +168,10 @@
"individual_questions/gender_i_c": "male",
"individual_questions/individual_vulnerabilities/disability_i_c": "not disabled",
"individual_questions/full_name_i_c": "Test Testowy",
+ "individual_questions/full_name_latin_i_c": "TestL Full TestowyL",
+ "individual_questions/given_name_latin_i_c": "TestL",
+ "individual_questions/middle_name_latin_i_c": "TestL",
+ "individual_questions/family_name_latin_i_c": "TestL",
"individual_questions/is_only_collector": "NO",
"individual_questions/mas_treatment_i_f": "1",
"individual_questions/arm_picture_i_f": "signature-17_32_52.png",
@@ -324,6 +328,7 @@
"individual_questions/individual_vulnerabilities/observed_disability_i_f": "memory",
"individual_questions/individual_vulnerabilities/wellbeing_index/active_h_f": "1",
"individual_questions/family_name_i_c": "Testowski",
+ "individual_questions/full_name_latin_i_c": "33333",
"individual_questions/individual_vulnerabilities/wellbeing_index/interested_h_f": "4",
"individual_questions/individual_index": "1",
"individual_questions/full_name_i_c": "Test Testowski",
@@ -792,6 +797,11 @@ def test_validate_everything(
"header": "facility_admin_area_h_c",
"message": "Area with code: AF777 does not exist",
},
+ {
+ "header": "full_name_latin_i_c",
+ "message": "invalid_name, Only ASCII letters, spaces, hyphens, and apostrophes are allowed., "
+ "Value provided: 33333",
+ },
{
"header": "preferred_language_i_c",
"message": "Invalid choice test for field preferred_language_i_c",
diff --git a/tests/unit/apps/registration_data/test_rdi_kobo_create.py b/tests/unit/apps/registration_data/test_rdi_kobo_create.py
index d4470f33f5f..630b19cd3a5 100644
--- a/tests/unit/apps/registration_data/test_rdi_kobo_create.py
+++ b/tests/unit/apps/registration_data/test_rdi_kobo_create.py
@@ -195,12 +195,26 @@ def test_execute(
individuals_obj_data = model_to_dict(
individual,
- ("country", "sex", "age", "marital_status", "relationship"),
+ (
+ "country",
+ "sex",
+ "age",
+ "marital_status",
+ "relationship",
+ "full_name_latin",
+ "given_name_latin",
+ "middle_name_latin",
+ "family_name_latin",
+ ),
)
expected_ind = {
"relationship": "HEAD",
"sex": "MALE",
"marital_status": "MARRIED",
+ "full_name_latin": "Test Testowski",
+ "given_name_latin": "TestLatin",
+ "middle_name_latin": None,
+ "family_name_latin": "Testowski",
}
assert individuals_obj_data == expected_ind
diff --git a/tests/unit/apps/registration_data/test_rdi_people_create.py b/tests/unit/apps/registration_data/test_rdi_people_create.py
index 47273fadf1d..46e9ec5c8a0 100644
--- a/tests/unit/apps/registration_data/test_rdi_people_create.py
+++ b/tests/unit/apps/registration_data/test_rdi_people_create.py
@@ -237,10 +237,10 @@ def test_execute(
assert PendingIndividual.objects.count() == 5
individual_data = {
- "full_name": "Derek Index4",
+ "full_name": "Derek IndexFour",
"given_name": "Derek",
"middle_name": "",
- "family_name": "Index4",
+ "family_name": "IndexFour",
"sex": "MALE",
"relationship": "HEAD",
"birth_date": date(2000, 8, 22),
@@ -267,12 +267,12 @@ def test_execute(
assert roles.count() == 2
primary_role = roles.get(role=ROLE_PRIMARY)
assert primary_role.role == ROLE_PRIMARY
- assert primary_role.individual.full_name == "Derek Index4"
+ assert primary_role.individual.full_name == "Derek IndexFour"
alternate_role = roles.get(role=ROLE_ALTERNATE)
assert alternate_role.role == ROLE_ALTERNATE
- assert alternate_role.individual.full_name == "Collector ForJanIndex_3"
+ assert alternate_role.individual.full_name == "Collector ForJanIndexThree"
assert alternate_role.individual.flex_fields["custom_field_i_f"] == 2.99
- ind_2 = PendingIndividual.objects.get(full_name="Jan Index3")
+ ind_2 = PendingIndividual.objects.get(full_name="Jan IndexThree")
hh_2 = ind_2.household
assert hh_2.facility.name == "NEW SCHOOL 23"
@@ -286,9 +286,9 @@ def test_execute(
assert worker.household is None
assert PendingAccount.objects.count() == 3
- dmd1 = PendingAccount.objects.get(individual__full_name="Collector ForJanIndex_3")
- dmd2 = PendingAccount.objects.get(individual__full_name="WorkerCollector ForDerekIndex_4")
- dmd3 = PendingAccount.objects.get(individual__full_name="Jan Index3")
+ dmd1 = PendingAccount.objects.get(individual__full_name="Collector ForJanIndexThree")
+ dmd2 = PendingAccount.objects.get(individual__full_name="WorkerCollector ForDerekIndexFour")
+ dmd3 = PendingAccount.objects.get(individual__full_name="Jan IndexThree")
assert dmd1.rdi_merge_status == MergeStatusModel.PENDING
assert dmd2.rdi_merge_status == MergeStatusModel.PENDING
assert dmd3.rdi_merge_status == MergeStatusModel.PENDING
@@ -323,7 +323,7 @@ def test_execute_sets_phone_no_valid_on_imported_individual(
program.id,
)
- individual = PendingIndividual.objects.get(full_name="Derek Index4")
+ individual = PendingIndividual.objects.get(full_name="Derek IndexFour")
assert str(individual.phone_no) == "+48605899013"
assert individual.phone_no_valid is True
diff --git a/tests/unit/apps/registration_data/test_rdi_xlsx_create.py b/tests/unit/apps/registration_data/test_rdi_xlsx_create.py
index 75c6d39b331..e0039fa2a01 100644
--- a/tests/unit/apps/registration_data/test_rdi_xlsx_create.py
+++ b/tests/unit/apps/registration_data/test_rdi_xlsx_create.py
@@ -290,6 +290,10 @@ def test_execute(
"birth_date": date(1963, 2, 3),
"marital_status": "MARRIED",
"email": "fake_email_123@mail.com",
+ "given_name_latin": "Some",
+ "middle_name_latin": "MiddleLatin",
+ "family_name_latin": "Name",
+ "full_name_latin": "Some Full Name",
}
matching_individuals = PendingIndividual.objects.filter(**individual_data)
assert matching_individuals.count() == 1
diff --git a/tests/unit/apps/registration_data/test_template_file_generator.py b/tests/unit/apps/registration_data/test_template_file_generator.py
index cc01612a85c..c3b5c1ac086 100644
--- a/tests/unit/apps/registration_data/test_template_file_generator.py
+++ b/tests/unit/apps/registration_data/test_template_file_generator.py
@@ -105,16 +105,16 @@ def test_add_template_columns(program: Program) -> None:
assert people_rows[0][10] == "pp_admin3_i_c"
assert people_rows[1][10] == "Social Worker resides in which admin3? - SELECT_ONE"
- assert people_rows[0][19] == "pp_middle_name_i_c"
- assert people_rows[1][19] == "Middle name(s) - STRING"
+ assert people_rows[0][21] == "pp_middle_name_i_c"
+ assert people_rows[1][21] == "Middle name(s) - STRING"
- assert people_rows[0][39] == "pp_drivers_license_issuer_i_c"
- assert people_rows[1][39] == "Issuing country of driver's license - SELECT_ONE"
+ assert people_rows[0][43] == "pp_drivers_license_issuer_i_c"
+ assert people_rows[1][43] == "Issuing country of driver's license - SELECT_ONE"
- assert people_rows[0][69] == "pp_village_i_c"
- assert people_rows[1][69] == "Village - STRING"
+ assert people_rows[0][73] == "pp_village_i_c"
+ assert people_rows[1][73] == "Village - STRING"
- assert people_rows[0][89] == "pdu_flex_attribute_round_1_collection_date"
+ assert people_rows[0][93] == "pdu_flex_attribute_round_1_collection_date"
- assert people_rows[0][83] == "pp_index_id"
- assert people_rows[1][83] == "Index ID - INTEGER - required"
+ assert people_rows[0][87] == "pp_index_id"
+ assert people_rows[1][87] == "Index ID - INTEGER - required"
diff --git a/tests/unit/apps/registration_data/test_xlsx_upload_validators_methods.py b/tests/unit/apps/registration_data/test_xlsx_upload_validators_methods.py
index 7d2711a5e8c..1148be7836d 100644
--- a/tests/unit/apps/registration_data/test_xlsx_upload_validators_methods.py
+++ b/tests/unit/apps/registration_data/test_xlsx_upload_validators_methods.py
@@ -475,6 +475,12 @@ def test_rows_validator(
"message": "Sheet: 'Individuals', Unexpected value: TestInvalid for "
"type select one of field preferred_language_i_c",
},
+ {
+ "row_number": 4,
+ "header": "full_name_latin_i_c",
+ "message": "invalid_name, Only ASCII letters, spaces, hyphens, and apostrophes are allowed., "
+ "Value provided: 222222",
+ },
{
"row_number": 8,
"header": "relationship_i_c",
diff --git a/uv.lock b/uv.lock
index 564a7e4b5ec..4fdce1fdca4 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 2
requires-python = "==3.14.*"
[[package]]
@@ -2066,6 +2066,8 @@ dependencies = [
{ name = "swapper" },
{ name = "tblib" },
{ name = "tomli" },
+ { name = "transliterate" },
+ { name = "unidecode" },
{ name = "update" },
{ name = "urllib3" },
{ name = "weasyprint" },
@@ -2253,6 +2255,8 @@ requires-dist = [
{ name = "swapper", specifier = ">=1.3,<2" },
{ name = "tblib", specifier = ">=1.7,<4" },
{ name = "tomli", specifier = ">=2.0.1,<3" },
+ { name = "transliterate", specifier = ">=1.10.2" },
+ { name = "unidecode", specifier = ">=1.4" },
{ name = "update", specifier = ">=0.0.1,<1" },
{ name = "urllib3", specifier = ">=1.26.9,<3" },
{ name = "weasyprint", specifier = ">=68" },
@@ -4857,6 +4861,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" },
]
+[[package]]
+name = "transliterate"
+version = "1.10.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/88/e3/84a89c289a5cf00c7aeabfb5a862a6e4cdc25819701cf1c454a18d32ac33/transliterate-1.10.2.tar.gz", hash = "sha256:bc608e0d48e687db9c2b1d7ea7c381afe0d1849cad216087d8e03d8d06a57c85", size = 43088, upload-time = "2018-09-17T20:52:37.984Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a1/6e/9a9d597dbdd6d0172427c8cc07c35736471e631060df9e59eeb87687f817/transliterate-1.10.2-py2.py3-none-any.whl", hash = "sha256:010a5021bf6021689c4fade0985f3f7b3db1f2f16a48a09a56797f171c08ed42", size = 45780, upload-time = "2018-09-17T20:52:40.332Z" },
+]
+
[[package]]
name = "trio"
version = "0.34.0"
@@ -5058,6 +5074,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/7f/757df777aaa82320175a46015d67b5f074ba47e73c031e1226a931cf5cbf/unicef_security-1.10.0-py2.py3-none-any.whl", hash = "sha256:1a5751024a10694b55f8963201c6c6f6045fb43e84a3036a41f73fe2f8ce9a66", size = 23321, upload-time = "2026-06-30T15:11:31.651Z" },
]
+[[package]]
+name = "unidecode"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" },
+]
+
[[package]]
name = "unittest-xml-reporting"
version = "4.0.0"