diff --git a/pyproject.toml b/pyproject.toml index e1d0374043b..9192ce1c802 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,6 +113,8 @@ dependencies = [ "swapper>=1.3,<2", "tblib>=1.7,<4", "tomli>=2.0.1,<3", + "transliterate>=1.10.2", + "unidecode>=1.4", "update>=0.0.1,<1", "urllib3>=1.26.9,<3", "weasyprint>=68", diff --git a/src/frontend/src/components/accountability/Feedback/HouseholdQuestionnaire/HouseholdQuestionnaire.tsx b/src/frontend/src/components/accountability/Feedback/HouseholdQuestionnaire/HouseholdQuestionnaire.tsx index ddf81dba1c9..8e6bf98852a 100644 --- a/src/frontend/src/components/accountability/Feedback/HouseholdQuestionnaire/HouseholdQuestionnaire.tsx +++ b/src/frontend/src/components/accountability/Feedback/HouseholdQuestionnaire/HouseholdQuestionnaire.tsx @@ -11,6 +11,7 @@ import { LoadingComponent } from '@components/core/LoadingComponent'; import { useQuery } from '@tanstack/react-query'; import { HouseholdDetail } from '@restgenerated/models/HouseholdDetail'; import { RestService } from '@restgenerated/services/RestService'; +import { displayNameWithLatin } from '@utils/utils'; import { restQueryKey } from '@utils/queryKeys'; interface HouseholdQuestionnaireProps { @@ -90,7 +91,7 @@ function HouseholdQuestionnaire({ - {selectedHouseholdData.headOfHousehold?.fullName} + {displayNameWithLatin(selectedHouseholdData.headOfHousehold, 'fullName')} ), size: 3, diff --git a/src/frontend/src/components/accountability/Feedback/IndividualQuestionnnaire/IndividualQuestionnaire.tsx b/src/frontend/src/components/accountability/Feedback/IndividualQuestionnnaire/IndividualQuestionnaire.tsx index 3d99f6695d8..8418e28d046 100644 --- a/src/frontend/src/components/accountability/Feedback/IndividualQuestionnnaire/IndividualQuestionnaire.tsx +++ b/src/frontend/src/components/accountability/Feedback/IndividualQuestionnnaire/IndividualQuestionnaire.tsx @@ -7,7 +7,7 @@ import { ContentLink } from '@core/ContentLink'; import { useProgramContext } from 'src/programContext'; import { ReactElement } from 'react'; import withErrorBoundary from '@components/core/withErrorBoundary'; -import { choicesToDict } from '@utils/utils'; +import { choicesToDict, displayNameWithLatin } from '@utils/utils'; import { RestService } from '@restgenerated/index'; import { restQueryKey } from '@utils/queryKeys'; import { IndividualChoices } from '@restgenerated/models/IndividualChoices'; @@ -50,7 +50,7 @@ const IndividualQuestionnaire = ({ - {selectedIndividualData.fullName} + {displayNameWithLatin(selectedIndividualData, 'fullName')} ), size: 3, diff --git a/src/frontend/src/components/core/ContentLink.tsx b/src/frontend/src/components/core/ContentLink.tsx index 215cb36819d..aea19157a8a 100644 --- a/src/frontend/src/components/core/ContentLink.tsx +++ b/src/frontend/src/components/core/ContentLink.tsx @@ -1,6 +1,6 @@ import styled from 'styled-components'; import { MiśTheme } from '../../theme'; -import { ReactElement } from 'react'; +import { ReactElement, ReactNode } from 'react'; interface LinkProps { fullWidth?: boolean; @@ -23,7 +23,7 @@ export function ContentLink({ fullWidth = false, }: { href: string; - children: string | ReactElement; + children: ReactNode; download?: boolean; fullWidth?: boolean; }): ReactElement { diff --git a/src/frontend/src/components/grievances/AddIndividualDataChange.tsx b/src/frontend/src/components/grievances/AddIndividualDataChange.tsx index 52b1b3b4469..25daca72739 100644 --- a/src/frontend/src/components/grievances/AddIndividualDataChange.tsx +++ b/src/frontend/src/components/grievances/AddIndividualDataChange.tsx @@ -9,6 +9,8 @@ import { FormikDateField } from '@shared/Formik/FormikDateField'; import { FormikFileField } from '@shared/Formik/FormikFileField'; import { FormikSelectField } from '@shared/Formik/FormikSelectField'; import { FormikTextField } from '@shared/Formik/FormikTextField'; +import { FormikCheckboxField } from '@shared/Formik/FormikCheckboxField'; +import { LATIN_NAME_FIELDS } from './utils/latinNames'; import { LoadingComponent } from '@core/LoadingComponent'; import { Title } from '@core/Title'; import { AgencyField } from './AgencyField'; @@ -188,7 +190,14 @@ function AddIndividualDataChange({ const flexFields = combinedData.results.filter((item) => item.isFlexField) || []; const coreFields = - combinedData.results.filter((item) => !item.isFlexField) || []; + combinedData.results.filter( + (item) => + !item.isFlexField && + !( + values.transliterateLatinNames && + LATIN_NAME_FIELDS.includes(item.name) + ), + ) || []; return ( !isEditTicket && ( <> @@ -203,6 +212,14 @@ function AddIndividualDataChange({ {coreFields.map((item) => ( ))} + + + {flexFields.length > 0 && {t('Flex Fields')}} {flexFields.map((item) => ( { let textValue = value; @@ -231,6 +234,13 @@ function AddIndividualGrievanceDetails({ )} + {transliterateLatinNames ? ( + + + {t('Latin names will be transliterated automatically')} + + + ) : null} {allLabels} diff --git a/src/frontend/src/components/grievances/EditIndividualDataChange/EditIndividualDataChange.tsx b/src/frontend/src/components/grievances/EditIndividualDataChange/EditIndividualDataChange.tsx index 8aa24a1077a..5487f7f8720 100644 --- a/src/frontend/src/components/grievances/EditIndividualDataChange/EditIndividualDataChange.tsx +++ b/src/frontend/src/components/grievances/EditIndividualDataChange/EditIndividualDataChange.tsx @@ -1,7 +1,7 @@ import { Box, Button, Grid, Typography } from '@mui/material'; import { AddCircleOutlined } from '@mui/icons-material'; import { useLocation } from 'react-router-dom'; -import { FieldArray } from 'formik'; +import { Field, FieldArray } from 'formik'; import { ReactElement, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; @@ -11,6 +11,8 @@ import { restQueryKey } from '@utils/queryKeys'; import { useBaseUrl } from '@hooks/useBaseUrl'; import { LoadingComponent } from '@core/LoadingComponent'; import { Title } from '@core/Title'; +import { FormikCheckboxField } from '@shared/Formik/FormikCheckboxField'; +import { hasNameFieldRow } from '@components/grievances/utils/latinNames'; import { EditIndividualDataChangeFieldRow } from './EditIndividualDataChangeFieldRow'; import { ExistingDocumentFieldArray } from './ExistingDocumentFieldArray'; import { ExistingIdentityFieldArray } from './ExistingIdentityFieldArray'; @@ -210,6 +212,16 @@ function EditIndividualDataChange({ {t('Add new field')} + {hasNameFieldRow(values.individualDataUpdateFields) && ( + + + + )} )} /> diff --git a/src/frontend/src/components/grievances/EditIndividualDataChange/EditIndividualDataChangeFieldRow.tsx b/src/frontend/src/components/grievances/EditIndividualDataChange/EditIndividualDataChangeFieldRow.tsx index 542afeba035..417b968f11f 100644 --- a/src/frontend/src/components/grievances/EditIndividualDataChange/EditIndividualDataChangeFieldRow.tsx +++ b/src/frontend/src/components/grievances/EditIndividualDataChange/EditIndividualDataChangeFieldRow.tsx @@ -8,6 +8,7 @@ import { FormikSelectField } from '@shared/Formik/FormikSelectField'; import { EditIndividualDataChangeField } from './EditIndividualDataChangeField'; import { CurrentValue } from './CurrentValue'; import { IndividualDetail } from '@restgenerated/models/IndividualDetail'; +import { LATIN_NAME_FIELDS } from '../utils/latinNames'; export interface EditIndividualDataChangeFieldRowProps { fields; @@ -58,8 +59,12 @@ export const EditIndividualDataChangeFieldRow = ({ choices={fields .filter( (item) => - !notAvailableFields.includes(item.name) || - item.name === itemValue?.fieldName, + item.name === itemValue?.fieldName || + (!notAvailableFields.includes(item.name) && + !( + values.transliterateLatinNames && + LATIN_NAME_FIELDS.includes(item.name) + )), ) .map((item) => ({ value: item.name, diff --git a/src/frontend/src/components/grievances/EditPeopleDataChange/EditPeopleDataChange.tsx b/src/frontend/src/components/grievances/EditPeopleDataChange/EditPeopleDataChange.tsx index cff0aefe2a7..875ba37ca3c 100644 --- a/src/frontend/src/components/grievances/EditPeopleDataChange/EditPeopleDataChange.tsx +++ b/src/frontend/src/components/grievances/EditPeopleDataChange/EditPeopleDataChange.tsx @@ -8,11 +8,13 @@ import { IndividualList } from '@restgenerated/models/IndividualList'; import { RestService } from '@restgenerated/services/RestService'; import { restQueryKey } from '@utils/queryKeys'; import { useQuery } from '@tanstack/react-query'; -import { FieldArray } from 'formik'; +import { Field, FieldArray } from 'formik'; import { ReactElement, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation } from 'react-router-dom'; import styled from 'styled-components'; +import { FormikCheckboxField } from '@shared/Formik/FormikCheckboxField'; +import { hasNameFieldRow } from '@components/grievances/utils/latinNames'; import { EditPeopleDataChangeFieldRow } from './EditPeopleDataChangeFieldRow'; import { ExistingDocumentFieldArray } from '@components/grievances/EditIndividualDataChange/ExistingDocumentFieldArray'; import { NewDocumentFieldArray } from '@components/grievances/EditIndividualDataChange/NewDocumentFieldArray'; @@ -194,6 +196,16 @@ function EditPeopleDataChange({ {t('Add new field')} + {hasNameFieldRow(values.individualDataUpdateFields) && ( + + + + )} )} /> diff --git a/src/frontend/src/components/grievances/EditPeopleDataChange/EditPeopleDataChangeFieldRow.tsx b/src/frontend/src/components/grievances/EditPeopleDataChange/EditPeopleDataChangeFieldRow.tsx index 4faf4c1c1ef..b7132dcc509 100644 --- a/src/frontend/src/components/grievances/EditPeopleDataChange/EditPeopleDataChangeFieldRow.tsx +++ b/src/frontend/src/components/grievances/EditPeopleDataChange/EditPeopleDataChangeFieldRow.tsx @@ -8,6 +8,7 @@ import { FormikSelectField } from '@shared/Formik/FormikSelectField'; import { EditPeopleDataChangeField } from './EditPeopleDataChangeField'; import { CurrentValue } from './CurrentValue'; import { IndividualDetail } from '@restgenerated/models/IndividualDetail'; +import { LATIN_NAME_FIELDS } from '../utils/latinNames'; export interface EditPeopleDataChangeFieldRowProps { fields: any[]; @@ -58,8 +59,12 @@ export const EditPeopleDataChangeFieldRow = ({ choices={fields .filter( (item) => - !notAvailableFields.includes(item.name) || - item.name === itemValue?.fieldName, + item.name === itemValue?.fieldName || + (!notAvailableFields.includes(item.name) && + !( + values.transliterateLatinNames && + LATIN_NAME_FIELDS.includes(item.name) + )), ) .map((item) => ({ value: item.name, diff --git a/src/frontend/src/components/grievances/FlagDetails.tsx b/src/frontend/src/components/grievances/FlagDetails.tsx index 4f18d036922..70e0fe65864 100644 --- a/src/frontend/src/components/grievances/FlagDetails.tsx +++ b/src/frontend/src/components/grievances/FlagDetails.tsx @@ -8,6 +8,7 @@ import { TableRow, Typography, } from '@mui/material'; +import { displayNameWithLatin } from '@utils/utils'; import styled from 'styled-components'; import moment from 'moment'; import { useTranslation } from 'react-i18next'; @@ -136,7 +137,7 @@ export const FlagDetails = ({ - - {details.goldenRecordsIndividual.fullName} + {displayNameWithLatin(details.goldenRecordsIndividual, 'fullName')} diff --git a/src/frontend/src/components/grievances/HouseholdQuestionnaire/HouseholdQuestionnaire.tsx b/src/frontend/src/components/grievances/HouseholdQuestionnaire/HouseholdQuestionnaire.tsx index 865f6614e3d..1dddfa169ff 100644 --- a/src/frontend/src/components/grievances/HouseholdQuestionnaire/HouseholdQuestionnaire.tsx +++ b/src/frontend/src/components/grievances/HouseholdQuestionnaire/HouseholdQuestionnaire.tsx @@ -11,6 +11,7 @@ import withErrorBoundary from '@components/core/withErrorBoundary'; import { HouseholdDetail } from '@restgenerated/models/HouseholdDetail'; import { useQuery } from '@tanstack/react-query'; import { RestService } from '@restgenerated/services/RestService'; +import { displayNameWithLatin } from '@utils/utils'; import { restQueryKey } from '@utils/queryKeys'; interface HouseholdQuestionnaireProps { @@ -93,7 +94,7 @@ function HouseholdQuestionnaire({ - {selectedHouseholdData.headOfHousehold.fullName} + {displayNameWithLatin(selectedHouseholdData.headOfHousehold, 'fullName')} ) : ( '-' diff --git a/src/frontend/src/components/grievances/IndividualQuestionnnaire/IndividualQuestionnaire.tsx b/src/frontend/src/components/grievances/IndividualQuestionnnaire/IndividualQuestionnaire.tsx index 295944893d9..50bb8bc3065 100644 --- a/src/frontend/src/components/grievances/IndividualQuestionnnaire/IndividualQuestionnaire.tsx +++ b/src/frontend/src/components/grievances/IndividualQuestionnnaire/IndividualQuestionnaire.tsx @@ -11,7 +11,7 @@ import { RestService } from '@restgenerated/index'; import { restQueryKey } from '@utils/queryKeys'; import { IndividualChoices } from '@restgenerated/models/IndividualChoices'; import { useQuery } from '@tanstack/react-query'; -import { choicesToDict } from '@utils/utils'; +import { choicesToDict, displayNameWithLatin } from '@utils/utils'; interface IndividualQuestionnaireProps { values; @@ -49,7 +49,7 @@ const IndividualQuestionnaire = ({ - {selectedIndividualData.fullName} + {displayNameWithLatin(selectedIndividualData, 'fullName')} ), size: 3, @@ -123,7 +123,7 @@ const IndividualQuestionnaire = ({ - {selectedIndividualData.fullName} + {displayNameWithLatin(selectedIndividualData, 'fullName')} ), size: 3, diff --git a/src/frontend/src/components/grievances/LookUps/LookUpDelegate/LookUpDelegateDisplay.tsx b/src/frontend/src/components/grievances/LookUps/LookUpDelegate/LookUpDelegateDisplay.tsx index 0202b55e90d..19c9b9016c0 100644 --- a/src/frontend/src/components/grievances/LookUps/LookUpDelegate/LookUpDelegateDisplay.tsx +++ b/src/frontend/src/components/grievances/LookUps/LookUpDelegate/LookUpDelegateDisplay.tsx @@ -3,6 +3,7 @@ import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import { useTranslation } from 'react-i18next'; import { BlueText, DarkGrey, LightGrey, StyledBox } from '../LookUpStyles'; +import { displayNameWithLatin } from '@utils/utils'; import { ReactElement } from 'react'; interface LookUpDelegateDisplayProps { @@ -37,7 +38,7 @@ export const LookUpDelegateDisplay = ({ {t('Delegate')}: {values.selectedDelegate?.unicefId || - values.selectedDelegate?.fullName || + displayNameWithLatin(values.selectedDelegate, 'fullName') || '-'} diff --git a/src/frontend/src/components/grievances/LookUps/LookUpIndividualTable/LookUpIndividualTableRow.tsx b/src/frontend/src/components/grievances/LookUps/LookUpIndividualTable/LookUpIndividualTableRow.tsx index 5378138033f..c9faa9223be 100644 --- a/src/frontend/src/components/grievances/LookUps/LookUpIndividualTable/LookUpIndividualTableRow.tsx +++ b/src/frontend/src/components/grievances/LookUps/LookUpIndividualTable/LookUpIndividualTableRow.tsx @@ -5,7 +5,7 @@ import { useBaseUrl } from '@hooks/useBaseUrl'; import { Radio } from '@mui/material'; import TableCell from '@mui/material/TableCell'; import { IndividualList } from '@restgenerated/models/IndividualList'; -import { sexToCapitalize } from '@utils/utils'; +import { displayNameWithLatin, sexToCapitalize } from '@utils/utils'; import { ReactElement } from 'react'; import { useProgramContext } from 'src/programContext'; @@ -54,7 +54,7 @@ export function LookUpIndividualTableRow({ {individual.unicefId || '-'} )} - {individual.fullName} + {displayNameWithLatin(individual, 'fullName')} {!isSocialDctType && ( {individual.household ? individual.household.unicefId : '-'} diff --git a/src/frontend/src/components/grievances/NeedsAdjudication/NeedsAdjudicationTable.tsx b/src/frontend/src/components/grievances/NeedsAdjudication/NeedsAdjudicationTable.tsx index 92d0655e78b..83ed1e490cd 100644 --- a/src/frontend/src/components/grievances/NeedsAdjudication/NeedsAdjudicationTable.tsx +++ b/src/frontend/src/components/grievances/NeedsAdjudication/NeedsAdjudicationTable.tsx @@ -1,5 +1,6 @@ import { ChangeEvent } from 'react'; import { BlackLink } from '@core/BlackLink'; +import { displayNameWithLatin } from '@utils/utils'; import { UniversalMoment } from '@core/UniversalMoment'; import PeopleIcon from '@mui/icons-material/People'; import PersonIcon from '@mui/icons-material/Person'; @@ -157,7 +158,7 @@ export const NeedsAdjudicationTable = ({ )} - {possibleDuplicate?.fullName} + {displayNameWithLatin(possibleDuplicate, 'fullName')} {possibleDuplicate?.sex} diff --git a/src/frontend/src/components/grievances/RequestedIndividualDataChange.tsx b/src/frontend/src/components/grievances/RequestedIndividualDataChange.tsx index 69a8b286d69..37cfc71231e 100644 --- a/src/frontend/src/components/grievances/RequestedIndividualDataChange.tsx +++ b/src/frontend/src/components/grievances/RequestedIndividualDataChange.tsx @@ -71,6 +71,9 @@ export function RequestedIndividualDataChange({ delete individualData.accounts; delete individualData.accounts_to_edit; + // UX flag, not an approvable field change + delete individualData.transliterateLatinNames; + const entries = Object.entries(individualData); const entriesFlexFields = Object.entries(flexFields); allApprovedCount += documents.filter((el) => el.approveStatus).length; diff --git a/src/frontend/src/components/grievances/RequestedIndividualDataChangeTable/RequestedIndividualDataChangeTable.tsx b/src/frontend/src/components/grievances/RequestedIndividualDataChangeTable/RequestedIndividualDataChangeTable.tsx index 96e0e4d250f..50a45f76500 100644 --- a/src/frontend/src/components/grievances/RequestedIndividualDataChangeTable/RequestedIndividualDataChangeTable.tsx +++ b/src/frontend/src/components/grievances/RequestedIndividualDataChangeTable/RequestedIndividualDataChangeTable.tsx @@ -1,4 +1,6 @@ import { ReactElement } from 'react'; +import { Box, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; import { useArrayToDict } from '@hooks/useArrayToDict'; import { LoadingComponent } from '@core/LoadingComponent'; import { DocumentsTable } from './DocumentsTable'; @@ -29,6 +31,7 @@ export function RequestedIndividualDataChangeTable({ values, isEdit, }: RequestedIndividualDataChangeTableProps): ReactElement { + const { t } = useTranslation(); const { businessAreaSlug } = useBaseUrl(); const { data: addIndividualFieldsData, isLoading: loading } = useQuery({ @@ -94,6 +97,7 @@ export function RequestedIndividualDataChangeTable({ accounts, accountsToEdit, flexFields, + transliterateLatinNames, ...restIndividualData } = individualData; const entries = restIndividualData && Object.entries(restIndividualData); @@ -133,6 +137,13 @@ export function RequestedIndividualDataChangeTable({ return (
+ {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"