diff --git a/docker-compose.yml b/docker-compose.yml index 6d730a6402..fb0f911cac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,7 +84,10 @@ services: WFP_EMAIL_RECIPIENTS_NEW_ACCOUNT: DISABLE_PASSWORD_LOGINS: MAINTENANCE_MODE: - SERVER_URL: # Limit logging in dev to not overflow terminal + SERVER_URL: + SUPERSET_URL: + SUPERSET_ADMIN_USERNAME: + SUPERSET_ADMIN_PASSWORD: logging: &iaso_logging driver: 'json-file' options: diff --git a/hat/assets/js/apps/Iaso/components/maps/MarkerMapComponent.tsx b/hat/assets/js/apps/Iaso/components/maps/MarkerMapComponent.tsx index 8a64333ab7..2e01a6e933 100644 --- a/hat/assets/js/apps/Iaso/components/maps/MarkerMapComponent.tsx +++ b/hat/assets/js/apps/Iaso/components/maps/MarkerMapComponent.tsx @@ -30,7 +30,6 @@ const useStyles = () => { string, SxProps >), - height: 400, minWidth: 200, marginBottom: 0, position: 'relative', @@ -65,7 +64,6 @@ export const MarkerMap: FunctionComponent = ({ const { formatMessage } = useSafeIntl(); const styles: Record> = useStyles(); - const boundsOptions: Record = { padding: [50, 50], maxZoom: maxZoom || currentTile.maxZoom, @@ -89,7 +87,12 @@ export const MarkerMap: FunctionComponent = ({ ); if (latitude === undefined || longitude === undefined) return null; return ( - + {!isMarkerInside && ( diff --git a/hat/assets/js/apps/Iaso/components/tables/PaperTableRow.tsx b/hat/assets/js/apps/Iaso/components/tables/PaperTableRow.tsx index fc256fba42..7d92c4ac71 100644 --- a/hat/assets/js/apps/Iaso/components/tables/PaperTableRow.tsx +++ b/hat/assets/js/apps/Iaso/components/tables/PaperTableRow.tsx @@ -1,46 +1,39 @@ -import { TableCell, TableRow } from '@mui/material'; -import { makeStyles } from '@mui/styles'; +import { TableCell, TableRow, Theme } from '@mui/material'; import { LoadingSpinner } from 'bluesquare-components'; -import classNames from 'classnames'; import React, { FunctionComponent } from 'react'; +import { SxStyles } from '../../types/general'; import { NumberCell } from '../Cells/NumberCell'; -const useStyles = makeStyles(theme => ({ - withBorder: { - // @ts-ignore - borderRight: `1px solid ${theme.palette.ligthGray.border}`, - }, - boldTitle: { - fontWeight: 'bold', - }, -})); - type RowProps = { label?: string; - value?: string | number; + value?: string | number | React.ReactNode; isLoading?: boolean; - withLeftCellBorder?: boolean; - boldLeftCellText?: boolean; className?: string; + withoutPadding?: boolean; +}; + +const styles: SxStyles = { + label: (theme: Theme) => ({ + fontWeight: 'bold', + // @ts-ignore + borderRight: `1px solid ${theme.palette.ligthGray.border}`, + }), + cellNoPadding: { + padding: 0, + }, }; export const PaperTableRow: FunctionComponent = ({ label, value, isLoading = false, - withLeftCellBorder = true, - boldLeftCellText = true, className, + withoutPadding = false, }) => { - const classes = useStyles(); - const borderClass = withLeftCellBorder ? classes.withBorder : ''; - const boldTitle = boldLeftCellText ? classes.boldTitle : ''; return ( - - {label} - - + {label} + {isLoading && ( = ({ size={25} /> )} - {!isLoading && typeof value === 'string' && value} + {!isLoading && typeof value !== 'number' && value} {!isLoading && typeof value === 'number' && ( )} diff --git a/hat/assets/js/apps/Iaso/constants/urls.ts b/hat/assets/js/apps/Iaso/constants/urls.ts index af00e7f365..9e02e14e96 100644 --- a/hat/assets/js/apps/Iaso/constants/urls.ts +++ b/hat/assets/js/apps/Iaso/constants/urls.ts @@ -183,6 +183,7 @@ export const baseRouteConfigs: Record = { 'userRoles', 'withLocation', 'projectIds', + 'source_version_id', 'paymentStatus', ...paginationPathParams, 'paymentIds', diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/en.json b/hat/assets/js/apps/Iaso/domains/app/translations/en.json index 578be7bab2..8760d6b6ed 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/en.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/en.json @@ -947,6 +947,8 @@ "iaso.pages.errors.text": "Text content is required", "iaso.pages.errors.url": "Url is required", "iaso.pages.errors.urlNotValid": "Url is not valid", + "iaso.pages.superset": "Superset", + "iaso.pages.superset_dashboard_id": "Dashboard ID", "iaso.pages.title": "Embedded links", "iaso.payments.createLot": "Create a new lot of payments", "iaso.payments.download_payments": "Download payments", @@ -1575,4 +1577,4 @@ "trypelim.permissions.zones": "Zones", "trypelim.permissions.zones_edit": "Edit zones", "trypelim.permissions.zones_shapes_edit": "Edit zone shapes" -} \ No newline at end of file +} diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json index 4c2e7f6b3e..1ae8623fbd 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json @@ -947,6 +947,8 @@ "iaso.pages.errors.text": "Le contenu est requis", "iaso.pages.errors.url": "L'url est un champ requis", "iaso.pages.errors.urlNotValid": "L'url n'est pas valide", + "iaso.pages.superset": "Superset", + "iaso.pages.superset_dashboard_id": "Identifiant du tableau de bord", "iaso.pages.title": "Liens intégrés", "iaso.payments.createLot": "Créer un nouveau lot de paiements", "iaso.payments.download_payments": "Télécharger les paiements", @@ -1574,4 +1576,4 @@ "trypelim.permissions.zones": "Zones", "trypelim.permissions.zones_edit": "Edit zones", "trypelim.permissions.zones_shapes_edit": "Editer les contours géographiques des zones de santé" -} \ No newline at end of file +} diff --git a/hat/assets/js/apps/Iaso/domains/entities/components/BeneficiaryBaseInfo.tsx b/hat/assets/js/apps/Iaso/domains/entities/components/BeneficiaryBaseInfo.tsx index cd3a567d7d..f52f68b878 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/components/BeneficiaryBaseInfo.tsx +++ b/hat/assets/js/apps/Iaso/domains/entities/components/BeneficiaryBaseInfo.tsx @@ -1,13 +1,19 @@ import { makeStyles } from '@mui/styles'; +import { + commonStyles, + IconButton, + LinkButton, + useSafeIntl, +} from 'bluesquare-components'; import React, { FunctionComponent } from 'react'; -import { commonStyles, IconButton, useSafeIntl } from 'bluesquare-components'; -import MESSAGES from '../messages'; +import { Grid } from '@mui/material'; +import WidgetPaper from '../../../components/papers/WidgetPaperComponent'; import { baseUrls } from '../../../constants/urls'; +import MESSAGES from '../messages'; import { Beneficiary } from '../types/beneficiary'; import { Field } from '../types/fields'; import { BeneficiaryBaseInfoContents } from './BeneficiaryBaseInfoContents'; -import WidgetPaper from '../../../components/papers/WidgetPaperComponent'; const useStyles = makeStyles(theme => ({ ...commonStyles(theme), @@ -19,22 +25,53 @@ type Props = { beneficiary: Beneficiary; fields: Field[]; withLinkToBeneficiary?: boolean; + hasDuplicates?: boolean; + duplicateUrl?: string; +}; + +const BeneficiaryTitle: FunctionComponent<{ + hasDuplicates: boolean; + duplicateUrl?: string; +}> = ({ hasDuplicates, duplicateUrl }) => { + const { formatMessage } = useSafeIntl(); + return hasDuplicates && duplicateUrl ? ( + + + {formatMessage(MESSAGES.beneficiaryInfo)} + + + + {formatMessage(MESSAGES.seeDuplicates)} + + + + ) : ( + formatMessage(MESSAGES.beneficiaryInfo) + ); }; export const BeneficiaryBaseInfo: FunctionComponent = ({ beneficiary, fields, withLinkToBeneficiary = false, + hasDuplicates = false, + duplicateUrl, }) => { - const { formatMessage } = useSafeIntl(); const classes: Record = useStyles(); const widgetContents = ; + const title = ( + + ); + if (withLinkToBeneficiary) { return ( = ({ ); } return ( - + {widgetContents} ); diff --git a/hat/assets/js/apps/Iaso/domains/entities/components/BeneficiaryBaseInfoContents.tsx b/hat/assets/js/apps/Iaso/domains/entities/components/BeneficiaryBaseInfoContents.tsx index 6e46ab7d2f..3002bb6d3a 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/components/BeneficiaryBaseInfoContents.tsx +++ b/hat/assets/js/apps/Iaso/domains/entities/components/BeneficiaryBaseInfoContents.tsx @@ -1,14 +1,18 @@ /* eslint-disable react/require-default-props */ import { Box, Table, TableBody } from '@mui/material'; import { makeStyles } from '@mui/styles'; -import React, { FunctionComponent } from 'react'; import { commonStyles } from 'bluesquare-components'; -import { Field } from '../types/fields'; +import React, { FunctionComponent } from 'react'; import { PaperTableRow } from '../../../components/tables/PaperTableRow'; +import { Field } from '../types/fields'; const useStyles = makeStyles(theme => ({ ...commonStyles(theme), - infoPaperBox: { minHeight: '100px' }, + infoPaperBox: { + minHeight: '100px', + // @ts-ignore + borderTop: `1px solid ${theme.palette.ligthGray.border}`, + }, })); type Props = { @@ -29,6 +33,7 @@ export const BeneficiaryBaseInfoContents: FunctionComponent = ({ label={field.label} value={field.value} key={field.key} + withoutPadding={field.type === 'geopoint'} /> ))} diff --git a/hat/assets/js/apps/Iaso/domains/entities/details.tsx b/hat/assets/js/apps/Iaso/domains/entities/details.tsx index 3e6f9ff511..2dcb2e25e5 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/details.tsx +++ b/hat/assets/js/apps/Iaso/domains/entities/details.tsx @@ -1,31 +1,30 @@ -import React, { FunctionComponent, useMemo } from 'react'; +import { Box, Divider, Grid } from '@mui/material'; +import { makeStyles } from '@mui/styles'; import { - useSafeIntl, commonStyles, - useGoBack, LoadingSpinner, - LinkButton, + useGoBack, + useSafeIntl, } from 'bluesquare-components'; -import { Box, Divider, Grid } from '@mui/material'; -import { makeStyles } from '@mui/styles'; -import TopBar from '../../components/nav/TopBarComponent'; -import MESSAGES from './messages'; -import { baseUrls } from '../../constants/urls'; -import { useGetBeneficiary, useGetSubmissions } from './hooks/requests'; -import { useGetBeneficiaryFields } from './hooks/useGetBeneficiaryFields'; -import { Beneficiary } from './types/beneficiary'; -import { useBeneficiariesDetailsColumns } from './config'; +import React, { FunctionComponent, useMemo } from 'react'; import { CsvButton } from '../../components/Buttons/CsvButton'; import { XlsxButton } from '../../components/Buttons/XslxButton'; -import { BeneficiaryBaseInfo } from './components/BeneficiaryBaseInfo'; +import TopBar from '../../components/nav/TopBarComponent'; import WidgetPaper from '../../components/papers/WidgetPaperComponent'; import { TableWithDeepLink } from '../../components/tables/TableWithDeepLink'; +import { baseUrls } from '../../constants/urls'; import { useParamsObject } from '../../routing/hooks/useParamsObject'; +import { BeneficiaryBaseInfo } from './components/BeneficiaryBaseInfo'; +import { useBeneficiariesDetailsColumns } from './config'; +import { useGetBeneficiary, useGetSubmissions } from './hooks/requests'; +import { useGetBeneficiaryFields } from './hooks/useGetBeneficiaryFields'; +import MESSAGES from './messages'; +import { Beneficiary } from './types/beneficiary'; const useStyles = makeStyles(theme => ({ ...commonStyles(theme), titleRow: { fontWeight: 'bold' }, - fullWidth: { width: '100%' }, + fullWidth: { width: '100%', height: 'auto' }, })); export const Details: FunctionComponent = () => { @@ -72,78 +71,56 @@ export const Details: FunctionComponent = () => { {isLoading && } {!isLoading && ( - - + + 0} + duplicateUrl={duplicateUrl} /> - {duplicates.length > 0 && ( - + - - - {formatMessage(MESSAGES.seeDuplicates)} - - - - )} - {/* TODO uncomment when edition is possible */} - {/* - - { - console.log( - 'Edit Beneficiary', - beneficiary?.name, - entityId, - ); - // eslint-disable-next-line no-alert - alert('Entity edition'); - }} - color="action" - /> - - */} - - - - - - - - + + + + + + + - - - - + + + )} diff --git a/hat/assets/js/apps/Iaso/domains/entities/entityTypes/components/EntityTypesDialog.tsx b/hat/assets/js/apps/Iaso/domains/entities/entityTypes/components/EntityTypesDialog.tsx index 85a39bdf2e..09227b2e7b 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/entityTypes/components/EntityTypesDialog.tsx +++ b/hat/assets/js/apps/Iaso/domains/entities/entityTypes/components/EntityTypesDialog.tsx @@ -150,11 +150,7 @@ export const EntityTypesDialog: FunctionComponent = ({ ); return ( @@ -193,7 +189,7 @@ export const EntityTypesDialog: FunctionComponent = ({ cancelMessage={MESSAGES.cancel} confirmMessage={MESSAGES.save} renderTrigger={renderTrigger} - maxWidth="xs" + maxWidth="md" onOpen={() => { resetForm(); setIsOpen(true); diff --git a/hat/assets/js/apps/Iaso/domains/entities/hooks/requests.ts b/hat/assets/js/apps/Iaso/domains/entities/hooks/requests.ts index 79caf6476e..1bd2af1260 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/hooks/requests.ts +++ b/hat/assets/js/apps/Iaso/domains/entities/hooks/requests.ts @@ -1,29 +1,29 @@ -import { UseMutationResult, UseQueryResult } from 'react-query'; import moment from 'moment'; +import { UseMutationResult, UseQueryResult } from 'react-query'; // @ts-ignore import { apiDateFormat } from 'Iaso/utils/dates.ts'; import { Pagination, UrlParams } from 'bluesquare-components'; -import { useSnackMutation, useSnackQuery } from '../../../libs/apiHooks'; import { deleteRequest, getRequest, - postRequest, patchRequest, + postRequest, } from '../../../libs/Api'; +import { useSnackMutation, useSnackQuery } from '../../../libs/apiHooks'; import MESSAGES from '../messages'; -import { Location } from '../components/ListMap'; -import getDisplayName, { Profile } from '../../../utils/usersUtils'; import { makeUrlWithParams } from '../../../libs/utils'; +import getDisplayName, { Profile } from '../../../utils/usersUtils'; +import { Location } from '../components/ListMap'; -import { Beneficiary } from '../types/beneficiary'; -import { DisplayedLocation } from '../types/locations'; import { DropdownOptions } from '../../../types/utils'; +import { PaginatedInstances } from '../../instances/types/instance'; import { DropdownTeamsOptions, Team } from '../../teams/types/team'; +import { Beneficiary } from '../types/beneficiary'; import { ExtraColumn } from '../types/fields'; -import { PaginatedInstances } from '../../instances/types/instance'; import { Params } from '../types/filters'; +import { DisplayedLocation } from '../types/locations'; export interface PaginatedBeneficiaries extends Pagination { result: Array; @@ -210,8 +210,8 @@ const getSubmissions = ( }; export const useGetSubmissions = ( - params: Partial, - entityId?: number, + params: Partial & ParamsWithAccountId, + entityId: number, ): UseQueryResult => { return useSnackQuery({ queryKey: ['submissionsForEntity', entityId, params], diff --git a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetBeneficiaryFields.ts b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetBeneficiaryFields.ts index 5cab8aae0b..674eb2ed36 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetBeneficiaryFields.ts +++ b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetBeneficiaryFields.ts @@ -1,6 +1,7 @@ import { useSafeIntl } from 'bluesquare-components'; import { useMemo } from 'react'; +import { useGetFormDescriptor } from '../../forms/fields/hooks/useGetFormDescriptor'; import { useGetPossibleFields } from '../../forms/hooks/useGetPossibleFields'; import MESSAGES from '../messages'; import { Beneficiary } from '../types/beneficiary'; @@ -18,6 +19,10 @@ export const useGetBeneficiaryFields = ( beneficiary?.attributes?.form_id, ); + const { data: formDescriptors } = useGetFormDescriptor( + beneficiary?.attributes?.form_id, + ); + const detailFields = useMemo(() => { let fields = []; if (beneficiaryTypes && beneficiary) { @@ -33,9 +38,10 @@ export const useGetBeneficiaryFields = ( detailFields, beneficiary, possibleFields, + formDescriptors, ); - const staticFields = useMemo( + const staticFields: Field[] = useMemo( () => [ { label: formatMessage(MESSAGES.nfcCards), diff --git a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.ts b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.ts deleted file mode 100644 index a350d37ed5..0000000000 --- a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useSafeIntl } from 'bluesquare-components'; -import moment from 'moment'; - -import { Beneficiary, FileContent } from '../types/beneficiary'; - -import { FieldType } from '../../forms/types/forms'; - -import MESSAGES from '../messages'; - -const textPlaceholder = '--'; - -export const useGetFieldValue = (): (( - fieldKey: string, - fileContent: FileContent | Beneficiary, - type: FieldType, -) => string) => { - const { formatMessage } = useSafeIntl(); - const getValue = (fieldKey, fileContent, type): string => { - switch (type) { - case 'text': - case 'calculate': - case 'integer': - case 'decimal': - case 'select one': - case 'note': { - return fileContent[fieldKey] || textPlaceholder; - } - case 'date': { - return fileContent[fieldKey] - ? moment(fileContent[fieldKey]).format('L') - : textPlaceholder; - } - case 'start': - case 'end': - case 'dateTime': { - return fileContent[fieldKey] - ? moment(fileContent[fieldKey]).format('LTS') - : textPlaceholder; - } - case 'time': { - return fileContent[fieldKey] - ? moment(fileContent[fieldKey]).format('T') - : textPlaceholder; - } - default: - return formatMessage(MESSAGES.typeNotSupported, { type }); - } - }; - return getValue; -}; diff --git a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx new file mode 100644 index 0000000000..a8a531ebc7 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx @@ -0,0 +1,111 @@ +import { textPlaceholder, useSafeIntl } from 'bluesquare-components'; +import moment from 'moment'; +import React from 'react'; + +import { Box } from '@mui/material'; +import { Beneficiary, FileContent } from '../types/beneficiary'; + +import { FieldType, FormDescriptor } from '../../forms/types/forms'; + +import { findDescriptorInChildren, getDescriptorValue } from '../../../utils'; + +import { MarkerMap } from '../../../components/maps/MarkerMapComponent'; +import { formatLabel } from '../../instances/utils'; +import MESSAGES from '../messages'; + +const getDescriptorListValues = ( + fieldKey: string, + fileContent: FileContent | Beneficiary, + formDescriptors?: FormDescriptor[], +): string[] => { + const fieldsKeys = fileContent[fieldKey]?.split(' ') || []; + let listValues: string[] = []; + formDescriptors?.forEach(formDescriptor => { + const descriptor = findDescriptorInChildren(fieldKey, formDescriptor); + if (descriptor?.children) { + listValues = + descriptor.children + .filter(child => fieldsKeys.includes(child.name)) + .map(child => formatLabel(child)) || []; + } + }); + return listValues; +}; + +export const useGetFieldValue = ( + formDescriptors?: FormDescriptor[], +): (( + fieldKey: string, + fileContent: FileContent | Beneficiary, + type: FieldType, +) => string | number | React.ReactNode) => { + const { formatMessage } = useSafeIntl(); + const getValue = (fieldKey, fileContent, type) => { + switch (type) { + case 'text': + case 'calculate': + case 'integer': + case 'decimal': + case 'note': { + return fileContent[fieldKey] || textPlaceholder; + } + case 'date': { + return fileContent[fieldKey] + ? moment(fileContent[fieldKey]).format('L') + : textPlaceholder; + } + case 'start': + case 'end': + case 'dateTime': { + return fileContent[fieldKey] + ? moment(fileContent[fieldKey]).format('LTS') + : textPlaceholder; + } + case 'select one': + case 'select_one': { + return getDescriptorValue( + fieldKey, + fileContent, + formDescriptors, + ); + } + case 'select_all_that_apply': + case 'select all that apply': + case 'select_multiple': + case 'select multiple': { + const listValues = getDescriptorListValues( + fieldKey, + fileContent, + formDescriptors, + ); + return listValues.length > 0 + ? listValues.join(' - ') + : textPlaceholder; + } + + case 'geopoint': { + if (!fileContent[fieldKey]) return textPlaceholder; + const latitude = fileContent[fieldKey]?.split(' ')[0]; + const longitude = fileContent[fieldKey]?.split(' ')[1]; + return ( + + + + ); + } + case 'time': { + return fileContent[fieldKey] + ? moment(fileContent[fieldKey]).format('T') + : textPlaceholder; + } + default: + return formatMessage(MESSAGES.typeNotSupported, { type }); + } + }; + return getValue; +}; diff --git a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFields.ts b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFields.tsx similarity index 86% rename from hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFields.ts rename to hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFields.tsx index 3e61eae1b1..8a9b04205d 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFields.ts +++ b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFields.tsx @@ -1,18 +1,19 @@ -import { PossibleField } from '../../forms/types/forms'; +import { FormDescriptor, PossibleField } from '../../forms/types/forms'; import { useGetFieldValue } from './useGetFieldValue'; +import { formatLabel } from '../../instances/utils'; import { Beneficiary } from '../types/beneficiary'; import { Field } from '../types/fields'; -import { formatLabel } from '../../instances/utils'; export const useGetFields = ( fieldsKeys: string[], beneficiary?: Beneficiary, possibleFields?: PossibleField[], + formDescriptors?: FormDescriptor[], ): Field[] => { const fields: Field[] = []; - const getValue = useGetFieldValue(); + const getValue = useGetFieldValue(formDescriptors); if (possibleFields && beneficiary?.attributes?.file_content) { const fileContent = beneficiary.attributes.file_content; fieldsKeys.forEach(fieldKey => { diff --git a/hat/assets/js/apps/Iaso/domains/entities/types/fields.ts b/hat/assets/js/apps/Iaso/domains/entities/types/fields.ts index 14bd7a3123..8768ced0f2 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/types/fields.ts +++ b/hat/assets/js/apps/Iaso/domains/entities/types/fields.ts @@ -1,10 +1,11 @@ +import React from 'react'; import { FieldType } from '../../forms/types/forms'; export type Field = { + value: string | number | React.ReactNode; label: string; - value?: string | number; - key?: string; - type?: FieldType; + type?: string; + key: string; }; export type ExtraColumn = { diff --git a/hat/assets/js/apps/Iaso/domains/forms/fields/constants.ts b/hat/assets/js/apps/Iaso/domains/forms/fields/constants.ts index 6791e4f50f..46b039d44c 100644 --- a/hat/assets/js/apps/Iaso/domains/forms/fields/constants.ts +++ b/hat/assets/js/apps/Iaso/domains/forms/fields/constants.ts @@ -88,7 +88,20 @@ export const iasoFields: Field[] = [ { type: 'select_multiple', alias: 'select multiple', - disabled: true, + useListValues: true, + queryBuilder: { + type: 'multiselect', + excludeOperators: [ + 'proximity', + 'select_any_in', + 'select_not_any_in', + ], + valueSources: ['value'], + }, + }, + { + type: 'select_all_that_apply', + alias: 'select all that apply', useListValues: true, queryBuilder: { type: 'multiselect', diff --git a/hat/assets/js/apps/Iaso/domains/forms/fields/hooks/useGetQueryBuildersFields.ts b/hat/assets/js/apps/Iaso/domains/forms/fields/hooks/useGetQueryBuildersFields.ts index 6a278adbc8..a07720d373 100644 --- a/hat/assets/js/apps/Iaso/domains/forms/fields/hooks/useGetQueryBuildersFields.ts +++ b/hat/assets/js/apps/Iaso/domains/forms/fields/hooks/useGetQueryBuildersFields.ts @@ -2,11 +2,11 @@ import { QueryBuilderFields } from 'bluesquare-components'; import { formatLabel } from '../../../instances/utils'; -import { FormDescriptor, PossibleField } from '../../types/forms'; import { PossibleFieldsForForm } from '../../hooks/useGetPossibleFields'; +import { FormDescriptor, PossibleField } from '../../types/forms'; -import { iasoFields, Field } from '../constants'; import { findDescriptorInChildren } from '../../../../utils'; +import { Field, iasoFields } from '../constants'; export const useGetQueryBuildersFields = ( formDescriptors?: FormDescriptor[], diff --git a/hat/assets/js/apps/Iaso/domains/forms/types/forms.ts b/hat/assets/js/apps/Iaso/domains/forms/types/forms.ts index 606a7edda3..7a1cd60960 100644 --- a/hat/assets/js/apps/Iaso/domains/forms/types/forms.ts +++ b/hat/assets/js/apps/Iaso/domains/forms/types/forms.ts @@ -30,6 +30,8 @@ export type FieldType = | 'select_multiple' | 'select_one_from_file' | 'select_multiple_from_file' + | 'select all that apply' + | 'select_all_that_apply' | 'rank' | 'note' | 'geopoint' diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsFilters.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsFilters.tsx index 1fc1821f9f..67e1b088d7 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsFilters.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsFilters.tsx @@ -81,10 +81,7 @@ export const OrgUnitFilters: FunctionComponent = ({ locationLimit, setLocationLimit, }) => { - const classes: Record = useStyles(); - const { formatMessage }: { formatMessage: IntlFormatMessage } = - useSafeIntl(); - const currentUser = useCurrentUser(); + // STATES const [dataSourceId, setDataSourceId] = useState( currentSearch?.source ? parseInt(currentSearch?.source, 10) : undefined, ); @@ -99,19 +96,15 @@ export const OrgUnitFilters: FunctionComponent = ({ const [initialOrgUnitId, setInitialOrgUnitId] = useState< string | undefined >(currentSearch?.levels); - const [filters, setFilters] = useState>(currentSearch); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); - const { data: initialOrgUnit } = useGetOrgUnit(initialOrgUnitId); - - useSkipEffectOnMount(() => { - setInitialOrgUnitId(currentSearch?.levels); - }, [currentSearch?.levels]); + const [filters, setFilters] = useState>(currentSearch); + // STATES + // DATA + const { data: initialOrgUnit } = useGetOrgUnit(initialOrgUnitId); const { data: dataSources, isFetching: isFetchingDataSources } = useGetDataSources(true); - - const getVersionLabel = useGetVersionLabel(dataSources); const { data: projects, isFetching: isFetchingProjects } = useGetProjectsDropDown(); const { data: groups, isFetching: isFetchingGroups } = useGetGroups({ @@ -120,51 +113,71 @@ export const OrgUnitFilters: FunctionComponent = ({ }); const { data: orgUnitTypes, isFetching: isFetchingOrgUnitTypes } = useGetOrgUnitTypesDropdownOptions(projectId); - - const instancesOptions = useInstancesOptions(); - const { data: validationStatusOptions, isLoading: isLoadingValidationStatusOptions, } = useGetOrgUnitValidationStatus(true); - const handleChange = (key, value) => { - const newFilters: Record = { - ...filters, - }; - if (key === 'version') { - setSourceVersionId(parseInt(value, 10)); - } - if (key === 'source') { - setInitialOrgUnitId(undefined); - setSourceVersionId(undefined); - setDataSourceId(parseInt(value, 10)); - delete newFilters.levels; - delete newFilters.orgUnitParentId; - } - if (key === 'levels') { - setInitialOrgUnitId(value); - } - if (key === 'project') { - setInitialOrgUnitId(undefined); - newFilters.orgUnitTypeId = undefined; - setProjectId(parseInt(value, 10)); - } - if ((!value || value === '') && newFilters[key]) { - delete newFilters[key]; - } else { - newFilters[key] = value; - } - if (newFilters.source && newFilters.version) { - delete newFilters.source; - } - setFilters(newFilters); - const tempSearches: [Record] = [...searches]; - tempSearches[searchIndex] = newFilters; - setSearches(tempSearches); - }; - const currentColor = filters?.color - ? `#${filters.color}` - : getChipColors(searchIndex); + // DATA + + const getNewSourceVersionId = useCallback( + (newDataSourceId: number) => { + const dataSource = dataSources?.find( + src => src?.original?.id === newDataSourceId, + ); + const versions = dataSource?.original?.versions || []; + return ( + dataSource?.original?.default_version?.id || + (versions.length > 0 + ? versions[versions.length - 1]?.id + : undefined) + ); + }, + [dataSources], + ); + + // EVENTS + const handleChange = useCallback( + (key, value) => { + const newFilters: Record = { + ...filters, + }; + if (key === 'version') { + setSourceVersionId(parseInt(value, 10)); + } + if (key === 'source') { + setInitialOrgUnitId(undefined); + const newDataSourceId = parseInt(value, 10); + const newSourceVersionId = + getNewSourceVersionId(newDataSourceId); + setSourceVersionId(newSourceVersionId); + newFilters.version = newSourceVersionId?.toString(); + setDataSourceId(newDataSourceId); + delete newFilters.levels; + delete newFilters.orgUnitParentId; + } + if (key === 'levels') { + setInitialOrgUnitId(value); + } + if (key === 'project') { + setInitialOrgUnitId(undefined); + newFilters.orgUnitTypeId = undefined; + setProjectId(parseInt(value, 10)); + } + if ((!value || value === '') && newFilters[key]) { + delete newFilters[key]; + } else { + newFilters[key] = value; + } + if (newFilters.source && newFilters.version) { + delete newFilters.source; + } + setFilters(newFilters); + const tempSearches: [Record] = [...searches]; + tempSearches[searchIndex] = newFilters; + setSearches(tempSearches); + }, + [filters, searches, searchIndex, setSearches, getNewSourceVersionId], + ); const handleLocationLimitChange = useCallback( (key: string, value: number) => { @@ -172,6 +185,31 @@ export const OrgUnitFilters: FunctionComponent = ({ }, [setLocationLimit], ); + // EVENTS + + const currentColor = filters?.color + ? `#${filters.color}` + : getChipColors(searchIndex); + + // HOOKS + const currentUser = useCurrentUser(); + const instancesOptions = useInstancesOptions(); + const getVersionLabel = useGetVersionLabel(dataSources); + const classes: Record = useStyles(); + const { formatMessage }: { formatMessage: IntlFormatMessage } = + useSafeIntl(); + // HOOKS + + // USE EFFECTS + useSkipEffectOnMount(() => { + setInitialOrgUnitId(currentSearch?.levels); + }, [currentSearch?.levels]); + + useSkipEffectOnMount(() => { + if (filters !== currentSearch) { + setFilters(currentSearch); + } + }, [currentSearch]); // Splitting this effect from the one below, so we can use the deps array useEffect(() => { @@ -194,15 +232,7 @@ export const OrgUnitFilters: FunctionComponent = ({ }, [dataSourceId, dataSources, filters?.version]); useEffect(() => { - // if no dataSourceId or sourceVersionId are provided, use the default from user - if ( - !dataSourceId && - !sourceVersionId && - !filters?.version && - currentUser?.account?.default_version?.data_source?.id && - !filters?.group - ) { - // TO-DO => IA-1491 when coming from groups page, we need to prefill source and version from the selected group ! + if (!dataSourceId) { setDataSourceId( filters?.source ?? currentUser?.account?.default_version?.data_source?.id, @@ -211,36 +241,15 @@ export const OrgUnitFilters: FunctionComponent = ({ filters?.version ?? currentUser?.account?.default_version?.id, ); } + if (dataSourceId && !sourceVersionId) { + const newSourceVersionId = getNewSourceVersionId(dataSourceId); + setSourceVersionId(newSourceVersionId); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Set the version to the dataSources default version when changing source, or the first version if no default is set - useEffect(() => { - if (dataSourceId) { - const dataSource = dataSources?.find( - src => src?.original?.id === dataSourceId, - ); - if ( - dataSource && - !dataSource.original?.versions.find( - version => version.id === sourceVersionId, - ) - ) { - const selectedVersion = - dataSource.original?.default_version?.id || - dataSource.original?.versions[ - dataSource.original.versions.length - 1 - ]?.id; - setSourceVersionId(selectedVersion); - } - } - }, [dataSourceId, sourceVersionId, dataSources]); + // USE EFFECTS - useSkipEffectOnMount(() => { - if (filters !== currentSearch) { - setFilters(currentSearch); - } - }, [currentSearch]); const versionsDropDown = useMemo(() => { if (!dataSources || !dataSourceId) return []; return ( diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/details.js b/hat/assets/js/apps/Iaso/domains/orgUnits/details.js index f9875f62a5..48c2fa641b 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/details.js +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/details.js @@ -218,7 +218,7 @@ const OrgUnitDetail = () => { } = orgUnitRevision.fields; const coordinates = location - ? wktToGeoJSON(location)?.coordinates ?? [] + ? (wktToGeoJSON(location)?.coordinates ?? []) : []; const [longitude, latitude] = coordinates; const aliases = revisionAliases diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/hooks.js b/hat/assets/js/apps/Iaso/domains/orgUnits/hooks.js index 7d1e099859..687baa9843 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/hooks.js +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/hooks.js @@ -77,7 +77,9 @@ export const useOrgUnitDetailData = ( snackErrorMsg: MESSAGES.fetchGroupsError, options: { select: data => data.groups, - enabled: tab === 'children' || tab === 'infos', + enabled: + (tab === 'children' || tab === 'infos') && + (Boolean(originalOrgUnit) || isNewOrgunit), ...cacheOptions, }, }, diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/orgUnitTypes/hooks/useGetOrgUnitTypes.ts b/hat/assets/js/apps/Iaso/domains/orgUnits/orgUnitTypes/hooks/useGetOrgUnitTypes.ts index 99421abbd0..1df25af5aa 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/orgUnitTypes/hooks/useGetOrgUnitTypes.ts +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/orgUnitTypes/hooks/useGetOrgUnitTypes.ts @@ -3,12 +3,12 @@ import { getRequest } from '../../../../libs/Api'; import { useSnackQuery } from '../../../../libs/apiHooks'; import { makeUrlWithParams } from '../../../../libs/utils'; import { - PaginatedOrgUnitTypes, OrgUnitTypesParams, + PaginatedOrgUnitTypes, } from '../../types/orgunitTypes'; const queryParamsMap = new Map([['projectIds', 'project_ids']]); -const apiParamsKeys = ['order', 'page', 'limit', 'search']; +const apiParamsKeys = ['order', 'page', 'limit', 'search', 'with_units_count']; const getParams = (params: OrgUnitTypesParams) => { const { pageSize, ...urlParams } = params; diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/orgUnitTypes/index.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/orgUnitTypes/index.tsx index dcbcd01ebf..edb542a646 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/orgUnitTypes/index.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/orgUnitTypes/index.tsx @@ -1,25 +1,25 @@ -import React, { FunctionComponent } from 'react'; import { Box, Grid } from '@mui/material'; import { makeStyles } from '@mui/styles'; import { - commonStyles, AddButton, - useSafeIntl, Column, + commonStyles, + useSafeIntl, } from 'bluesquare-components'; +import React, { FunctionComponent } from 'react'; import TopBar from '../../../components/nav/TopBarComponent'; -import { OrgUnitsTypesDialog } from './components/OrgUnitsTypesDialog'; import { TableWithDeepLink } from '../../../components/tables/TableWithDeepLink'; +import { OrgUnitsTypesDialog } from './components/OrgUnitsTypesDialog'; import { baseUrls } from '../../../constants/urls'; import { OrgUnitTypesParams } from '../types/orgunitTypes'; import MESSAGES from './messages'; -import { useGetColumns } from './config/tableColumns'; -import { useGetOrgUnitTypes } from './hooks/useGetOrgUnitTypes'; import { useParamsObject } from '../../../routing/hooks/useParamsObject'; import { Filters } from './components/Filters'; +import { useGetColumns } from './config/tableColumns'; +import { useGetOrgUnitTypes } from './hooks/useGetOrgUnitTypes'; const baseUrl = baseUrls.orgUnitTypes; @@ -28,11 +28,14 @@ const useStyles = makeStyles(theme => ({ })); const OrgUnitTypes: FunctionComponent = () => { - const params = useParamsObject(baseUrl) as OrgUnitTypesParams; + const params = useParamsObject(baseUrl) as unknown as OrgUnitTypesParams; const classes: Record = useStyles(); const { formatMessage } = useSafeIntl(); const columns: Column[] = useGetColumns(); - const { data, isFetching } = useGetOrgUnitTypes(params); + const { data, isFetching } = useGetOrgUnitTypes({ + ...params, + with_units_count: true, + }); return ( <> theme.palette.primary.main, + alignSelf: 'center', + textAlign: 'right', + flex: '1', + cursor: 'pointer', + }, +}; export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ params, }) => { - const { formatMessage } = useSafeIntl(); + const redirectToReplace = useRedirectToReplace(); + const newParams = useMemo(() => params, [params]); + + const defaultSourceVersion = useDefaultSourceVersion(); + const [selectedVersionId, setSelectedVersionId] = useState( + newParams.source_version_id + ? newParams.source_version_id + : defaultSourceVersion.version.id.toString(), + ); + const { filters, handleSearch, handleChange, filtersUpdated } = useFilterState({ baseUrl, params }); + const { data: dataSources, isFetching: isFetchingDataSources } = + useGetDataSources(true); const { data: initialOrgUnit } = useGetOrgUnit(params.parent_id); - // IA-3641 hard coding values fro groups dropdown until refactor - // const { data: groupOptions, isLoading: isLoadingGroups } = - // useGetGroupDropdown({}); - const groupOptions = []; - const isLoadingGroups = false; - // IA-3641 -----END const { data: orgUnitTypeOptions, isLoading: isLoadingTypes } = useGetOrgUnitTypesDropdownOptions(); const { data: forms, isFetching: isLoadingForms } = useGetForms(); @@ -49,6 +75,33 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ useGetProjectsDropdownOptions(); const { data: paymentStatuses, isFetching: isFetchingPaymentStatuses } = usePaymentStatusOptions(); + + // Redirect to default version + useEffect(() => { + if (!newParams.source_version_id) { + newParams.source_version_id = selectedVersionId; + redirectToReplace(baseUrl, newParams); + } + }, [newParams, selectedVersionId, redirectToReplace]); + + // Get the source when the source_version_id exists + const sourceParam = useMemo( + () => + newParams.source_version_id + ? dataSources?.filter(source => + source.original.versions.some( + version => + version.id.toString() === + newParams.source_version_id, + ), + )[0].value + : undefined, + [dataSources, newParams.source_version_id], + ); + + const [dataSource, setDataSource] = useState( + sourceParam || defaultSourceVersion.source.id.toString(), + ); const formOptions = useMemo( () => forms?.map(form => ({ @@ -57,6 +110,43 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ })) || [], [forms], ); + // Get the initial data source id + const initialDataSource = useMemo( + () => + dataSources?.find( + source => + source.value === defaultSourceVersion.source.id.toString(), + )?.value || '', + [dataSources, defaultSourceVersion.source.id], + ); + + const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + const { formatMessage } = useSafeIntl(); + + const { data: groupOptions, isLoading: isLoadingGroups } = + useGetGroupDropdown( + selectedVersionId ? { defaultVersion: selectedVersionId } : {}, + ); + + // Change the selected dataSource + useEffect(() => { + const updatedDataSource = + sourceParam || + dataSources?.find( + source => + source.value === defaultSourceVersion.source.id.toString(), + )?.value; + + if (updatedDataSource) { + setDataSource(updatedDataSource as unknown as string); + } + }, [ + dataSources, + defaultSourceVersion.source.id, + setDataSource, + sourceParam, + ]); + const statusOptions: DropdownOptions[] = useMemo( () => [ { @@ -82,6 +172,59 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ [handleChange], ); + // handle dataSource and sourceVersion change + const handleDataSourceVersionChange = useCallback( + (key, newValue) => { + if (key === 'source') { + setDataSource(newValue); + const selectedSource = dataSources?.filter( + source => source.value === newValue, + )[0]; + setSelectedVersionId( + selectedSource?.original?.default_version.id.toString(), + ); + handleChange( + 'source_version_id', + selectedSource?.original?.default_version.id, + ); + } else { + setSelectedVersionId(newValue.toString()); + handleChange('source_version_id', newValue); + } + // Reset the group filter state + handleChange('groups', []); + // Reset the parent_id and groups params + delete newParams.parent_id; + delete newParams.groups; + redirectToReplace(baseUrl, newParams); + }, + [dataSources, handleChange, newParams, redirectToReplace], + ); + + const getVersionLabel = useGetVersionLabel(dataSources); + // Get the versions dropdown options based on the selected dataSource + const versionsDropDown = useMemo(() => { + if (!dataSources || !dataSource) return []; + return ( + dataSources + .filter( + src => (src.value as unknown as string) === dataSource, + )[0] + ?.original?.versions.sort((a, b) => a.number - b.number) + .map(version => ({ + label: getVersionLabel(version.id), + value: version.id.toString(), + })) ?? [] + ); + }, [dataSource, dataSources, getVersionLabel]); + // Reset the OrgUnitTreeviewModal when the sourceVersion changed + const sourceTreeviewResetControl = useRef(selectedVersionId); + useEffect(() => { + if (sourceTreeviewResetControl.current !== selectedVersionId) { + sourceTreeviewResetControl.current = selectedVersionId; + } + }, [selectedVersionId]); + return ( @@ -117,19 +260,78 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ options={groupOptions} loading={isLoadingGroups} labelString={formatMessage(MESSAGES.group)} - disabled - helperText={formatMessage(MESSAGES.featureDisabled)} /> + + + {!showAdvancedSettings && ( + setShowAdvancedSettings(true)} + > + {formatMessage(MESSAGES.showAdvancedSettings)} + + )} + {showAdvancedSettings && ( + <> + + + + + setShowAdvancedSettings(false) + } + > + {formatMessage( + MESSAGES.hideAdvancedSettings, + )} + + + + )} + { handleChange('parent_id', orgUnit?.id); }} initialSelection={initialOrgUnit} + resetTrigger={ + sourceTreeviewResetControl.current !== + selectedVersionId + } /> { users: params.userIds, user_roles: params.userRoles, with_location: params.withLocation, + source_version_id: params.source_version_id, }), [ params.created_at_after, @@ -87,6 +88,7 @@ export const ReviewOrgUnitChanges: FunctionComponent = () => { params.groups, params.org_unit_type_id, params.parent_id, + params.source_version_id, params.status, params.userIds, params.userRoles, diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/messages.ts b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/messages.ts index 401727049e..a7edcc8072 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/messages.ts +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/messages.ts @@ -221,6 +221,22 @@ const MESSAGES = defineMessages({ id: 'iaso.label.featureDisabled', defaultMessage: 'Feature temporarily disabled', }, + showAdvancedSettings: { + id: 'iaso.form.label.showAdvancedSettings', + defaultMessage: 'Show advanced settings', + }, + hideAdvancedSettings: { + id: 'iaso.form.label.hideAdvancedSettings', + defaultMessage: 'Hide advanced settings', + }, + source: { + defaultMessage: 'Source', + id: 'iaso.orgUnits.source', + }, + sourceVersion: { + id: 'iaso.form.label.sourceVersion', + defaultMessage: 'Source version', + }, }); export default MESSAGES; diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/types.ts b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/types.ts index 7e162e6ecf..7899a6cb0e 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/types.ts +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/types.ts @@ -19,6 +19,7 @@ export type ApproveOrgUnitParams = UrlParams & { paymentStatus?: 'pending' | 'sent' | 'rejected' | 'paid'; paymentIds?: string; // comma separated ids potentialPaymentIds?: string; // comma separated ids + source_version_id?: string; }; export type OrgUnitChangeRequestDetailParams = UrlParams & { diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/types/orgunitTypes.ts b/hat/assets/js/apps/Iaso/domains/orgUnits/types/orgunitTypes.ts index 52ac2fe2d3..ed42ac156b 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/types/orgunitTypes.ts +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/types/orgunitTypes.ts @@ -50,4 +50,5 @@ export interface PaginatedOrgUnitTypes extends Pagination { export type OrgUnitTypesParams = UrlParams & { accountId?: string; search?: string; + with_units_count?: boolean; }; diff --git a/hat/assets/js/apps/Iaso/domains/pages/components/CreateEditDialog.js b/hat/assets/js/apps/Iaso/domains/pages/components/CreateEditDialog.js index ac96253f45..5ff3952d0a 100644 --- a/hat/assets/js/apps/Iaso/domains/pages/components/CreateEditDialog.js +++ b/hat/assets/js/apps/Iaso/domains/pages/components/CreateEditDialog.js @@ -27,7 +27,7 @@ import { useCurrentUser } from '../../../utils/usersUtils.ts'; import { useSavePage } from '../hooks/useSavePage'; import MESSAGES from '../messages'; -import { PAGES_TYPES, IFRAME, TEXT, RAW } from '../constants'; +import { PAGES_TYPES, IFRAME, TEXT, RAW, SUPERSET } from '../constants'; const useStyles = makeStyles(theme => ({ ...commonStyles(theme), @@ -233,13 +233,25 @@ const CreateEditDialog = ({ isOpen, onClose, selectedPage }) => { /> - + {type === SUPERSET && ( + + )} + {type !== SUPERSET && ( + + )} diff --git a/hat/assets/js/apps/Iaso/domains/pages/constants.js b/hat/assets/js/apps/Iaso/domains/pages/constants.js index e579d6ab61..675e89ff77 100644 --- a/hat/assets/js/apps/Iaso/domains/pages/constants.js +++ b/hat/assets/js/apps/Iaso/domains/pages/constants.js @@ -3,6 +3,7 @@ import MESSAGES from './messages'; export const RAW = 'RAW'; export const TEXT = 'TEXT'; export const IFRAME = 'IFRAME'; +export const SUPERSET = 'SUPERSET'; export const PAGES_TYPES = [ { @@ -17,4 +18,8 @@ export const PAGES_TYPES = [ value: 'IFRAME', label: MESSAGES.iframe, }, + { + value: 'SUPERSET', + label: MESSAGES.superset, + }, ]; diff --git a/hat/assets/js/apps/Iaso/domains/pages/messages.js b/hat/assets/js/apps/Iaso/domains/pages/messages.js index eed1ce250e..c0fa8dc5bc 100644 --- a/hat/assets/js/apps/Iaso/domains/pages/messages.js +++ b/hat/assets/js/apps/Iaso/domains/pages/messages.js @@ -118,6 +118,14 @@ const MESSAGES = defineMessages({ id: 'iaso.label.rawHtml', defaultMessage: 'Raw html', }, + superset: { + id: 'iaso.pages.superset', + defaultMessage: 'Superset', + }, + superset_dashboard_id: { + id: 'iaso.pages.superset_dashboard_id', + defaultMessage: 'Dashboard ID', + }, needsAuthentication: { id: 'iaso.label.needsAuthentication', defaultMessage: 'Authentification required', diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/types/userRoles.ts b/hat/assets/js/apps/Iaso/domains/userRoles/types/userRoles.ts index fdb45bd1ed..6d82ec86f2 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/types/userRoles.ts +++ b/hat/assets/js/apps/Iaso/domains/userRoles/types/userRoles.ts @@ -6,6 +6,7 @@ export type UserRole = { created_at: string; updated_at?: string; editable_org_unit_type_ids?: number[]; + permissions?: string[]; }; export type UserRolesFilterParams = { name?: string; diff --git a/hat/assets/js/apps/Iaso/domains/users/components/UsersDialog.tsx b/hat/assets/js/apps/Iaso/domains/users/components/UsersDialog.tsx index 496f06d71b..f9612df24c 100644 --- a/hat/assets/js/apps/Iaso/domains/users/components/UsersDialog.tsx +++ b/hat/assets/js/apps/Iaso/domains/users/components/UsersDialog.tsx @@ -10,6 +10,7 @@ import { import React, { FunctionComponent, useCallback, + useEffect, useMemo, useState, } from 'react'; @@ -74,9 +75,7 @@ const UserDialogComponent: FunctionComponent = ({ const [tab, setTab] = useState('infos'); const [openWarning, setOpenWarning] = useState(false); const [hasNoOrgUnitManagementWrite, setHasNoOrgUnitManagementWrite] = - useState( - !user.permissions.value.includes(Permissions.ORG_UNITS), - ); + useState(false); const saveUser = useCallback(() => { const currentUser: any = {}; Object.keys(user).forEach(key => { @@ -156,6 +155,26 @@ const UserDialogComponent: FunctionComponent = ({ } return ''; }, [formatMessage, isPhoneNumberUpdated, isUserWithoutPermissions]); + + const allUserRolesPermissions = useMemo( + () => + user.user_roles_permissions.value.flatMap(role => role.permissions), + [user.user_roles_permissions.value], + ); + + const allUserUserRolesPermissions = useMemo(() => { + const allUserPermissions = user.user_permissions.value; + + return [ + ...new Set([...allUserPermissions, ...allUserRolesPermissions]), + ]; + }, [allUserRolesPermissions, user.user_permissions.value]); + + useEffect(() => { + setHasNoOrgUnitManagementWrite( + !allUserUserRolesPermissions.includes(Permissions.ORG_UNITS), + ); + }, [allUserRolesPermissions.length, allUserUserRolesPermissions]); return ( <> = ({ currentUser={user} handleChange={permissions => { setFieldValue('user_permissions', permissions); - setHasNoOrgUnitManagementWrite( - !permissions.includes( - Permissions.ORG_UNITS, - ), - ); }} setFieldValue={(key, value) => setFieldValue(key, value) diff --git a/hat/assets/js/apps/Iaso/utils/index.ts b/hat/assets/js/apps/Iaso/utils/index.ts index 7efb8ace51..db706197e9 100644 --- a/hat/assets/js/apps/Iaso/utils/index.ts +++ b/hat/assets/js/apps/Iaso/utils/index.ts @@ -1,6 +1,13 @@ +import { textPlaceholder } from 'bluesquare-components'; import { createContext } from 'react'; import pluginsConfigs from '../../../../../../plugins'; import { Plugin } from '../domains/app/types'; +import { + Beneficiary, + FileContent, +} from '../domains/entities/types/beneficiary'; +import { FormDescriptor } from '../domains/forms/types/forms'; +import { formatLabel } from '../domains/instances/utils'; export const getYears = ( yearsCount: number, @@ -88,3 +95,28 @@ export const findDescriptorInChildren = (field: any, descriptor: any): any => { return undefined; }, null); }; + +export const getDescriptorValue = ( + fieldKey: string, + fileContent: FileContent | Beneficiary, + formDescriptors?: FormDescriptor[], +): string => { + let value = textPlaceholder; + if (fileContent[fieldKey]) { + formDescriptors?.forEach(formDescriptor => { + const descriptor = findDescriptorInChildren( + fieldKey, + formDescriptor, + ); + if (descriptor?.children) { + const descriptorValue = descriptor.children.find( + child => fileContent[fieldKey] === child.name, + ); + if (descriptorValue) { + value = formatLabel(descriptorValue); + } + } + }); + } + return value; +}; diff --git a/hat/assets/js/cypress/fixtures/forms/detail.json b/hat/assets/js/cypress/fixtures/forms/detail.json index 8ea4180d3a..8f5351b4bf 100644 --- a/hat/assets/js/cypress/fixtures/forms/detail.json +++ b/hat/assets/js/cypress/fixtures/forms/detail.json @@ -5,12 +5,21 @@ 7 ], "period_type": null, + "latest_form_version": { + "id": 39, + "version_id": "2024120501", + "file": "/media/forms/test_attachment_2024120501.xml", + "xls_file": "/media/forms/test_attachment_2024120501.xlsx", + "created_at": 1733390819.98435, + "updated_at": 1733390819.98444 + }, "label_keys": [ "label_key_1", "label_key_2", "label_key_3" ], - "possible_fields": [{ + "possible_fields": [ + { "name": "date", "type": "date", "label": "Date" @@ -30,5 +39,31 @@ "type": "text", "label": "Nom" } + ], + "possible_fields_with_latest_version": [ + { + "name": "date", + "type": "date", + "label": "Date", + "is_latest": true + }, + { + "name": "city", + "type": "text", + "label": "Ville", + "is_latest": true + }, + { + "name": "firstname", + "type": "text", + "label": "Prénom", + "is_latest": true + }, + { + "name": "name", + "type": "text", + "label": "Nom", + "is_latest": true + } ] } \ No newline at end of file diff --git a/hat/assets/js/cypress/integration/05 - orgUnits/reviewChanges/changeRequest.spec.js b/hat/assets/js/cypress/integration/05 - orgUnits/reviewChanges/changeRequest.spec.js index 678881ca12..f071d98fba 100644 --- a/hat/assets/js/cypress/integration/05 - orgUnits/reviewChanges/changeRequest.spec.js +++ b/hat/assets/js/cypress/integration/05 - orgUnits/reviewChanges/changeRequest.spec.js @@ -539,7 +539,7 @@ describe('Organisations changes', () => { 'have.attr', 'href', // `/api/orgunits/changes/export_to_csv/?&groups=2,3&status=new`, - `/api/orgunits/changes/export_to_csv/?&status=new`, + `/api/orgunits/changes/export_to_csv/?&status=new&source_version_id=3`, ); }); }); diff --git a/hat/assets/js/supersetSDK.tsx b/hat/assets/js/supersetSDK.tsx new file mode 100644 index 0000000000..b02f697850 --- /dev/null +++ b/hat/assets/js/supersetSDK.tsx @@ -0,0 +1,3 @@ +import { embedDashboard } from '@superset-ui/embedded-sdk'; + +window.embedDashboard = embedDashboard; diff --git a/hat/settings.py b/hat/settings.py index 97e051be6d..0722c74818 100644 --- a/hat/settings.py +++ b/hat/settings.py @@ -656,6 +656,11 @@ def sentry_error_sampler(_, hint): "querydict": False, } +# Superset dashboard/chart embedding configuration +SUPERSET_URL = os.environ.get("SUPERSET_URL", None) +SUPERSET_ADMIN_USERNAME = os.environ.get("SUPERSET_ADMIN_USERNAME", None) +SUPERSET_ADMIN_PASSWORD = os.environ.get("SUPERSET_ADMIN_PASSWORD", None) + # Plugin config print("Enabled plugins:", PLUGINS) for plugin_name in PLUGINS: diff --git a/hat/templates/iaso/pages/superset.html b/hat/templates/iaso/pages/superset.html new file mode 100644 index 0000000000..a5742647ef --- /dev/null +++ b/hat/templates/iaso/pages/superset.html @@ -0,0 +1,52 @@ +{% load static %} + + + + {{title}} + {{ analytics_script | safe }} + + + + +
+ {{ config|json_script:'config' }} + + + {% include "iaso/pages/refresh_data_set_snippet.html" %} + + diff --git a/hat/webpack.dev.js b/hat/webpack.dev.js index 1bc695cfe4..65c2183dd8 100644 --- a/hat/webpack.dev.js +++ b/hat/webpack.dev.js @@ -161,6 +161,9 @@ module.exports = { dependOn: 'common', import: './assets/js/apps/Iaso/index', }, + superset: { + import: './assets/js/supersetSDK', + }, }, output: { diff --git a/hat/webpack.prod.js b/hat/webpack.prod.js index a93e9b5580..71a3bce630 100644 --- a/hat/webpack.prod.js +++ b/hat/webpack.prod.js @@ -15,6 +15,7 @@ module.exports = { entry: { common: ['react', 'react-dom', 'react-intl', 'typescript'], iaso: './assets/js/apps/Iaso/index', + superset: './assets/js/supersetSDK', }, stats: { diff --git a/iaso/admin.py b/iaso/admin.py index 1841b47d04..1239b2e049 100644 --- a/iaso/admin.py +++ b/iaso/admin.py @@ -789,6 +789,7 @@ class AlgorithmRunAdmin(admin.ModelAdmin): class PageAdmin(admin.ModelAdmin): formfield_overrides = {models.JSONField: {"widget": IasoJSONEditorWidget}} autocomplete_fields = ["account"] + list_display = ("name", "slug", "type", "account") @admin.register(EntityDuplicate) diff --git a/iaso/api/common.py b/iaso/api/common.py index c0dac17677..ea165b484c 100644 --- a/iaso/api/common.py +++ b/iaso/api/common.py @@ -8,7 +8,7 @@ from django.contrib.auth.models import User from django.db import transaction from django.db.models import ProtectedError, Q -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseBadRequest from django.utils.timezone import make_aware from django.utils.translation import gettext as _ from rest_framework import compat, exceptions, filters, pagination, permissions, serializers @@ -246,6 +246,11 @@ def get_paginated_response(self, data): ) +class EtlPaginator(Paginator): + page_size = 20 + max_page_size = 1000 + + class ModelViewSet(BaseModelViewSet): results_key = "results" # FIXME Contrary to name it remove result key if NOT paginated @@ -304,6 +309,25 @@ def perform_destroy(self, instance): ) +class EtlModelViewset(ModelViewSet): + """ + Sub class of ModelViewset that enforces the presence of pagination queryparams for GET requests. + Imposes the use of Paginator as pagination class + Use case: dashboard endpoints that will try to fetch all instances of a model + """ + + pagination_class = EtlPaginator + + def get_pagination_class(self): + custom_pagination_class = getattr(self, "pagination_class", None) + if custom_pagination_class and not issubclass(custom_pagination_class, EtlPaginator): + raise TypeError( + f"The pagination_class must be a subclass of {EtlPaginator.__name__}. " + f"Received: {custom_pagination_class.__name__}." + ) + return custom_pagination_class + + class ChoiceEnum(enum.Enum): active = "active" all = "all" diff --git a/iaso/api/groups.py b/iaso/api/groups.py index a7c702e42c..549430aefb 100644 --- a/iaso/api/groups.py +++ b/iaso/api/groups.py @@ -164,12 +164,15 @@ def dropdown(self, request, *args): if user and user.is_authenticated: account = user.iaso_profile.account # Filter on version ids (linked to the account)"" - versions = SourceVersion.objects.filter(data_source__projects__account=account) + default_version = self.request.query_params.get("defaultVersion", account.default_version.id) + versions = SourceVersion.objects.filter(data_source__projects__account=account, pk=default_version) else: # this check if project need auth project = Project.objects.get_for_user_and_app_id(user, app_id) - versions = SourceVersion.objects.filter(data_source__projects=project) + default_version = self.request.query_params.get("defaultVersion", project.account.default_version.id) + versions = SourceVersion.objects.filter(data_source__projects=project, pk=default_version) + groups = Group.objects.filter(source_version__in=versions).distinct() queryset = self.filter_queryset(groups) diff --git a/iaso/api/org_unit_change_requests/filters.py b/iaso/api/org_unit_change_requests/filters.py index 35b1f37fa2..35a7b67877 100644 --- a/iaso/api/org_unit_change_requests/filters.py +++ b/iaso/api/org_unit_change_requests/filters.py @@ -3,6 +3,7 @@ from django.db.models import Q from django.db.models.query import QuerySet from django.utils.translation import gettext_lazy as _ +from iaso.models.data_source import SourceVersion from rest_framework.exceptions import ValidationError from iaso.api.common import parse_comma_separated_numeric_values from iaso.models import OrgUnit, OrgUnitChangeRequest @@ -36,9 +37,11 @@ class OrgUnitChangeRequestListFilter(django_filters.rest_framework.FilterSet): potential_payment_ids = django_filters.CharFilter( method="filter_potential_payments", label=_("Potential Payment IDs (comma-separated)") ) + source_version_id = django_filters.NumberFilter(field_name="org_unit__version", label=_("Source version ID")) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.form.fields["created_at"].fields[0].input_formats = settings.API_DATE_INPUT_FORMATS self.form.fields["created_at"].fields[-1].input_formats = settings.API_DATE_INPUT_FORMATS diff --git a/iaso/api/org_unit_change_requests/views.py b/iaso/api/org_unit_change_requests/views.py index 5c8bc08065..77b0e9a1eb 100644 --- a/iaso/api/org_unit_change_requests/views.py +++ b/iaso/api/org_unit_change_requests/views.py @@ -75,6 +75,7 @@ def get_queryset(self): "old_parent", "new_org_unit_type", "old_org_unit_type", + "org_unit__version", ) .prefetch_related( "org_unit__groups", @@ -88,6 +89,7 @@ def get_queryset(self): ) .exclude_soft_deleted_new_reference_instances() ) + return org_units_change_requests.filter(org_unit__in=org_units) def has_org_unit_permission(self, org_unit_to_change: OrgUnit) -> None: diff --git a/iaso/api/org_unit_types/serializers.py b/iaso/api/org_unit_types/serializers.py index be4f4cd94a..2eb301630f 100644 --- a/iaso/api/org_unit_types/serializers.py +++ b/iaso/api/org_unit_types/serializers.py @@ -3,8 +3,9 @@ from django.db.models import Q from rest_framework import serializers -from iaso.models import Form, OrgUnitType, OrgUnit, Project -from ..common import TimestampField, DynamicFieldsModelSerializer +from iaso.models import Form, OrgUnit, OrgUnitType, Project + +from ..common import DynamicFieldsModelSerializer, TimestampField from ..forms import FormSerializer from ..projects.serializers import ProjectSerializer @@ -79,6 +80,10 @@ class Meta: # Fixme make this directly in db ! def get_units_count(self, obj: OrgUnitType): + # Skip computation if the parameter is not present + if not self.context["request"].query_params.get("with_units_count"): + return None + orgUnits = OrgUnit.objects.filter_for_user_and_app_id( self.context["request"].user, self.context["request"].query_params.get("app_id") ).filter(Q(validated=True) & Q(org_unit_type__id=obj.id)) @@ -121,6 +126,12 @@ def validate(self, data: typing.Mapping): raise serializers.ValidationError({"project_ids": "Invalid project ids"}) return data + def to_representation(self, instance): + # Remove units_count from fields if not requested + if not self.context["request"].query_params.get("with_units_count"): + self.fields.pop("units_count", None) + return super().to_representation(instance) + class OrgUnitTypeSerializerV2(DynamicFieldsModelSerializer): """This one is a bit cryptic: sub_unit_types is only needed for "root" org unit types @@ -179,6 +190,10 @@ class Meta: # Fixme make this directly in db ! def get_units_count(self, obj: OrgUnitType): + # Skip computation if the parameter is not present + if not self.context["request"].query_params.get("with_units_count"): + return None + orgUnits = OrgUnit.objects.filter_for_user_and_app_id( self.context["request"].user, self.context["request"].query_params.get("app_id") ).filter(Q(validation_status=OrgUnit.VALIDATION_VALID) & Q(org_unit_type__id=obj.id)) @@ -244,6 +259,12 @@ def validate(self, data: typing.Mapping): validate_reference_forms(data) return data + def to_representation(self, instance): + # Remove units_count from fields if not requested + if not self.context["request"].query_params.get("with_units_count"): + self.fields.pop("units_count", None) + return super().to_representation(instance) + class OrgUnitTypesDropdownSerializer(serializers.ModelSerializer): class Meta: diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index 202347b67a..1a0e6e9561 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -1,5 +1,5 @@ import copy -from typing import Any, List, Optional, Union, Set +from typing import Any, List, Optional, Set, Union from django.conf import settings from django.contrib.auth import login, models, update_session_auth_hash @@ -232,9 +232,9 @@ def retrieve(self, request, *args, **kwargs): account_user = request.user.tenant_users.first().account_user account_user.backend = "django.contrib.auth.backends.ModelBackend" login(request, account_user) - try: - profile = request.user.iaso_profile + queryset = self.get_queryset() + profile = queryset.get(user=request.user) profile_dict = profile.as_dict() return Response(profile_dict) except Profile.DoesNotExist: diff --git a/iaso/api/storage.py b/iaso/api/storage.py index 79913db660..99a4a4f7bb 100644 --- a/iaso/api/storage.py +++ b/iaso/api/storage.py @@ -356,61 +356,63 @@ def create(self, _, request): This will also create a new StorageDevice if the storage_id / storage_type / account combination is not found """ - user = request.user + import_storage_logs(request.data, request.user) - for log_data in request.data: - # We receive an array of logs, we'll process them one by one - log_id = log_data["id"] + return Response("", status=status.HTTP_201_CREATED) - try: - StorageLogEntry.objects.get(id=log_id) - # That log entry already exists, skip it - except StorageLogEntry.DoesNotExist: - # New log entry, we continue - storage_id = log_data["storage_id"] - storage_type = log_data["storage_type"] - operation_type = log_data["operation_type"] - - if storage_type not in [c[1] for c in StorageDevice.STORAGE_TYPE_CHOICES]: - raise ValueError(f"Invalid storage type: {storage_type}") - - if operation_type not in [c[1] for c in StorageLogEntry.OPERATION_TYPE_CHOICES]: - raise ValueError(f"Invalid operation type: {operation_type}") - - performed_at = timestamp_to_utc_datetime(int(log_data["performed_at"])) - - concerned_instances = Instance.objects.none() - if "instances" in log_data: - concerned_instances = Instance.objects.filter(uuid__in=log_data["instances"]) - - concerned_orgunit = None - if "org_unit_id" in log_data and log_data["org_unit_id"] is not None: - concerned_orgunit = OrgUnit.objects.get(id=log_data["org_unit_id"]) - - concerned_entity = None - entity_id = log_data.get("entity_id") or log_data.get("entity_uuid") - if entity_id: - concerned_entity = Entity.objects.get(uuid=entity_id) - - account = user.iaso_profile.account - - # 1. Create the storage device, if needed - device, _ = StorageDevice.objects.get_or_create( - account=account, customer_chosen_id=storage_id, type=storage_type - ) - - StorageLogEntry.objects.create_and_update_device( - log_id=log_id, - device=device, - operation_type=operation_type, - performed_at=performed_at, - user=user, - concerned_orgunit=concerned_orgunit, - concerned_entity=concerned_entity, - concerned_instances=concerned_instances, - ) - return Response("", status=status.HTTP_201_CREATED) +def import_storage_logs(data, user): + for log_data in data: + # We receive an array of logs, we'll process them one by one + log_id = log_data["id"] + + try: + StorageLogEntry.objects.get(id=log_id) + # That log entry already exists, skip it + except StorageLogEntry.DoesNotExist: + # New log entry, we continue + storage_id = log_data["storage_id"] + storage_type = log_data["storage_type"] + operation_type = log_data["operation_type"] + + if storage_type not in [c[1] for c in StorageDevice.STORAGE_TYPE_CHOICES]: + raise ValueError(f"Invalid storage type: {storage_type}") + + if operation_type not in [c[1] for c in StorageLogEntry.OPERATION_TYPE_CHOICES]: + raise ValueError(f"Invalid operation type: {operation_type}") + + performed_at = timestamp_to_utc_datetime(int(log_data["performed_at"])) + + concerned_instances = Instance.objects.none() + if "instances" in log_data: + concerned_instances = Instance.objects.filter(uuid__in=log_data["instances"]) + + concerned_orgunit = None + if "org_unit_id" in log_data and log_data["org_unit_id"] is not None: + concerned_orgunit = OrgUnit.objects.get(id=log_data["org_unit_id"]) + + concerned_entity = None + entity_id = log_data.get("entity_id") or log_data.get("entity_uuid") + if entity_id: + concerned_entity = Entity.objects.get(uuid=entity_id) + + account = user.iaso_profile.account + + # 1. Create the storage device, if needed + device, _ = StorageDevice.objects.get_or_create( + account=account, customer_chosen_id=storage_id, type=storage_type + ) + + StorageLogEntry.objects.create_and_update_device( + log_id=log_id, + device=device, + operation_type=operation_type, + performed_at=performed_at, + user=user, + concerned_orgunit=concerned_orgunit, + concerned_entity=concerned_entity, + concerned_instances=concerned_instances, + ) def logs_for_device_generate_export( diff --git a/iaso/api/superset.py b/iaso/api/superset.py new file mode 100644 index 0000000000..99978f49b2 --- /dev/null +++ b/iaso/api/superset.py @@ -0,0 +1,59 @@ +import requests + +from django.conf import settings +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status, viewsets +from rest_framework.response import Response + + +@swagger_auto_schema() +class SupersetTokenViewSet(viewsets.ViewSet): + """ + POST /api/superset/token + + This endpoint creates a "guest token" to embed private Superset dashboards + in an iframe in Iaso (typically via a "Page") + + See: + https://www.npmjs.com/package/@superset-ui/embedded-sdk + """ + + def create(self, request): + dashboard_id = request.data.get("dashboard_id") + + base_url = settings.SUPERSET_URL + headers = {"Content-Type": "application/json"} + + # Log in to Superset to get access_token + payload = { + "username": settings.SUPERSET_ADMIN_USERNAME, + "password": settings.SUPERSET_ADMIN_PASSWORD, + "provider": "db", + "refresh": True, + } + response = requests.post(base_url + "/api/v1/security/login", json=payload, headers=headers) + access_token = response.json()["access_token"] + headers["Authorization"] = f"Bearer {access_token}" + + # Fetch CSRF token + response = requests.get(base_url + "/api/v1/security/csrf_token/", headers=headers) + headers["X-CSRF-TOKEN"] = response.json()["result"] + headers["Cookie"] = response.headers.get("Set-Cookie") + headers["Referer"] = base_url + + # Fetch Guest token + current_user = request.user + payload = { + "user": { + "username": current_user.username, + "first_name": current_user.first_name, + "last_name": current_user.last_name, + }, + "resources": [{"type": "dashboard", "id": dashboard_id}], + "rls": [], + } + + response = requests.post(base_url + "/api/v1/security/guest_token/", json=payload, headers=headers) + guest_token = response.json()["token"] + + return Response({"token": guest_token}, status=status.HTTP_201_CREATED) diff --git a/iaso/management/commands/delete_all_instances_and_entities.py b/iaso/management/commands/delete_all_instances_and_entities.py index 739dc1f83e..54d5e92fc6 100644 --- a/iaso/management/commands/delete_all_instances_and_entities.py +++ b/iaso/management/commands/delete_all_instances_and_entities.py @@ -1,7 +1,7 @@ from django.core.management.base import BaseCommand from django.db import transaction -from iaso.models import Account, Entity, Instance, InstanceFile, StorageDevice, StorageLogEntry +from iaso.models import Account, Entity, EntityType, Instance, InstanceFile, StorageDevice, StorageLogEntry class Command(BaseCommand): @@ -15,6 +15,7 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("--account", type=int, dest="account_id") + parser.add_argument("--entity_type_id", type=int, dest="entity_type_id") parser.add_argument( "--dry-run", action="store_true", @@ -43,15 +44,26 @@ def handle(self, *args, **options): account = Account.objects.get(pk=account_id) project_ids = list(account.project_set.values_list("id", flat=True)) + entity_type_id = options.get("entity_type_id") + if entity_type_id: + entity_type = EntityType.objects.get(pk=entity_type_id, account=account) + self.stdout.write(f"NOTE: Deleting only for entity type {entity_type}") + with transaction.atomic(): self.stdout.write("--------------") - self.stdout.write(f"Deleting all form submissions and entities for account {account.name}") + self.stdout.write(f"Deleting form submissions and entities for account {account.name}") + if entity_type_id: + self.stdout.write(f"and entity type {entity_type}") self.stdout.write("--------------") self.stdout.write() self.stdout.write("Deleting form submissions...") - instances_to_delete = Instance.objects.filter(form__projects__id__in=project_ids) - entities_to_delete = Entity.objects_include_deleted.filter(account=account) + if entity_type_id: + instances_to_delete = Instance.objects.filter(entity__entity_type=entity_type) + entities_to_delete = Entity.objects_include_deleted.filter(account=account, entity_type=entity_type) + else: + instances_to_delete = Instance.objects.filter(form__projects__id__in=project_ids) + entities_to_delete = Entity.objects_include_deleted.filter(account=account) self.delete_resources( "\tfile instances", @@ -73,7 +85,10 @@ def handle(self, *args, **options): self.stdout.write("DONE.") self.stdout.write("Deleting storages...") - storages_to_delete = StorageDevice.objects.filter(account=account) + if entity_type_id: + storages_to_delete = StorageDevice.objects.filter(account=account, entity__entity_type=entity_type) + else: + storages_to_delete = StorageDevice.objects.filter(account=account) self.delete_resources( "\tStorageLogEntry", StorageLogEntry.objects.filter(device__in=storages_to_delete), diff --git a/iaso/migrations/0300_alter_page_type.py b/iaso/migrations/0300_alter_page_type.py new file mode 100644 index 0000000000..d0c817523f --- /dev/null +++ b/iaso/migrations/0300_alter_page_type.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.14 on 2024-09-26 15:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0299_merge_0297_entity_merged_to_0298_profile_organization"), + ] + + operations = [ + migrations.AlterField( + model_name="page", + name="type", + field=models.CharField( + choices=[ + ("RAW", "Raw html"), + ("TEXT", "Text"), + ("IFRAME", "Iframe"), + ("POWERBI", "PowerBI report"), + ("SUPERSET", "Superset dashboard"), + ], + default="RAW", + max_length=40, + ), + ), + ] diff --git a/iaso/migrations/0312_merge_20241206_1243.py b/iaso/migrations/0312_merge_20241206_1243.py new file mode 100644 index 0000000000..1a23f87962 --- /dev/null +++ b/iaso/migrations/0312_merge_20241206_1243.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.16 on 2024-12-06 12:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0300_alter_page_type"), + ("iaso", "0311_alter_tenantuser_account_user_and_more"), + ] + + operations = [] diff --git a/iaso/migrations/0313_page_superset_dashboard_id_and_more.py b/iaso/migrations/0313_page_superset_dashboard_id_and_more.py new file mode 100644 index 0000000000..7399c7817f --- /dev/null +++ b/iaso/migrations/0313_page_superset_dashboard_id_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.16 on 2024-12-06 13:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0312_merge_20241206_1243"), + ] + + operations = [ + migrations.AddField( + model_name="page", + name="superset_dashboard_id", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="page", + name="superset_dashboard_ui_config", + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/iaso/models/__init__.py b/iaso/models/__init__.py index 7966341e52..a1390def44 100644 --- a/iaso/models/__init__.py +++ b/iaso/models/__init__.py @@ -5,7 +5,7 @@ from .org_unit import OrgUnit, OrgUnitType, OrgUnitChangeRequest from .org_unit_change_request_configuration import OrgUnitChangeRequestConfiguration from .project import Project -from .pages import Page, RAW, TEXT, IFRAME, POWERBI +from .pages import Page, RAW, TEXT, IFRAME, POWERBI, SUPERSET from .comment import CommentIaso from .import_gpkg import ImportGPKG from .entity import EntityType, Entity diff --git a/iaso/models/pages.py b/iaso/models/pages.py index 78653bcbdb..1aef17926a 100644 --- a/iaso/models/pages.py +++ b/iaso/models/pages.py @@ -8,12 +8,14 @@ TEXT = "TEXT" IFRAME = "IFRAME" POWERBI = "POWERBI" +SUPERSET = "SUPERSET" PAGES_TYPES = [ (RAW, _("Raw html")), (TEXT, _("Text")), (IFRAME, _("Iframe")), (POWERBI, _("PowerBI report")), + (SUPERSET, _("Superset dashboard")), ] @@ -50,5 +52,8 @@ class Page(models.Model): help_text="Language and locale for the PowerBI embedded report e.g en-us or fr-be", ) + superset_dashboard_id = models.TextField(blank=True, null=True) + superset_dashboard_ui_config = models.JSONField(blank=True, null=True) + def __str__(self): return "%s " % (self.name,) diff --git a/iaso/tasks/process_mobile_bulk_upload.py b/iaso/tasks/process_mobile_bulk_upload.py index 4001432178..831fcca708 100644 --- a/iaso/tasks/process_mobile_bulk_upload.py +++ b/iaso/tasks/process_mobile_bulk_upload.py @@ -30,11 +30,13 @@ from hat.sync.views import create_instance_file, process_instance_file from iaso.api.instances import import_data as import_instances from iaso.api.mobile.org_units import import_data as import_org_units +from iaso.api.storage import import_storage_logs from iaso.models import Project, Instance from iaso.utils.s3_client import download_file INSTANCES_JSON = "instances.json" ORG_UNITS_JSON = "orgUnits.json" +STORAGE_LOGS_JSON = "storageLogs.json" logger = logging.getLogger(__name__) @@ -88,6 +90,11 @@ def process_mobile_bulk_upload(api_import_id, project_id, task=None): duplicated_count = duplicate_instance_files(new_instance_files) stats["new_instance_files"] = len(new_instance_files) + duplicated_count + if STORAGE_LOGS_JSON in zip_ref.namelist(): + logger.info("Processing storage logs") + storage_logs_data = read_json_file_from_zip(zip_ref, STORAGE_LOGS_JSON) + import_storage_logs(storage_logs_data, user) + except Exception as e: logger.exception("Exception! Rolling back import: " + str(e)) api_import.has_problem = True diff --git a/iaso/tests/api/test_etl_viewset.py b/iaso/tests/api/test_etl_viewset.py new file mode 100644 index 0000000000..7d1946534f --- /dev/null +++ b/iaso/tests/api/test_etl_viewset.py @@ -0,0 +1,50 @@ +from django.test import TestCase +from rest_framework.pagination import LimitOffsetPagination +from iaso.api.common import EtlModelViewset, EtlPaginator + + +# Dummy paginator classes +class ValidCustomPaginator(EtlPaginator): + default_limit = 20 + + +class InvalidPaginator(LimitOffsetPagination): # Not inheriting from EtlPaginator + pass + + +# Dummy ViewSet using the enforced paginator +class TestViewSet(EtlModelViewset): + pass + + +class TestEnforcedPaginatorModelViewSet(TestCase): + def test_default_pagination_class(self): + """Test that the default pagination_class is used.""" + viewset = TestViewSet() + pagination_class = viewset.get_pagination_class() + self.assertTrue(issubclass(pagination_class, EtlPaginator)) + + def test_valid_custom_pagination_class(self): + """Test that a valid custom pagination_class is accepted.""" + + class CustomViewSet(EtlModelViewset): + pagination_class = ValidCustomPaginator + + viewset = CustomViewSet() + pagination_class = viewset.get_pagination_class() + self.assertEqual(pagination_class, ValidCustomPaginator) + + def test_invalid_pagination_class(self): + """Test that an invalid pagination_class raises a TypeError.""" + + class InvalidViewSet(EtlModelViewset): + pagination_class = InvalidPaginator + + with self.assertRaises(TypeError) as err: + InvalidViewSet().get_pagination_class() + + self.assertEqual( + str(err.exception), + f"The pagination_class must be a subclass of {EtlPaginator.__name__}. " + f"Received: {InvalidPaginator.__name__}.", + ) diff --git a/iaso/tests/api/test_org_unit_types_v2.py b/iaso/tests/api/test_org_unit_types_v2.py index d22b35b862..5c838e085f 100644 --- a/iaso/tests/api/test_org_unit_types_v2.py +++ b/iaso/tests/api/test_org_unit_types_v2.py @@ -134,7 +134,7 @@ def test_org_unit_types_list_count_valid_orgunits(self): self.data_source_1.projects.set([self.ead, self.esd]) self.client.force_authenticate(self.jane) - response = self.client.get("/api/v2/orgunittypes/?order=id") + response = self.client.get("/api/v2/orgunittypes/?order=id&with_units_count=true") self.assertJSONResponse(response, 200) response_data = response.json()["orgUnitTypes"] @@ -154,6 +154,27 @@ def test_org_unit_types_list_count_valid_orgunits(self): for other_types in response_data[2:]: self.assertEqual(other_types["units_count"], 0) + def test_org_unit_types_list_without_units_count(self): + """GET /orgunittypes/ without with_units_count should not include units_count in response""" + + # Create some org units to ensure there would be counts if requested + m.OrgUnit.objects.create( + name="OU 1 ok", + org_unit_type=self.org_unit_type_1, + validation_status=m.OrgUnit.VALIDATION_VALID, + version=self.version_1, + ) + + self.client.force_authenticate(self.jane) + response = self.client.get("/api/v2/orgunittypes/?order=id") + self.assertJSONResponse(response, 200) + + response_data = response.json()["orgUnitTypes"] + + # Verify units_count is not present in any of the returned org unit types + for org_unit_type in response_data: + self.assertNotIn("units_count", org_unit_type) + def test_org_unit_types_retrieve_without_auth_or_app_id(self): """GET /orgunittypes// without auth or app id should result in a 200 empty response""" diff --git a/iaso/tests/api/test_profiles.py b/iaso/tests/api/test_profiles.py index d7d38a7d51..45e0eebb1e 100644 --- a/iaso/tests/api/test_profiles.py +++ b/iaso/tests/api/test_profiles.py @@ -3,11 +3,13 @@ import jsonschema import numpy as np import pandas as pd +from django.contrib.auth import get_user_model from django.contrib.auth.models import Group, Permission from django.core import mail from django.test import override_settings from django.utils.translation import gettext as _ +from rest_framework import status from hat.menupermissions import models as permission from hat.menupermissions.constants import MODULES @@ -274,6 +276,28 @@ def test_profile_me_ok(self): self.assertHasField(response_data, "is_superuser", bool) self.assertHasField(response_data, "org_units", list) + def test_profile_me_no_profile(self): + """GET /profiles/me/ with auth, but without profile + The goal is to know that this call doesn't result in a 500 error + """ + User = get_user_model() + username = "I don't have a profile, i'm sad :(" + user_without_profile = User.objects.create(username=username) + self.client.force_authenticate(user_without_profile) + response = self.client.get("/api/profiles/me/") + self.assertJSONResponse(response, status.HTTP_200_OK) + + response_data = response.json() + self.assertEqual(response_data["user_name"], username) + self.assertEqual(response_data["first_name"], "") + self.assertEqual(response_data["last_name"], "") + self.assertEqual(response_data["user_id"], user_without_profile.id) + self.assertEqual(response_data["email"], "") + self.assertEqual(response_data["projects"], []) + self.assertFalse(response_data["is_staff"]) + self.assertFalse(response_data["is_superuser"]) + self.assertIsNone(response_data["account"]) + def test_profile_me_superuser_ok(self): """GET /profiles/me/ with auth (superuser)""" diff --git a/iaso/tests/fixtures/mobile_bulk_uploads/storage_logs_and_change_requests/5475bfcf-5a3f-4170-9d88-245d89352362/3_2_809f9a76-3f3f-4033-aefb-98f47fb2ccaf_2024-12-03_12-46-06.xml b/iaso/tests/fixtures/mobile_bulk_uploads/storage_logs_and_change_requests/5475bfcf-5a3f-4170-9d88-245d89352362/3_2_809f9a76-3f3f-4033-aefb-98f47fb2ccaf_2024-12-03_12-46-06.xml new file mode 100644 index 0000000000..c6c1f9db23 --- /dev/null +++ b/iaso/tests/fixtures/mobile_bulk_uploads/storage_logs_and_change_requests/5475bfcf-5a3f-4170-9d88-245d89352362/3_2_809f9a76-3f3f-4033-aefb-98f47fb2ccaf_2024-12-03_12-46-06.xml @@ -0,0 +1,15 @@ + + +
+ test + teszt + m +
+ + + uuid:38e7cfff-4489-46ba-87e5-e436c7e8d815 + +
diff --git a/iaso/tests/fixtures/mobile_bulk_uploads/storage_logs_and_change_requests/instances.json b/iaso/tests/fixtures/mobile_bulk_uploads/storage_logs_and_change_requests/instances.json new file mode 100644 index 0000000000..b2980d4261 --- /dev/null +++ b/iaso/tests/fixtures/mobile_bulk_uploads/storage_logs_and_change_requests/instances.json @@ -0,0 +1,13 @@ +[ + { + "id": "5475bfcf-5a3f-4170-9d88-245d89352362", + "created_at": 1.733226371386e9, + "updated_at": 1.733226371386e9, + "file": "/storage/emulated/0/Android/data/com.bluesquarehub.iaso/files/Documents/iaso/instances/3_2_2024-12-03_12-46-06/3_2_809f9a76-3f3f-4033-aefb-98f47fb2ccaf_2024-12-03_12-46-06.xml", + "name": "Test profile", + "formId": "1", + "orgUnitId": "1", + "entityUuid": "5475bfcf-5a3f-4170-9d88-245d89352362", + "entityTypeId": "1" + } +] diff --git a/iaso/tests/fixtures/mobile_bulk_uploads/storage_logs_and_change_requests/storageLogs.json b/iaso/tests/fixtures/mobile_bulk_uploads/storage_logs_and_change_requests/storageLogs.json new file mode 100644 index 0000000000..8272c4bb16 --- /dev/null +++ b/iaso/tests/fixtures/mobile_bulk_uploads/storage_logs_and_change_requests/storageLogs.json @@ -0,0 +1,21 @@ +[ + { + "id": "ff023d13-25fa-43c5-9717-5f427b929cb6", + "storage_id": "BGJGcuxqgA==", + "storage_type": "NFC", + "operation_type": "RESET", + "instances": [], + "org_unit_id": "1", + "performed_at": 1.733226445771e9 + }, + { + "id": "da14ef24-74a1-4f7b-9621-6e33e88773b8", + "storage_id": "BGJGcuxqgA==", + "storage_type": "NFC", + "entity_id": "5475bfcf-5a3f-4170-9d88-245d89352362", + "instances": ["5475bfcf-5a3f-4170-9d88-245d89352362"], + "org_unit_id": "1", + "performed_at": 1729839290.867, + "operation_type": "WRITE_PROFILE" + } +] diff --git a/iaso/tests/tasks/test_process_mobile_bulk_upload.py b/iaso/tests/tasks/test_process_mobile_bulk_upload.py index a1b06cb5aa..7cf77092cf 100644 --- a/iaso/tests/tasks/test_process_mobile_bulk_upload.py +++ b/iaso/tests/tasks/test_process_mobile_bulk_upload.py @@ -616,3 +616,59 @@ def test_duplicate_uuids_multiple_active(self, mock_logger, mock_download_file): self.assertEqual(ent1.instances.count() + ent2.instances.count(), 3) err_msg = f"Multiple non-deleted entities for UUID {ent1.uuid}, entity_type_id {self.default_entity_type.id}" mock_logger.exception.assert_called_once_with(err_msg) + + def test_storage_logs(self, mock_download_file): + entity_uuid = "5475bfcf-5a3f-4170-9d88-245d89352362" + files_for_zip = [ + "instances.json", + "storageLogs.json", + entity_uuid, # the folder with XML submission + ] + with zipfile.ZipFile(f"/tmp/{entity_uuid}.zip", "w", zipfile.ZIP_DEFLATED) as zipf: + add_to_zip(zipf, zip_fixture_dir("storage_logs_and_change_requests"), files_for_zip) + + mock_download_file.return_value = f"/tmp/{entity_uuid}.zip" + + self.assertEqual(m.Entity.objects.count(), 0) + self.assertEqual(m.Instance.objects.count(), 0) + self.assertEqual(m.StorageDevice.objects.count(), 0) + self.assertEqual(m.StorageLogEntry.objects.count(), 0) + + process_mobile_bulk_upload( + api_import_id=self.api_import.id, + project_id=self.project.id, + task=self.task, + _immediate=True, + ) + + mock_download_file.assert_called_once() + + # check Task status and result + self.task.refresh_from_db() + self.assertEqual(self.task.status, m.SUCCESS) + self.api_import.refresh_from_db() + self.assertEqual(self.api_import.import_type, "bulk") + self.assertFalse(self.api_import.has_problem) + + # Instances (Submissions) + Entity were created + self.assertEqual(m.Entity.objects.count(), 1) + entity = m.Entity.objects.get(uuid=entity_uuid) + self.assertEqual(m.Instance.objects.count(), 1) + instance = m.Instance.objects.get(uuid=entity_uuid) + + # Storage logs + self.assertEqual(m.StorageDevice.objects.count(), 1) + self.assertEqual(m.StorageLogEntry.objects.count(), 2) + storage_device = m.StorageDevice.objects.first() + self.assertEqual(storage_device.type, "NFC") + self.assertEqual(storage_device.org_unit_id, 1) + self.assertEqual(storage_device.entity, entity) + + reset_log = m.StorageLogEntry.objects.get(operation_type="RESET") + self.assertEqual(reset_log.org_unit_id, 1) + self.assertIsNone(reset_log.entity) + + write_log = m.StorageLogEntry.objects.get(operation_type="WRITE_PROFILE") + self.assertEqual(write_log.org_unit_id, 1) + self.assertEqual(write_log.entity, entity) + self.assertEqual(list(write_log.instances.all()), [instance]) diff --git a/iaso/urls.py b/iaso/urls.py index e0d023b409..16e080f16e 100644 --- a/iaso/urls.py +++ b/iaso/urls.py @@ -90,6 +90,7 @@ from .api.setup_account import SetupAccountViewSet from .api.source_versions import SourceVersionViewSet from .api.storage import StorageBlacklistedViewSet, StorageLogViewSet, StorageViewSet, logs_per_device +from .api.superset import SupersetTokenViewSet from .api.tasks import TaskSourceViewSet from .api.tasks.create.export_mobile_setup import ExportMobileSetupViewSet from .api.tasks.create.import_gpkg import ImportGPKGViewSet @@ -203,9 +204,8 @@ router.register(r"mobile/metadata/lastupdates", LastUpdatesViewSet, basename="lastupdates") router.register(r"modules", ModulesViewSet, basename="modules") router.register(r"configs", ConfigViewSet, basename="jsonconfigs") - - router.register(r"mobile/bulkupload", MobileBulkUploadsViewSet, basename="mobilebulkupload") +router.register(r"superset/token", SupersetTokenViewSet, basename="supersettoken") router.registry.extend(plugins_router.registry) urlpatterns: URLList = [ diff --git a/iaso/views.py b/iaso/views.py index 1e4656aa40..b2a6114ad9 100644 --- a/iaso/views.py +++ b/iaso/views.py @@ -13,7 +13,7 @@ from drf_yasg.openapi import ReferenceResolver from hat.__version__ import DEPLOYED_BY, DEPLOYED_ON, VERSION -from iaso.models import IFRAME, POWERBI, TEXT, Account, Page +from iaso.models import IFRAME, POWERBI, SUPERSET, TEXT, Account, Page from iaso.utils.powerbi import get_powerbi_report_token @@ -83,6 +83,23 @@ def page(request, page_slug): "iaso/pages/powerbi.html", content, ) + elif page.type == SUPERSET: + content.update( + { + "config": { + "superset_url": settings.SUPERSET_URL, + "dashboard_id": page.superset_dashboard_id, + "dashboard_ui_config": page.superset_dashboard_ui_config, + }, + "title": page.name, + "page": page, + } + ) + response = render( + request, + "iaso/pages/superset.html", + content, + ) else: raw_html = page.content if analytics_script and raw_html is not None: diff --git a/package-lock.json b/package-lock.json index b204fd379a..bf1bb8a95c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@mui/x-date-pickers": "^5.0.0-beta.2", "@mui/x-tree-view": "^6.0.0-beta.0", "@sentry/browser": "^8.35.0", + "@superset-ui/embedded-sdk": "^0.1.2", "@terraformer/wkt": "^2.1.2", "@types/react": "^17.0.36", "@types/react-dom": "^17.0.9", @@ -4171,6 +4172,20 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, + "node_modules/@superset-ui/embedded-sdk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@superset-ui/embedded-sdk/-/embedded-sdk-0.1.2.tgz", + "integrity": "sha512-S+KXiplLAF4Sjp/b5whvGnNHQJBoz3GxJbp3ymiPGvxBkLIxELakY6KZ5Qk0s8+Cl0KWKF6t/8Norn/UaS9oJQ==", + "dependencies": { + "@superset-ui/switchboard": "^0.20.2", + "jwt-decode": "^4.0.0" + } + }, + "node_modules/@superset-ui/switchboard": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@superset-ui/switchboard/-/switchboard-0.20.2.tgz", + "integrity": "sha512-ORnueRpcnAt/IJB8IFaB+M5uDnItLZRJexWj0TKFcN9TBZhE9bQ6J6ARyfOq6VRVlLcaYfrfBYukaZCg3Fh5Jw==" + }, "node_modules/@terraformer/wkt": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@terraformer/wkt/-/wkt-2.2.1.tgz", @@ -12835,6 +12850,14 @@ "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", "dev": true }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, "node_modules/keycode": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz", diff --git a/package.json b/package.json index bbc2d9f081..ebd239e05e 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@mui/x-date-pickers": "^5.0.0-beta.2", "@mui/x-tree-view": "^6.0.0-beta.0", "@sentry/browser": "^8.35.0", + "@superset-ui/embedded-sdk": "^0.1.2", "@terraformer/wkt": "^2.1.2", "@types/react": "^17.0.36", "@types/react-dom": "^17.0.9", diff --git a/plugins/polio/api/dashboards/subactivities.py b/plugins/polio/api/dashboards/subactivities.py new file mode 100644 index 0000000000..6c1468a5f4 --- /dev/null +++ b/plugins/polio/api/dashboards/subactivities.py @@ -0,0 +1,71 @@ +from iaso.api.common import EtlModelViewset +from iaso.api.serializers import OrgUnitSerializer +from plugins.polio.models import OrgUnit +from plugins.polio.models.base import SubActivity, SubActivityScope +from rest_framework import permissions, serializers + + +class SubActivityDashboardSerializer(serializers.ModelSerializer): + obr_name = serializers.CharField(source="round.campaign.obr_name") + round_number = serializers.CharField(source="round.number") + + class Meta: + model = SubActivity + fields = "__all__" + + +class SubActivityDashboardViewSet(EtlModelViewset): + """ + GET /api/polio/dashboards/subactivities/ + Returns all subactivities for the user's account, excluding those related to deleted campaig ns + Simple endpoint that returns all model fields to facilitate data manipulation by OpenHexa or PowerBI + """ + + http_method_names = ["get"] + permission_classes = [permissions.IsAuthenticated] + model = SubActivity + serializer_class = SubActivityDashboardSerializer + + def get_queryset(self): + return SubActivity.objects.filter( + round__campaign__account=self.request.user.iaso_profile.account, round__campaign__deleted_at__isnull=True + ) + + +class OrgUnitNestedSerializer(OrgUnitSerializer): + class Meta: + model = OrgUnit + fields = [ + "id", + "name", + ] + + +class SubActivityScopeDashboardSerializer(serializers.ModelSerializer): + obr_name = serializers.CharField(source="subactivity.round.campaign.obr_name") + round_number = serializers.IntegerField(source="subactivity.round.number") + subactivity_name = serializers.CharField(source="subactivity.name") + org_units = OrgUnitNestedSerializer(source="group.org_units", many=True) + + class Meta: + model = SubActivityScope + fields = "__all__" + + +class SubActivityScopeDashboardViewSet(EtlModelViewset): + """ + GET /api/polio/dashboards/subactivityscopes/ + Returns all subactivityscopes for the user's account, excluding those related to deleted campaigns + Simple endpoint that returns all model fields to facilitate data manipulation by OpenHexa or PowerBI + """ + + http_method_names = ["get"] + permission_classes = [permissions.IsAuthenticated] + model = SubActivityScope + serializer_class = SubActivityScopeDashboardSerializer + + def get_queryset(self): + return SubActivityScope.objects.filter( + subactivity__round__campaign__account=self.request.user.iaso_profile.account, + subactivity__round__campaign__deleted_at__isnull=True, + ) diff --git a/plugins/polio/api/urls.py b/plugins/polio/api/urls.py index 8499894984..e30053bb49 100644 --- a/plugins/polio/api/urls.py +++ b/plugins/polio/api/urls.py @@ -3,6 +3,7 @@ # TOFIX: Still haven't understood the exact problem but this should be # the first import to avoid some 'BudgetProcess' errors in tests: # `AttributeError: 'str' object has no attribute '_meta'` +from plugins.polio.api.dashboards.subactivities import SubActivityDashboardViewSet, SubActivityScopeDashboardViewSet from plugins.polio.api.dashboards.vaccine_stock_history import VaccineStockHistoryDashboardViewSet from plugins.polio.budget.api import BudgetProcessViewSet, BudgetStepViewSet, WorkflowViewSet @@ -134,3 +135,13 @@ SpreadSheetImportViewSet, basename="dashboard_preparedness_sheets", ) +router.register( + r"polio/dashboards/subactivities", + SubActivityDashboardViewSet, + basename="dashboard_subactivities", +) +router.register( + r"polio/dashboards/subactivityscopes", + SubActivityScopeDashboardViewSet, + basename="dashboard_subactivityscopes", +) diff --git a/plugins/polio/api/vaccines/repository_forms.py b/plugins/polio/api/vaccines/repository_forms.py index 1672e24ef9..95dc177947 100644 --- a/plugins/polio/api/vaccines/repository_forms.py +++ b/plugins/polio/api/vaccines/repository_forms.py @@ -1,6 +1,6 @@ """API endpoints and serializers for vaccine repository management.""" -from datetime import datetime, timedelta +from datetime import timedelta from django.db.models import Max, Min, OuterRef, Subquery from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema @@ -9,11 +9,10 @@ from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.mixins import ListModelMixin from rest_framework.viewsets import GenericViewSet -from django.db.models import OuterRef, Subquery, Q, Value, Case, When, CharField, Exists +from django.db.models import OuterRef, Subquery, Q, Case, When, CharField, Exists from iaso.api.common import Paginator from plugins.polio.models import ( - VACCINES, CampaignType, OutgoingStockMovement, Round, @@ -62,10 +61,12 @@ def filter_queryset(self, request, queryset, view): if file_type == "VRF": queryset = queryset.filter( campaign__vaccinerequestform__isnull=False, + campaign__vaccinerequestform__deleted_at__isnull=True, ) elif file_type == "PRE_ALERT": queryset = queryset.filter( campaign__vaccinerequestform__isnull=False, + campaign__vaccinerequestform__deleted_at__isnull=True, campaign__vaccinerequestform__vaccineprealert__isnull=False, ).distinct("id") elif file_type == "FORM_A": @@ -77,7 +78,9 @@ def filter_queryset(self, request, queryset, view): vrf_type = request.query_params.get("vrf_type", None) if vrf_type: queryset = queryset.filter( - campaign__vaccinerequestform__isnull=False, campaign__vaccinerequestform__vrf_type=vrf_type + campaign__vaccinerequestform__isnull=False, + campaign__vaccinerequestform__vrf_type=vrf_type, + campaign__vaccinerequestform__deleted_at__isnull=True, ) return queryset.distinct() @@ -150,11 +153,18 @@ class VaccineRepositoryFormsViewSet(GenericViewSet, ListModelMixin): serializer_class = VaccineRepositorySerializer pagination_class = Paginator filter_backends = [OrderingFilter, SearchFilter, VaccineReportingFilterBackend] - ordering_fields = ["campaign__country__name", "campaign__obr_name", "started_at", "vaccine_name", "number"] - ordering = ["-started_at"] + ordering_fields = [ + "campaign__country__name", + "campaign__obr_name", + "started_at", + "vaccine_name", + "number", + "campaign_started_at", + ] + ordering = ["-campaign_started_at"] search_fields = ["campaign__country__name", "campaign__obr_name"] permission_classes = [permissions.IsAuthenticatedOrReadOnly] - default_page_size = 20 + default_page_size = 50 file_type_param = openapi.Parameter( "file_type", @@ -303,6 +313,7 @@ def get_queryset(self): forma_subquery = OutgoingStockMovement.objects.filter( vaccine_stock__vaccine=OuterRef("vaccine_name"), round=OuterRef("id") ) - test_qs = rounds_queryset.filter(Exists(forma_subquery)) + rounds_queryset = rounds_queryset.filter(Q(Exists(vrf_subquery)) | Q(Exists(forma_subquery))) + return rounds_queryset diff --git a/plugins/polio/api/vaccines/repository_reports.py b/plugins/polio/api/vaccines/repository_reports.py index f55deb86d7..27e1095034 100644 --- a/plugins/polio/api/vaccines/repository_reports.py +++ b/plugins/polio/api/vaccines/repository_reports.py @@ -1,7 +1,4 @@ """API endpoints and serializers for vaccine repository reports.""" - -from datetime import datetime, timedelta -from django.db.models import OuterRef, Subquery, Q, Value, Case, When, CharField, Exists from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import filters, permissions, serializers @@ -9,7 +6,6 @@ from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.mixins import ListModelMixin from rest_framework.viewsets import GenericViewSet -from django.db.models import Prefetch from iaso.api.common import Paginator from plugins.polio.models import VaccineStock, DestructionReport, IncidentReport @@ -47,10 +43,20 @@ def filter_queryset(self, request, queryset, view): if file_type: try: filetypes = [tp.strip().upper() for tp in file_type.split(",")] - if "INCIDENT" in filetypes: + + has_incident = "INCIDENT" in filetypes + has_destruction = "DESTRUCTION" in filetypes + + if has_incident and has_destruction: + # Both types specified - must have both + queryset = queryset.filter(incidentreport__isnull=False, destructionreport__isnull=False) + elif has_incident: + # Only incident reports queryset = queryset.filter(incidentreport__isnull=False) - if "DESTRUCTION" in filetypes: + elif has_destruction: + # Only destruction reports queryset = queryset.filter(destructionreport__isnull=False) + # If no types specified, show all (no filtering needed) except ValueError: raise ValidationError("file_type must be a comma-separated list of strings") @@ -65,22 +71,26 @@ class VaccineRepositoryReportSerializer(serializers.Serializer): destruction_report_data = serializers.SerializerMethodField() def get_incident_report_data(self, obj): - return [ + pir = obj.incidentreport_set.all() + data = [ { "date": ir.date_of_incident_report, "file": ir.document.url if ir.document else None, } - for ir in obj.prefetched_incident_reports + for ir in pir ] + return data def get_destruction_report_data(self, obj): - return [ + drs = obj.destructionreport_set.all() + data = [ { "date": dr.destruction_report_date, "file": dr.document.url if dr.document else None, } - for dr in obj.prefetched_destruction_reports + for dr in drs ] + return data class VaccineRepositoryReportsViewSet(GenericViewSet, ListModelMixin): @@ -97,26 +107,18 @@ class VaccineRepositoryReportsViewSet(GenericViewSet, ListModelMixin): def get_queryset(self): """Get the queryset for VaccineStock objects.""" + base_qs = VaccineStock.objects.select_related( "country", - ).filter(Q(incidentreport__isnull=False) | Q(destructionreport__isnull=False)) - - incident_qs = IncidentReport.objects.only( - "vaccine_stock_id", - "date_of_incident_report", - "document", + ).prefetch_related( + "incidentreport_set", + "destructionreport_set", ) - destruction_qs = DestructionReport.objects.only( - "vaccine_stock_id", - "destruction_report_date", - "document", - ) + if self.request.user and self.request.user.is_authenticated: + base_qs = base_qs.filter(account=self.request.user.iaso_profile.account) - return base_qs.prefetch_related( - Prefetch("incidentreport_set", queryset=incident_qs, to_attr="prefetched_incident_reports"), - Prefetch("destructionreport_set", queryset=destruction_qs, to_attr="prefetched_destruction_reports"), - ).distinct() + return base_qs @swagger_auto_schema( manual_parameters=[ diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepository.tsx b/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepository.tsx index 7738e57ecb..6f96d8d6cb 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepository.tsx +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepository.tsx @@ -55,7 +55,7 @@ export const VaccineRepository: FunctionComponent = () => { ...params, tab: newTab, }; - redirectTo(baseUrl, newParams); + redirectTo(redirectUrl, newParams); }; return ( diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/forms/Filters.tsx b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/Filters.tsx index 31670fd7bd..e72f9c48d6 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/forms/Filters.tsx +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/Filters.tsx @@ -125,6 +125,7 @@ export const Filters: FunctionComponent = ({ params, redirectUrl }) => { onChange={(_key, value) => { setFileType(value); }} + clearable={false} value={fileType} type="select" options={fileTypes} diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useGetVaccineReporting.ts b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useGetVaccineReporting.ts index 64ca3b2dad..80f80376e4 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useGetVaccineReporting.ts +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useGetVaccineReporting.ts @@ -15,8 +15,8 @@ const getVaccineReporting = params => { return getRequest(`/api/polio/vaccine/repository/?${queryString}`); }; export const tableDefaults = { - order: 'started_at', - limit: 10, + order: '-campaign_started_at', + limit: 50, page: 1, }; diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useVaccineRepositoryColumns.tsx b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useVaccineRepositoryColumns.tsx index 6f590de51b..d7a1547397 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useVaccineRepositoryColumns.tsx +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useVaccineRepositoryColumns.tsx @@ -6,10 +6,15 @@ import { FormADocumentsCells } from '../../components/FormADocumentCells'; import { VrfDocumentsCells } from '../../components/VrfDocumentsCell'; import MESSAGES from '../../messages'; -export const useVaccineRepositoryColumns = (): Column[] => { +export const useVaccineRepositoryColumns = ( + params: Record, +): Column[] => { const { formatMessage } = useSafeIntl(); - return useMemo( - () => [ + const { file_type } = params; + // wrong typing in our library + // @ts-ignore + return useMemo(() => { + const columns = [ { Header: formatMessage(MESSAGES.country), id: 'campaign__country__name', @@ -44,28 +49,52 @@ export const useVaccineRepositoryColumns = (): Column[] => { Cell: DateCell, width: 30, }, - { + ]; + if ( + !file_type || + file_type === 'VRF' || + file_type === 'VRF,PRE_ALERT,FORM_A' + ) { + columns.push({ Header: 'VRF', accessor: 'vrf_data', Cell: VrfDocumentsCells, width: 30, + // wrong typing in our library + // @ts-ignore sortable: false, - }, - { + }); + } + if ( + !file_type || + file_type === 'PRE_ALERT' || + file_type === 'VRF,PRE_ALERT,FORM_A' + ) { + columns.push({ Header: 'Pre Alert', accessor: 'pre_alert_data', Cell: DocumentsCells, width: 30, + // wrong typing in our library + // @ts-ignore sortable: false, - }, - { + }); + } + if ( + !file_type || + file_type === 'FORM_A' || + file_type === 'VRF,PRE_ALERT,FORM_A' + ) { + columns.push({ Header: 'Form A', accessor: 'form_a_data', Cell: FormADocumentsCells, width: 20, + // wrong typing in our library + // @ts-ignore sortable: false, - }, - ], - [formatMessage], - ); + }); + } + return columns; + }, [file_type, formatMessage]); }; diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/forms/index.tsx b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/index.tsx index 234db6184b..13b057d561 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/forms/index.tsx +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/index.tsx @@ -37,7 +37,7 @@ export const Forms: FunctionComponent = ({ params }) => { const isEmbedded = location.pathname.includes(embeddedVaccineRepositoryUrl); const redirectUrl = isEmbedded ? embeddedVaccineRepositoryUrl : baseUrl; const { data, isFetching } = useGetVaccineReporting(formsParams); - const columns = useVaccineRepositoryColumns(); + const columns = useVaccineRepositoryColumns(formsParams); return ( <> diff --git a/plugins/polio/migrations/0208_alter_vaccinerequestform_rounds_and_more.py b/plugins/polio/migrations/0208_alter_vaccinerequestform_rounds_and_more.py new file mode 100644 index 0000000000..63a033576e --- /dev/null +++ b/plugins/polio/migrations/0208_alter_vaccinerequestform_rounds_and_more.py @@ -0,0 +1,99 @@ +# Generated by Django 4.2.17 on 2024-12-16 10:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("polio", "0207_merge_20241204_1433"), + ] + + operations = [ + migrations.AlterField( + model_name="vaccinerequestform", + name="rounds", + field=models.ManyToManyField(db_index=True, to="polio.round"), + ), + migrations.AddIndex( + model_name="destructionreport", + index=models.Index( + fields=["vaccine_stock", "destruction_report_date"], name="polio_destr_vaccine_e5b90d_idx" + ), + ), + migrations.AddIndex( + model_name="destructionreport", + index=models.Index(fields=["rrt_destruction_report_reception_date"], name="polio_destr_rrt_des_449e4f_idx"), + ), + migrations.AddIndex( + model_name="incidentreport", + index=models.Index( + fields=["vaccine_stock", "date_of_incident_report"], name="polio_incid_vaccine_b012dc_idx" + ), + ), + migrations.AddIndex( + model_name="incidentreport", + index=models.Index(fields=["incident_report_received_by_rrt"], name="polio_incid_inciden_067b16_idx"), + ), + migrations.AddIndex( + model_name="outgoingstockmovement", + index=models.Index(fields=["vaccine_stock", "campaign"], name="polio_outgo_vaccine_fa2e84_idx"), + ), + migrations.AddIndex( + model_name="outgoingstockmovement", + index=models.Index(fields=["form_a_reception_date"], name="polio_outgo_form_a__b64b56_idx"), + ), + migrations.AddIndex( + model_name="outgoingstockmovement", + index=models.Index(fields=["report_date"], name="polio_outgo_report__44ffe2_idx"), + ), + migrations.AddIndex( + model_name="vaccinearrivalreport", + index=models.Index(fields=["request_form", "arrival_report_date"], name="polio_vacci_request_48e891_idx"), + ), + migrations.AddIndex( + model_name="vaccinearrivalreport", + index=models.Index(fields=["po_number"], name="polio_vacci_po_numb_bd6c9f_idx"), + ), + migrations.AddIndex( + model_name="vaccinearrivalreport", + index=models.Index(fields=["doses_received"], name="polio_vacci_doses_r_d2cd9d_idx"), + ), + migrations.AddIndex( + model_name="vaccineprealert", + index=models.Index( + fields=["request_form", "estimated_arrival_time"], name="polio_vacci_request_4c2b0b_idx" + ), + ), + migrations.AddIndex( + model_name="vaccineprealert", + index=models.Index(fields=["po_number"], name="polio_vacci_po_numb_511963_idx"), + ), + migrations.AddIndex( + model_name="vaccineprealert", + index=models.Index(fields=["date_pre_alert_reception"], name="polio_vacci_date_pr_b7d59e_idx"), + ), + migrations.AddIndex( + model_name="vaccinerequestform", + index=models.Index(fields=["campaign", "vaccine_type"], name="polio_vacci_campaig_f43af8_idx"), + ), + migrations.AddIndex( + model_name="vaccinerequestform", + index=models.Index(fields=["vrf_type"], name="polio_vacci_vrf_typ_2acd7d_idx"), + ), + migrations.AddIndex( + model_name="vaccinerequestform", + index=models.Index(fields=["created_at"], name="polio_vacci_created_8563f0_idx"), + ), + migrations.AddIndex( + model_name="vaccinerequestform", + index=models.Index(fields=["updated_at"], name="polio_vacci_updated_fd171a_idx"), + ), + migrations.AddIndex( + model_name="vaccinestock", + index=models.Index(fields=["country", "vaccine"], name="polio_vacci_country_91274d_idx"), + ), + migrations.AddIndex( + model_name="vaccinestock", + index=models.Index(fields=["account"], name="polio_vacci_account_f1f77e_idx"), + ), + ] diff --git a/plugins/polio/migrations/0208_migrate_vrf_orpg_fields.py b/plugins/polio/migrations/0208_migrate_vrf_orpg_fields.py new file mode 100644 index 0000000000..880a75557c --- /dev/null +++ b/plugins/polio/migrations/0208_migrate_vrf_orpg_fields.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.17 on 2024-12-11 13:54 + +from django.db import migrations + + +def copy_orpg_fields_to_dg_fields(apps, schema_editor): + VaccineRequestForm = apps.get_model("polio", "VaccineRequestForm") + + vrfs = VaccineRequestForm.objects.filter(deleted_at__isnull=True) + for vrf in vrfs: + if not vrf.date_rrt_orpg_approval and vrf.date_dg_approval: + vrf.date_rrt_orpg_approval = vrf.date_dg_approval + if not vrf.quantities_approved_by_orpg_in_doses and vrf.quantities_approved_by_dg_in_doses: + vrf.quantities_approved_by_orpg_in_doses = vrf.quantities_approved_by_dg_in_doses + vrf.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("polio", "0207_merge_20241204_1433"), + ] + + operations = [migrations.RunPython(copy_orpg_fields_to_dg_fields, migrations.RunPython.noop, elidable=True)] diff --git a/plugins/polio/migrations/0209_merge_20241216_1100.py b/plugins/polio/migrations/0209_merge_20241216_1100.py new file mode 100644 index 0000000000..97c2f3cb7b --- /dev/null +++ b/plugins/polio/migrations/0209_merge_20241216_1100.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.17 on 2024-12-16 11:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("polio", "0208_alter_vaccinerequestform_rounds_and_more"), + ("polio", "0208_migrate_vrf_orpg_fields"), + ] + + operations = [] diff --git a/plugins/polio/models/base.py b/plugins/polio/models/base.py index bbe3c2eb08..9e369515ad 100644 --- a/plugins/polio/models/base.py +++ b/plugins/polio/models/base.py @@ -1036,9 +1036,17 @@ class VaccineRequestFormType(models.TextChoices): class VaccineRequestForm(SoftDeletableModel): - campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE) + class Meta: + indexes = [ + models.Index(fields=["campaign", "vaccine_type"]), # Frequently filtered together + models.Index(fields=["vrf_type"]), # Filtered in repository_forms.py + models.Index(fields=["created_at"]), # Used for ordering + models.Index(fields=["updated_at"]), # Used for ordering + ] + + campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE, db_index=True) vaccine_type = models.CharField(max_length=5, choices=VACCINES) - rounds = models.ManyToManyField(Round) + rounds = models.ManyToManyField(Round, db_index=True) date_vrf_signature = models.DateField(null=True, blank=True) date_vrf_reception = models.DateField(null=True, blank=True) date_dg_approval = models.DateField(null=True, blank=True) @@ -1117,6 +1125,13 @@ def save(self, *args, **kwargs): def get_doses_per_vial(self): return DOSES_PER_VIAL[self.request_form.vaccine_type] + class Meta: + indexes = [ + models.Index(fields=["request_form", "estimated_arrival_time"]), # Used together in queries + models.Index(fields=["po_number"]), # Unique field that's queried + models.Index(fields=["date_pre_alert_reception"]), # Used for filtering/ordering + ] + class VaccineArrivalReport(models.Model): request_form = models.ForeignKey(VaccineRequestForm, on_delete=models.CASCADE) @@ -1152,6 +1167,13 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) + class Meta: + indexes = [ + models.Index(fields=["request_form", "arrival_report_date"]), # Frequently queried together + models.Index(fields=["po_number"]), # Unique field that's queried + models.Index(fields=["doses_received"]), # Used in aggregations + ] + class VaccineStock(models.Model): account = models.ForeignKey("iaso.account", on_delete=models.CASCADE, related_name="vaccine_stocks") @@ -1167,6 +1189,10 @@ class VaccineStock(models.Model): class Meta: unique_together = ("country", "vaccine") + indexes = [ + models.Index(fields=["country", "vaccine"]), # Already unique_together, but used in many queries + models.Index(fields=["account"]), # Frequently filtered by account + ] def __str__(self): return f"{self.country} - {self.vaccine}" @@ -1204,6 +1230,13 @@ class Meta: # Form A class OutgoingStockMovement(models.Model): + class Meta: + indexes = [ + models.Index(fields=["vaccine_stock", "campaign"]), # Frequently queried together + models.Index(fields=["form_a_reception_date"]), # Used in ordering + models.Index(fields=["report_date"]), # Used in filtering/ordering + ] + campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE) round = models.ForeignKey(Round, on_delete=models.CASCADE, null=True, blank=True) vaccine_stock = models.ForeignKey( @@ -1234,6 +1267,12 @@ class DestructionReport(models.Model): storage=CustomPublicStorage(), upload_to="public_documents/destructionreport/", null=True, blank=True ) + class Meta: + indexes = [ + models.Index(fields=["vaccine_stock", "destruction_report_date"]), # Used together in queries + models.Index(fields=["rrt_destruction_report_reception_date"]), # Used in filtering + ] + class IncidentReport(models.Model): class StockCorrectionChoices(models.TextChoices): @@ -1262,6 +1301,12 @@ class StockCorrectionChoices(models.TextChoices): storage=CustomPublicStorage(), upload_to="public_documents/incidentreport/", null=True, blank=True ) + class Meta: + indexes = [ + models.Index(fields=["vaccine_stock", "date_of_incident_report"]), # Frequently queried together + models.Index(fields=["incident_report_received_by_rrt"]), # Used in filtering + ] + class Notification(models.Model): """ diff --git a/plugins/polio/tasks/archive_vaccine_stock_for_rounds.py b/plugins/polio/tasks/archive_vaccine_stock_for_rounds.py index 70fd6d1c1f..6656857af5 100644 --- a/plugins/polio/tasks/archive_vaccine_stock_for_rounds.py +++ b/plugins/polio/tasks/archive_vaccine_stock_for_rounds.py @@ -14,21 +14,22 @@ def archive_stock_for_round(round, vaccine_stock, reference_date, country=None): vaccine_stock_for_vaccine = vaccine_stock - if vaccine_stock_for_vaccine.exists(): + if vaccine_stock_for_vaccine: if not country: - vaccine_stock_for_vaccine = vaccine_stock_for_vaccine.filter(country=round.campaign.country.id) - vaccine_stock_for_vaccine = vaccine_stock_for_vaccine.first() - calculator = VaccineStockCalculator(vaccine_stock_for_vaccine) - total_usable_vials_in, total_usable_doses_in = calculator.get_total_of_usable_vials(reference_date) - total_unusable_vials_in, total_unusable_doses_in = calculator.get_total_of_unusable_vials(reference_date) - VaccineStockHistory.objects.create( - vaccine_stock=vaccine_stock_for_vaccine, - round=round, - usable_vials_in=total_usable_vials_in, - usable_doses_in=total_usable_doses_in, - unusable_vials_in=total_unusable_vials_in, - unusable_doses_in=total_unusable_doses_in, - ) + vaccine_stock_for_vaccine = vaccine_stock_for_vaccine.filter(country=round.campaign.country) + if vaccine_stock_for_vaccine: + vaccine_stock_for_vaccine = vaccine_stock_for_vaccine.first() + calculator = VaccineStockCalculator(vaccine_stock_for_vaccine) + total_usable_vials_in, total_usable_doses_in = calculator.get_total_of_usable_vials(reference_date) + total_unusable_vials_in, total_unusable_doses_in = calculator.get_total_of_unusable_vials(reference_date) + VaccineStockHistory.objects.create( + vaccine_stock=vaccine_stock_for_vaccine, + round=round, + usable_vials_in=total_usable_vials_in, + usable_doses_in=total_usable_doses_in, + unusable_vials_in=total_unusable_vials_in, + unusable_doses_in=total_unusable_doses_in, + ) @task_decorator(task_name="archive_vaccine_stock") @@ -38,7 +39,9 @@ def archive_vaccine_stock_for_rounds(date=None, country=None, campaign=None, vac reference_date = datetime.strptime(date, "%Y-%m-%d") if date else task_start round_end_date = reference_date - timedelta(days=14) - rounds_qs = Round.objects.filter(ended_at__lte=round_end_date, campaign__account=account) + rounds_qs = Round.objects.filter(ended_at__lte=round_end_date, campaign__account=account).select_related( + "campaign__country" + ) if country: rounds_qs = rounds_qs.filter(campaign__country__id=country) @@ -69,20 +72,21 @@ def archive_vaccine_stock_for_rounds(date=None, country=None, campaign=None, vac i = 0 for vax in vaccines: qs = vax_dict[vax] - vaccine_stock = VaccineStock.objects.filter(vaccine=vax) for r in qs: i += 1 - try: - archive_stock_for_round(round=r, reference_date=reference_date, vaccine_stock=vaccine_stock) - task.report_progress_and_stop_if_killed( - progress_value=i, - end_value=count, - progress_message=f"Stock history added for {r.pk} and vaccine {vax}", - ) - except IntegrityError: - task.report_progress_and_stop_if_killed( - progress_value=i, - end_value=count, - progress_message=f"Could not add stock history for round {r.pk} vaccine {vax}. History already exists", - ) + vaccine_stock = VaccineStock.objects.filter(vaccine=vax) + if vaccine_stock: + try: + archive_stock_for_round(round=r, reference_date=reference_date, vaccine_stock=vaccine_stock) + task.report_progress_and_stop_if_killed( + progress_value=i, + end_value=count, + progress_message=f"Stock history added for {r.pk} and vaccine {vax}", + ) + except IntegrityError: + task.report_progress_and_stop_if_killed( + progress_value=i, + end_value=count, + progress_message=f"Could not add stock history for round {r.pk} vaccine {vax}. History already exists", + ) diff --git a/plugins/polio/tests/api/dashboards/test_subactivities.py b/plugins/polio/tests/api/dashboards/test_subactivities.py new file mode 100644 index 0000000000..81bf6f62ce --- /dev/null +++ b/plugins/polio/tests/api/dashboards/test_subactivities.py @@ -0,0 +1,71 @@ +import datetime + +from django.contrib.auth.models import AnonymousUser +from django.utils.timezone import now + +from iaso.models.base import Group +from iaso.models.org_unit import OrgUnitType +from iaso.test import APITestCase +from plugins.polio.models import SubActivity, SubActivityScope +from plugins.polio.tests.api.test import PolioTestCaseMixin +from plugins.polio.tests.test_api import PolioAPITestCase + +BASE_URL = "/api/polio/dashboards/subactivities" + + +class SubactivitiesAPITestCase(APITestCase, PolioTestCaseMixin): + @classmethod + def setUpTestData(cls) -> None: + cls.account, cls.data_source, cls.source_version, cls.project = cls.create_account_datasource_version_project( + "Account", "Data source", "Project" + ) + cls.user, cls.anon, cls.user_no_perms = cls.create_base_users(cls.account, ["iaso_polio"]) + cls.country_type = OrgUnitType.objects.create(name="COUNTRY", short_name="COUNTRY") + cls.district_type = OrgUnitType.objects.create(name="DISTRICT", short_name="DISTRICT") + cls.campaign, cls.rnd1, cls.rnd2, cls.rnd3, cls.country, cls.district = cls.create_campaign( + obr_name="Test Campaign", + account=cls.account, + source_version=cls.source_version, + country_ou_type=cls.country_type, + district_ou_type=cls.district_type, + ) + + cls.sub_activity = SubActivity.objects.create( + name="Test SubActivity", + round=cls.rnd1, + start_date=datetime.date(2022, 1, 1), + end_date=datetime.date(2022, 1, 31), + ) + cls.group = Group.objects.create(name="Test group", source_version=cls.source_version) + cls.group.org_units.add(cls.district) + + cls.sub_activity_scope = SubActivityScope.objects.create( + subactivity=cls.sub_activity, group=cls.group, vaccine="mOPV2" + ) + + def test_anonymous_user_cannot_get(self): + self.client.force_authenticate(self.anon) + response = self.client.get(f"{BASE_URL}/") + self.assertEqual(response.status_code, 403) + + def test_default_pagination_is_added(self): + self.client.force_authenticate(self.user) + response = self.client.get(f"{BASE_URL}/") + data = self.assertJSONResponse(response, 200) + self.assertEqual(data["page"], 1) + self.assertEqual(data["limit"], 20) + + def test_max_page_size_is_enforced(self): + default_max_page_size = 1000 # default value from EtlPaginator + self.client.force_authenticate(self.user) + response = self.client.get(f"{BASE_URL}/?limit=2000&page=1") + data = self.assertJSONResponse(response, 200) + self.assertEqual(data["page"], 1) + self.assertEqual(data["limit"], default_max_page_size) + + def test_get_sub_activities(self): + self.client.force_authenticate(self.user) + response = self.client.get(f"{BASE_URL}/") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["name"], "Test SubActivity") diff --git a/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py b/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py new file mode 100644 index 0000000000..1c752f8338 --- /dev/null +++ b/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py @@ -0,0 +1,75 @@ +import datetime +from iaso.models.base import Group +from iaso.models.org_unit import OrgUnitType +from iaso.test import APITestCase +from plugins.polio.models import SubActivity, SubActivityScope +from plugins.polio.tests.api.test import PolioTestCaseMixin + +BASE_URL = "/api/polio/dashboards/subactivityscopes" + + +class SubactivitiesAPITestCase(APITestCase, PolioTestCaseMixin): + @classmethod + def setUpTestData(cls) -> None: + cls.account, cls.data_source, cls.source_version, cls.project = cls.create_account_datasource_version_project( + "Account", "Data source", "Project" + ) + cls.user, cls.anon, cls.user_no_perms = cls.create_base_users(cls.account, ["iaso_polio"]) + cls.country_type = OrgUnitType.objects.create(name="COUNTRY", short_name="COUNTRY") + cls.district_type = OrgUnitType.objects.create(name="DISTRICT", short_name="DISTRICT") + cls.campaign, cls.rnd1, cls.rnd2, cls.rnd3, cls.country, cls.district = cls.create_campaign( + obr_name="Test Campaign", + account=cls.account, + source_version=cls.source_version, + country_ou_type=cls.country_type, + district_ou_type=cls.district_type, + ) + + cls.sub_activity = SubActivity.objects.create( + name="Test SubActivity", + round=cls.rnd1, + start_date=datetime.date(2022, 1, 1), + end_date=datetime.date(2022, 1, 31), + ) + cls.group = Group.objects.create(name="Test group", source_version=cls.source_version) + cls.group.org_units.add(cls.district) + + cls.sub_activity_scope = SubActivityScope.objects.create( + subactivity=cls.sub_activity, group=cls.group, vaccine="mOPV2" + ) + + def test_anonymous_user_cannot_get(self): + self.client.force_authenticate(self.anon) + response = self.client.get(f"{BASE_URL}/") + self.assertEqual(response.status_code, 403) + + def test_default_pagination_is_added(self): + self.client.force_authenticate(self.user) + response = self.client.get(f"{BASE_URL}/") + data = self.assertJSONResponse(response, 200) + self.assertEqual(data["page"], 1) + self.assertEqual(data["limit"], 20) + + def test_max_page_size_is_enforced(self): + default_max_page_size = 1000 # default value from EtlPaginator + self.client.force_authenticate(self.user) + response = self.client.get(f"{BASE_URL}/?limit=2000&page=1") + data = self.assertJSONResponse(response, 200) + self.assertEqual(data["page"], 1) + self.assertEqual(data["limit"], default_max_page_size) + + def test_get_sub_activity_scopes(self): + self.client.force_authenticate(self.user) + response = self.client.get(f"{BASE_URL}/") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data["results"]), 1) + self.assertEqual(response.data["results"][0]["subactivity_name"], self.sub_activity.name) + self.assertEqual(response.data["results"][0]["subactivity"], self.sub_activity.pk) + self.assertEqual(response.data["results"][0]["id"], self.sub_activity_scope.pk) + self.assertEqual(response.data["results"][0]["obr_name"], self.campaign.obr_name) + self.assertEqual(response.data["results"][0]["round_number"], self.rnd1.number) + self.assertEqual(response.data["results"][0]["vaccine"], self.sub_activity_scope.vaccine) + self.assertEqual(response.data["results"][0]["group"], self.sub_activity_scope.group.pk) + self.assertEqual(len(response.data["results"][0]["org_units"]), 1) + self.assertEqual(response.data["results"][0]["org_units"][0]["name"], self.district.name) + self.assertEqual(response.data["results"][0]["org_units"][0]["id"], self.district.pk) diff --git a/plugins/polio/tests/test_vaccine_repository_forms.py b/plugins/polio/tests/test_vaccine_repository_forms.py index 9f09655a39..4465b67bb1 100644 --- a/plugins/polio/tests/test_vaccine_repository_forms.py +++ b/plugins/polio/tests/test_vaccine_repository_forms.py @@ -23,7 +23,7 @@ def setUp(cls): cls.org_unit_type_country = m.OrgUnitType.objects.create(name="Country") cls.org_unit_type_district = m.OrgUnitType.objects.create(name="District") - cls.campaign, cls.campaign_round_1, _, _, cls.testland, _district = cls.create_campaign( + cls.campaign, cls.campaign_round_1, _, _, cls.testland, _ = cls.create_campaign( obr_name="Test Campaign", account=cls.account, source_version=cls.source_version_1, @@ -31,6 +31,9 @@ def setUp(cls): country_name="Testland", district_ou_type=cls.org_unit_type_district, ) + cls.campaign_round_1.started_at = datetime.datetime(2021, 1, 4) + cls.campaign_round_1.save() + cls.campaign_no_vrf, cls.campaign_no_vrf_round_1, _, _, _, _ = cls.create_campaign( obr_name="No VRF", account=cls.account, @@ -42,6 +45,28 @@ def setUp(cls): # Has same country as cls.campaign, but no vrf. Should not appear in any API payload cls.campaign_no_vrf.country = cls.testland cls.campaign_no_vrf.save() + cls.campaign_deleted_vrf, cls.campaign_deleted_vrf_round_1, _, _, _, _ = cls.create_campaign( + obr_name="DELETED VRF", + account=cls.account, + source_version=cls.source_version_1, + country_ou_type=cls.org_unit_type_country, + country_name="Testland", + district_ou_type=cls.org_unit_type_district, + ) + # Has same country as cls.campaign, but no vrf. Should not appear in any API payload + cls.campaign_deleted_vrf.country = cls.testland + cls.campaign_deleted_vrf.save() + + cls.deleted_vaccine_request_form = pm.VaccineRequestForm.objects.create( + campaign=cls.campaign_no_vrf, + vaccine_type=pm.VACCINES[0][0], + date_vrf_reception=cls.now - datetime.timedelta(days=30), + date_vrf_signature=cls.now - datetime.timedelta(days=20), + date_dg_approval=cls.now - datetime.timedelta(days=10), + quantities_ordered_in_doses=500, + deleted_at=cls.now, + ) + cls.deleted_vaccine_request_form.rounds.set([cls.campaign_deleted_vrf_round_1]) cls.zambia = m.OrgUnit.objects.create( org_unit_type=cls.org_unit_type_country, @@ -119,6 +144,7 @@ def test_list_only_returns_campaigns_with_vrf_or_forma(self): results = data["results"] campaign_names = [r["campaign_obr_name"] for r in results] self.assertNotIn(self.campaign_no_vrf.obr_name, campaign_names) + self.assertNotIn(self.campaign_deleted_vrf.obr_name, campaign_names) self.assertIn(forma_campaign.obr_name, campaign_names) def test_list_response_structure(self): @@ -229,6 +255,12 @@ def test_ordering(self): self.client.force_authenticate(user=self.user) + # Test default ordering => -campaign start date + response = self.client.get(f"{BASE_URL}") + data = response.json() + self.assertEqual(data["results"][0]["campaign_obr_name"], "Test Campaign") + self.assertEqual(data["results"][3]["campaign_obr_name"], "Another Campaign") + # Test ordering by campaign name response = self.client.get(f"{BASE_URL}?order=campaign__obr_name") data = response.json() @@ -268,14 +300,15 @@ def test_ordering(self): # Test ordering by start date response = self.client.get(f"{BASE_URL}?order=started_at") data = response.json() - self.assertEqual(data["results"][0]["start_date"], self.campaign_round_1.started_at.strftime("%Y-%m-%d")) - self.assertEqual(data["results"][1]["start_date"], campaign2_round.started_at.strftime("%Y-%m-%d")) + self.assertEqual(data["results"][0]["start_date"], campaign2_round.started_at.strftime("%Y-%m-%d")) + self.assertEqual(data["results"][1]["start_date"], self.campaign_round_1.started_at.strftime("%Y-%m-%d")) # Test reverse ordering by start date response = self.client.get(f"{BASE_URL}?order=-started_at") data = response.json() self.assertEqual(data["results"][0]["start_date"], campaign2_rnd3.started_at.strftime("%Y-%m-%d")) - self.assertEqual(data["results"][3]["start_date"], self.campaign_round_1.started_at.strftime("%Y-%m-%d")) + self.assertEqual(data["results"][2]["start_date"], self.campaign_round_1.started_at.strftime("%Y-%m-%d")) + self.assertEqual(data["results"][3]["start_date"], campaign2_round.started_at.strftime("%Y-%m-%d")) def test_filtering(self): """Test filtering functionality of VaccineReportingViewSet""" diff --git a/plugins/wfp/templates/delete_all_instances_and_entities.html b/plugins/wfp/templates/delete_all_instances_and_entities.html index b9aa9ba990..bc18e08ccf 100644 --- a/plugins/wfp/templates/delete_all_instances_and_entities.html +++ b/plugins/wfp/templates/delete_all_instances_and_entities.html @@ -8,12 +8,20 @@

{% csrf_token %} -
-
- -
-
- + + +
+
+ + +
+
+ + +
+
diff --git a/plugins/wfp/views.py b/plugins/wfp/views.py index 5c28380d4c..45d85a1846 100644 --- a/plugins/wfp/views.py +++ b/plugins/wfp/views.py @@ -34,6 +34,7 @@ def delete_beneficiaries_analytics(request): def delete_all_instances_and_entities(request): dry_run = request.POST.get("dry_run", False) account = request.POST.get("account", None) + entity_type_id = request.POST.get("entity_type_id", None) if request.method == "POST": out = io.StringIO() @@ -41,6 +42,7 @@ def delete_all_instances_and_entities(request): "delete_all_instances_and_entities", dry_run=dry_run == "on", account=account, + entity_type_id=entity_type_id, stdout=out, ) output = out.getvalue()