From bbdee68b1a7eeed9f91cab9de2af9ec42fad3a76 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Tue, 3 Dec 2024 18:37:46 +0200 Subject: [PATCH 01/78] default data source on groups: WIP --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 105 ++++++++++++++++-- .../orgUnits/reviewChanges/messages.ts | 16 +++ iaso/api/groups.py | 18 ++- 3 files changed, 127 insertions(+), 12 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index a404573b15..b52f532737 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -1,6 +1,13 @@ -import React, { FunctionComponent, useCallback, useMemo } from 'react'; -import { Box, Grid } from '@mui/material'; -import { useSafeIntl } from 'bluesquare-components'; +import React, { + FunctionComponent, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { Box, Grid, Typography } from '@mui/material'; +import { commonStyles, useSafeIntl } from 'bluesquare-components'; +import { makeStyles } from '@mui/styles'; import { FilterButton } from '../../../../components/FilterButton'; import { useFilterState } from '../../../../hooks/useFilterState'; import InputComponent from '../../../../components/forms/InputComponent'; @@ -21,23 +28,59 @@ import { useGetProfilesDropdown } from '../../../instances/hooks/useGetProfilesD import { useGetUserRolesDropDown } from '../../../userRoles/hooks/requests/useGetUserRoles'; import { useGetProjectsDropdownOptions } from '../../../projects/hooks/requests'; import { usePaymentStatusOptions } from '../hooks/api/useGetPaymentStatusOptions'; +import { useGetGroupDropdown } from '../../hooks/requests/useGetGroups'; +import { useGetDataSources } from '../../hooks/requests/useGetDataSources'; +import { useDefaultSourceVersion } from '../../../dataSources/utils'; const baseUrl = baseUrls.orgUnitsChangeRequest; type Props = { params: ApproveOrgUnitParams }; - +const useStyles = makeStyles(theme => ({ + ...commonStyles(theme), + advancedSettings: { + color: theme.palette.primary.main, + alignSelf: 'center', + textAlign: 'right', + flex: '1', + cursor: 'pointer', + }, +})); export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ params, }) => { + const defaultSourceVersion = useDefaultSourceVersion(); + const classes = useStyles(); const { formatMessage } = useSafeIntl(); const { filters, handleSearch, handleChange, filtersUpdated } = useFilterState({ baseUrl, params }); + const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); 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; + const { data: groupOptions, isLoading: isLoadingGroups } = + useGetGroupDropdown({}); + // const groupOptions = []; + // const isLoadingGroups = false; // IA-3641 -----END + const { data: dataSources, isFetching: isFetchingDataSources } = + useGetDataSources(true); + const initialDataSource = + dataSources?.find( + source => + source.value === defaultSourceVersion.source.id.toString(), + )?.value || ''; + + const [dataSource, setDataSource] = useState(initialDataSource); + + useEffect(() => { + const updatedDataSource = dataSources?.find( + source => + source.value === defaultSourceVersion.source.id.toString(), + )?.value; + + if (updatedDataSource) { + setDataSource(updatedDataSource); + } + }, [dataSources, defaultSourceVersion.source.id]); + const { data: orgUnitTypeOptions, isLoading: isLoadingTypes } = useGetOrgUnitTypesDropdownOptions(); const { data: forms, isFetching: isLoadingForms } = useGetForms(); @@ -82,6 +125,10 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ [handleChange], ); + const handleDataSourceChange = (_, newValue) => { + setDataSource(newValue); + }; + return ( @@ -117,9 +164,47 @@ 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, + )} + + + + )} + 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/iaso/api/groups.py b/iaso/api/groups.py index a7c702e42c..f2a069b78e 100644 --- a/iaso/api/groups.py +++ b/iaso/api/groups.py @@ -164,12 +164,26 @@ 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) + data_source = ( + self.request.query_params.get("data_source") + if self.request.query_params.get("data_source") + else account.default_version + ) + versions = SourceVersion.objects.filter( + data_source__projects__account=account, data_source__default_version=data_source + ) else: # this check if project need auth + data_source = ( + self.request.query_params.get("data_source") + if self.request.query_params.get("data_source") + else project.account.default_version + ) project = Project.objects.get_for_user_and_app_id(user, app_id) - versions = SourceVersion.objects.filter(data_source__projects=project) + versions = SourceVersion.objects.filter( + data_source__projects=project, data_source__default_version=data_source + ) groups = Group.objects.filter(source_version__in=versions).distinct() queryset = self.filter_queryset(groups) From dfca86165863d40c3eeefef6b0bca560625eeb3a Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Thu, 5 Dec 2024 10:11:45 +0200 Subject: [PATCH 02/78] add source versions dropdown --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 45 +++++++++++++++---- iaso/api/groups.py | 16 +++---- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index b52f532737..0281c6d6e2 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -31,6 +31,7 @@ import { usePaymentStatusOptions } from '../hooks/api/useGetPaymentStatusOptions import { useGetGroupDropdown } from '../../hooks/requests/useGetGroups'; import { useGetDataSources } from '../../hooks/requests/useGetDataSources'; import { useDefaultSourceVersion } from '../../../dataSources/utils'; +import { useGetVersionLabel } from '../../hooks/useGetVersionLabel'; const baseUrl = baseUrls.orgUnitsChangeRequest; type Props = { params: ApproveOrgUnitParams }; @@ -53,20 +54,20 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ const { filters, handleSearch, handleChange, filtersUpdated } = useFilterState({ baseUrl, params }); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + 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: dataSources, isFetching: isFetchingDataSources } = useGetDataSources(true); - const initialDataSource = - dataSources?.find( - source => - source.value === defaultSourceVersion.source.id.toString(), - )?.value || ''; + const initialDataSource = useMemo( + () => + dataSources?.find( + source => + source.value === defaultSourceVersion.source.id.toString(), + )?.value || '', + [dataSources, defaultSourceVersion.source.id], + ); const [dataSource, setDataSource] = useState(initialDataSource); @@ -129,6 +130,21 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ setDataSource(newValue); }; + const getVersionLabel = useGetVersionLabel(dataSources); + + const versionsDropDown = useMemo(() => { + if (!dataSources || !initialDataSource) return []; + return ( + dataSources + .filter(src => src.value === initialDataSource)[0] + ?.original?.versions.sort((a, b) => a.number - b.number) + .map(version => ({ + label: getVersionLabel(version.id), + value: version.id.toString(), + })) ?? [] + ); + }, [dataSources, getVersionLabel, initialDataSource]); + return ( @@ -188,6 +204,17 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ options={dataSources} loading={isFetchingDataSources} /> + Date: Thu, 5 Dec 2024 18:43:29 +0200 Subject: [PATCH 03/78] WIP: update default_version on handle handleDataSourceChange --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index 0281c6d6e2..8fde16ba10 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -49,6 +49,9 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ params, }) => { const defaultSourceVersion = useDefaultSourceVersion(); + const [defaultSelectedVersionId, setDefaultSelectedVersionId] = useState( + defaultSourceVersion.version.id, + ); const classes = useStyles(); const { formatMessage } = useSafeIntl(); const { filters, handleSearch, handleChange, filtersUpdated } = @@ -128,22 +131,28 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ const handleDataSourceChange = (_, newValue) => { setDataSource(newValue); + const selectedSource = dataSources?.filter( + source => source.value === newValue, + )[0]; + setDefaultSelectedVersionId( + selectedSource?.original?.default_version.id, + ); }; const getVersionLabel = useGetVersionLabel(dataSources); const versionsDropDown = useMemo(() => { - if (!dataSources || !initialDataSource) return []; + if (!dataSources || !dataSource) return []; return ( dataSources - .filter(src => src.value === initialDataSource)[0] + .filter(src => src.value === dataSource)[0] ?.original?.versions.sort((a, b) => a.number - b.number) .map(version => ({ label: getVersionLabel(version.id), value: version.id.toString(), })) ?? [] ); - }, [dataSources, getVersionLabel, initialDataSource]); + }, [dataSource, dataSources, getVersionLabel]); return ( @@ -209,7 +218,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ disabled={isFetchingDataSources} keyValue="version" onChange={handleChange} - value={defaultSourceVersion.version.id} + value={defaultSelectedVersionId} label={MESSAGES.sourceVersion} options={versionsDropDown} clearable={false} From 7d4b4029a66ecb3f9f387013f810d5a581fe51e1 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Fri, 6 Dec 2024 12:13:38 +0100 Subject: [PATCH 04/78] wip --- .../orgUnits/components/OrgUnitsFilters.tsx | 163 +++++++++--------- 1 file changed, 83 insertions(+), 80 deletions(-) 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..6d16a13415 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,62 @@ 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 + + // 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 dataSource = dataSources?.find( + src => src?.original?.id === newDataSourceId, + ); + const versions = dataSource?.original?.versions || []; + const newSourceVersionId = + dataSource?.original?.default_version?.id || + (versions.length > 0 + ? versions[versions.length - 1]?.id + : undefined); + 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, dataSources], + ); const handleLocationLimitChange = useCallback( (key: string, value: number) => { @@ -172,6 +176,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(() => { @@ -213,34 +242,8 @@ export const OrgUnitFilters: FunctionComponent = ({ } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // USE EFFECTS - // 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]); - - useSkipEffectOnMount(() => { - if (filters !== currentSearch) { - setFilters(currentSearch); - } - }, [currentSearch]); const versionsDropDown = useMemo(() => { if (!dataSources || !dataSourceId) return []; return ( From c137bca3a83019640735920b2f9707748d916d22 Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Fri, 4 Oct 2024 14:43:07 +0200 Subject: [PATCH 05/78] SLEEP-1499 Support embedding of Superset dashboard in Iaso - Add a new pages template that supports embedding a new type of page "SUPERSET" - Add an API endpoint POST /api/superset/token that creates a Superset "guest token" for a specific dashboard --- docker-compose.yml | 12 +++- .../js/apps/Iaso/domains/pages/constants.js | 5 ++ .../js/apps/Iaso/domains/pages/messages.js | 4 ++ hat/settings.py | 5 ++ hat/templates/iaso/pages/superset.html | 57 ++++++++++++++++++ iaso/admin.py | 1 + iaso/api/superset.py | 59 +++++++++++++++++++ iaso/migrations/0300_alter_page_type.py | 27 +++++++++ iaso/models/__init__.py | 2 +- iaso/models/pages.py | 2 + iaso/urls.py | 4 +- iaso/views.py | 10 +++- 12 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 hat/templates/iaso/pages/superset.html create mode 100644 iaso/api/superset.py create mode 100644 iaso/migrations/0300_alter_page_type.py diff --git a/docker-compose.yml b/docker-compose.yml index 6d730a6402..6d565dcaf2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,7 +84,17 @@ services: WFP_EMAIL_RECIPIENTS_NEW_ACCOUNT: DISABLE_PASSWORD_LOGINS: MAINTENANCE_MODE: - SERVER_URL: # Limit logging in dev to not overflow terminal + SERVER_URL: + SUPERSET_DB_NAME: + SUPERSET_DB_HOSTNAME: + SUPERSET_DB_PASSWORD: + SUPERSET_PORT: + SUPERSET_DB_USERNAME: + SUPERSET_SECRET_KEY: + SUPERSET_IASO_USER_TOKEN: + SUPERSET_URL: + SUPERSET_ADMIN_USERNAME: + SUPERSET_ADMIN_PASSWORD: logging: &iaso_logging driver: 'json-file' options: 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..4918b0a8cd 100644 --- a/hat/assets/js/apps/Iaso/domains/pages/messages.js +++ b/hat/assets/js/apps/Iaso/domains/pages/messages.js @@ -118,6 +118,10 @@ const MESSAGES = defineMessages({ id: 'iaso.label.rawHtml', defaultMessage: 'Raw html', }, + superset: { + id: 'iaso.label.superset', + defaultMessage: 'Superset', + }, needsAuthentication: { id: 'iaso.label.needsAuthentication', defaultMessage: 'Authentification required', 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..37c9efdec1 --- /dev/null +++ b/hat/templates/iaso/pages/superset.html @@ -0,0 +1,57 @@ + + + + {{title}} + {{ analytics_script | safe }} + + + + +
+ + + + {% include "iaso/pages/refresh_data_set_snippet.html" %} + + 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/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/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/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..dceddb3826 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")), ] 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..b1605ea8c0 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,14 @@ def page(request, page_slug): "iaso/pages/powerbi.html", content, ) + elif page.type == SUPERSET: + # TODO: Use dedicated column for dashboard_id? + content.update({"dashboard_id": page.content, "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: From f3551bc1b28564cb8112c383faed780a293741a6 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Fri, 6 Dec 2024 13:33:24 +0100 Subject: [PATCH 06/78] select default version --- .../orgUnits/components/OrgUnitsFilters.tsx | 42 +++++++++++-------- 1 file changed, 24 insertions(+), 18 deletions(-) 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 6d16a13415..67e1b088d7 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsFilters.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsFilters.tsx @@ -119,6 +119,22 @@ export const OrgUnitFilters: FunctionComponent = ({ } = useGetOrgUnitValidationStatus(true); // 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) => { @@ -131,15 +147,8 @@ export const OrgUnitFilters: FunctionComponent = ({ if (key === 'source') { setInitialOrgUnitId(undefined); const newDataSourceId = parseInt(value, 10); - const dataSource = dataSources?.find( - src => src?.original?.id === newDataSourceId, - ); - const versions = dataSource?.original?.versions || []; const newSourceVersionId = - dataSource?.original?.default_version?.id || - (versions.length > 0 - ? versions[versions.length - 1]?.id - : undefined); + getNewSourceVersionId(newDataSourceId); setSourceVersionId(newSourceVersionId); newFilters.version = newSourceVersionId?.toString(); setDataSourceId(newDataSourceId); @@ -167,7 +176,7 @@ export const OrgUnitFilters: FunctionComponent = ({ tempSearches[searchIndex] = newFilters; setSearches(tempSearches); }, - [filters, searches, searchIndex, setSearches, dataSources], + [filters, searches, searchIndex, setSearches, getNewSourceVersionId], ); const handleLocationLimitChange = useCallback( @@ -223,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, @@ -240,8 +241,13 @@ 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 }, []); + // USE EFFECTS const versionsDropDown = useMemo(() => { From 7ab3e382eeea019c440192fa9188256dc58848a0 Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Fri, 6 Dec 2024 15:43:28 +0100 Subject: [PATCH 07/78] SLEEP-1499 Add Superset-specific config fields to Pages --- iaso/migrations/0312_merge_20241206_1243.py | 12 ++++++++++ ...313_page_superset_dashboard_id_and_more.py | 22 +++++++++++++++++++ iaso/models/pages.py | 3 +++ 3 files changed, 37 insertions(+) create mode 100644 iaso/migrations/0312_merge_20241206_1243.py create mode 100644 iaso/migrations/0313_page_superset_dashboard_id_and_more.py 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/pages.py b/iaso/models/pages.py index dceddb3826..1aef17926a 100644 --- a/iaso/models/pages.py +++ b/iaso/models/pages.py @@ -52,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,) From eb262fddb8e8569306b8dcf1df7d610a17c7dd85 Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Fri, 6 Dec 2024 15:51:02 +0100 Subject: [PATCH 08/78] SLEEP-1499 Improve configurability of Superset embedding --- .../Iaso/domains/app/translations/en.json | 4 ++- .../Iaso/domains/app/translations/fr.json | 4 ++- .../pages/components/CreateEditDialog.js | 30 ++++++++++++++----- .../js/apps/Iaso/domains/pages/messages.js | 6 +++- hat/templates/iaso/pages/superset.html | 30 ++++++++----------- iaso/views.py | 13 ++++++-- 6 files changed, 56 insertions(+), 31 deletions(-) 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 7fcba338b9..27a16b560e 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/en.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/en.json @@ -945,6 +945,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", @@ -1573,4 +1575,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 a3af1b3dd2..02eec6ecf8 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json @@ -945,6 +945,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", @@ -1572,4 +1574,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/pages/components/CreateEditDialog.js b/hat/assets/js/apps/Iaso/domains/pages/components/CreateEditDialog.js index ac96253f45..bd8ec5dac5 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,27 @@ const CreateEditDialog = ({ isOpen, onClose, selectedPage }) => { />
- + {type === SUPERSET && ( + <> + + + )} + {type !== SUPERSET && ( + + )}
diff --git a/hat/assets/js/apps/Iaso/domains/pages/messages.js b/hat/assets/js/apps/Iaso/domains/pages/messages.js index 4918b0a8cd..c0fa8dc5bc 100644 --- a/hat/assets/js/apps/Iaso/domains/pages/messages.js +++ b/hat/assets/js/apps/Iaso/domains/pages/messages.js @@ -119,9 +119,13 @@ const MESSAGES = defineMessages({ defaultMessage: 'Raw html', }, superset: { - id: 'iaso.label.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/templates/iaso/pages/superset.html b/hat/templates/iaso/pages/superset.html index 37c9efdec1..31ab45803a 100644 --- a/hat/templates/iaso/pages/superset.html +++ b/hat/templates/iaso/pages/superset.html @@ -7,41 +7,35 @@ -
- +
+ {{ config|json_script:'config' }} + @@ -29,7 +30,7 @@ document.getElementById('config').textContent, ); - supersetEmbeddedSdk.embedDashboard({ + window.embedDashboard({ id: config.dashboard_id, supersetDomain: config.superset_url, mountPoint: containerRef, 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/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", From 4b3ddce84549610a004c9b82076a5e706526cdb9 Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Fri, 6 Dec 2024 16:19:45 +0100 Subject: [PATCH 10/78] SLEEP-1499 Cleanup: remove unneeded ENV vars --- docker-compose.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6d565dcaf2..fb0f911cac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,13 +85,6 @@ services: DISABLE_PASSWORD_LOGINS: MAINTENANCE_MODE: SERVER_URL: - SUPERSET_DB_NAME: - SUPERSET_DB_HOSTNAME: - SUPERSET_DB_PASSWORD: - SUPERSET_PORT: - SUPERSET_DB_USERNAME: - SUPERSET_SECRET_KEY: - SUPERSET_IASO_USER_TOKEN: SUPERSET_URL: SUPERSET_ADMIN_USERNAME: SUPERSET_ADMIN_PASSWORD: From c8c4982b949af117c6b43592fb6a3290f3e6899f Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Mon, 9 Dec 2024 14:42:25 +0200 Subject: [PATCH 11/78] onChange data source versions --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index 8fde16ba10..e5942f2031 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -15,8 +15,6 @@ import { baseUrls } from '../../../../constants/urls'; import MESSAGES from '../messages'; import { OrgUnitTreeviewModal } from '../../components/TreeView/OrgUnitTreeviewModal'; import { useGetOrgUnit } from '../../components/TreeView/requests'; -// IA-3641 uncomment when UI has been refactored to limlit size of API call -// import { useGetGroupDropdown } from '../../hooks/requests/useGetGroups'; import { useGetOrgUnitTypesDropdownOptions } from '../../orgUnitTypes/hooks/useGetOrgUnitTypesDropdownOptions'; import { DropdownOptions } from '../../../../types/utils'; import DatesRange from '../../../../components/filters/DatesRange'; @@ -49,7 +47,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ params, }) => { const defaultSourceVersion = useDefaultSourceVersion(); - const [defaultSelectedVersionId, setDefaultSelectedVersionId] = useState( + const [selectedVersionId, setSelectedVersionId] = useState( defaultSourceVersion.version.id, ); const classes = useStyles(); @@ -129,14 +127,16 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ [handleChange], ); - const handleDataSourceChange = (_, newValue) => { - setDataSource(newValue); - const selectedSource = dataSources?.filter( - source => source.value === newValue, - )[0]; - setDefaultSelectedVersionId( - selectedSource?.original?.default_version.id, - ); + const handleDataSourceVersionChange = (key, newValue) => { + if (key === 'source') { + setDataSource(newValue); + const selectedSource = dataSources?.filter( + source => source.value === newValue, + )[0]; + setSelectedVersionId(selectedSource?.original?.default_version.id); + } else { + setSelectedVersionId(newValue); + } }; const getVersionLabel = useGetVersionLabel(dataSources); @@ -207,7 +207,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ type="select" disabled={isFetchingDataSources} keyValue="source" - onChange={handleDataSourceChange} + onChange={handleDataSourceVersionChange} value={isFetchingDataSources ? '' : dataSource} label={MESSAGES.source} options={dataSources} @@ -217,8 +217,8 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ type="select" disabled={isFetchingDataSources} keyValue="version" - onChange={handleChange} - value={defaultSelectedVersionId} + onChange={handleDataSourceVersionChange} + value={selectedVersionId} label={MESSAGES.sourceVersion} options={versionsDropDown} clearable={false} From bdf7e2a107260872a4a44cab13ba181345abf5af Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Mon, 9 Dec 2024 16:32:59 +0200 Subject: [PATCH 12/78] update groupOptions when the version is changed --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 17 +++++++++++++-- iaso/api/groups.py | 21 ++++++++----------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index e5942f2031..67512bc608 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -57,8 +57,15 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const { data: initialOrgUnit } = useGetOrgUnit(params.parent_id); - const { data: groupOptions, isLoading: isLoadingGroups } = - useGetGroupDropdown({}); + const { + data: groupOptions, + isLoading: isLoadingGroups, + refetch: refetchGroups, + } = useGetGroupDropdown( + selectedVersionId + ? { defaultVersion: selectedVersionId.toString() } + : {}, + ); const { data: dataSources, isFetching: isFetchingDataSources } = useGetDataSources(true); const initialDataSource = useMemo( @@ -70,6 +77,12 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ [dataSources, defaultSourceVersion.source.id], ); + useEffect(() => { + if (selectedVersionId) { + refetchGroups(); + } + }, [selectedVersionId, refetchGroups]); + const [dataSource, setDataSource] = useState(initialDataSource); useEffect(() => { diff --git a/iaso/api/groups.py b/iaso/api/groups.py index 5dee7e122c..06c15ff6c6 100644 --- a/iaso/api/groups.py +++ b/iaso/api/groups.py @@ -165,25 +165,22 @@ def dropdown(self, request, *args): account = user.iaso_profile.account # Filter on version ids (linked to the account)"" default_version = ( - self.request.query_params.get("default_version") - if self.request.query_params.get("default_version") - else account.default_version - ) - versions = SourceVersion.objects.filter( - data_source__projects__account=account, data_source__default_version=default_version + self.request.query_params.get("defaultVersion") + if self.request.query_params.get("defaultVersion") + else account.default_version.id ) + versions = SourceVersion.objects.filter(data_source__projects__account=account, pk=default_version) else: # this check if project need auth default_version = ( - self.request.query_params.get("default_version") - if self.request.query_params.get("default_version") - else project.account.default_version + self.request.query_params.get("defaultVersion") + if self.request.query_params.get("defaultVersion") + else project.account.default_version.id ) + project = Project.objects.get_for_user_and_app_id(user, app_id) - versions = SourceVersion.objects.filter( - data_source__projects=project, data_source__default_version=default_version - ) + 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) From 0fd678fcc21a1c8d98f0c314cf9bcbfa54c06335 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Mon, 9 Dec 2024 16:31:23 +0100 Subject: [PATCH 13/78] make select work --- .../entities/hooks/useGetBeneficiaryFields.ts | 6 ++ .../entities/hooks/useGetFieldValue.ts | 59 ++++++++++++++++++- .../domains/entities/hooks/useGetFields.ts | 7 ++- .../Iaso/domains/forms/fields/constants.ts | 15 ++++- .../fields/hooks/useGetQueryBuildersFields.ts | 4 +- .../js/apps/Iaso/domains/forms/types/forms.ts | 2 + 6 files changed, 84 insertions(+), 9 deletions(-) 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..8bfd12c0b1 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,6 +38,7 @@ export const useGetBeneficiaryFields = ( detailFields, beneficiary, possibleFields, + formDescriptors, ); const staticFields = useMemo( diff --git a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.ts b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.ts index a350d37ed5..e9f280a569 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.ts +++ b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.ts @@ -3,13 +3,17 @@ import moment from 'moment'; import { Beneficiary, FileContent } from '../types/beneficiary'; -import { FieldType } from '../../forms/types/forms'; +import { FieldType, FormDescriptor } from '../../forms/types/forms'; +import { findDescriptorInChildren } from '../../../utils'; +import { formatLabel } from '../../instances/utils'; import MESSAGES from '../messages'; const textPlaceholder = '--'; -export const useGetFieldValue = (): (( +export const useGetFieldValue = ( + formDescriptors?: FormDescriptor[], +): (( fieldKey: string, fileContent: FileContent | Beneficiary, type: FieldType, @@ -21,7 +25,6 @@ export const useGetFieldValue = (): (( case 'calculate': case 'integer': case 'decimal': - case 'select one': case 'note': { return fileContent[fieldKey] || textPlaceholder; } @@ -37,6 +40,56 @@ export const useGetFieldValue = (): (( ? moment(fileContent[fieldKey]).format('LTS') : textPlaceholder; } + case 'select one': + case 'select_one': { + let value = textPlaceholder; + if (fileContent[fieldKey]) { + formDescriptors?.forEach(formDescriptor => { + const descriptor = findDescriptorInChildren( + fieldKey, + formDescriptor, + ); + if (descriptor?.children) { + const descriptorValue = descriptor.children.find( + child => { + return fileContent[fieldKey] === child.name; + }, + ); + if (descriptorValue) { + value = formatLabel(descriptorValue); + } + } + }); + } + return value; + } + case 'select_all_that_apply': + case 'select all that apply': + case 'select_multiple': + case 'select multiple': { + if (fileContent[fieldKey]) { + const fieldsKeys = fileContent[fieldKey].split(' '); + let listValues = []; + formDescriptors?.forEach(formDescriptor => { + const descriptor = findDescriptorInChildren( + fieldKey, + formDescriptor, + ); + if (descriptor?.children) { + listValues = + descriptor.children + .filter(child => { + return fieldsKeys.includes(child.name); + }) + .map(child => formatLabel(child)) || []; + } + }); + return listValues.length > 0 + ? listValues.join(' - ') + : textPlaceholder; + } + return textPlaceholder; + } case 'time': { return fileContent[fieldKey] ? moment(fileContent[fieldKey]).format('T') diff --git a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFields.ts b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFields.ts 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.ts @@ -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/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' From f293829bcebec69aecd5adf09ed0187babab584d Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Mon, 9 Dec 2024 19:18:14 +0100 Subject: [PATCH 14/78] POLIO-1755: WIP oder by file type --- plugins/polio/api/vaccines/repository.py | 95 +++++++++++++++++++++++- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/plugins/polio/api/vaccines/repository.py b/plugins/polio/api/vaccines/repository.py index ec33a0a92c..adfcd72d36 100644 --- a/plugins/polio/api/vaccines/repository.py +++ b/plugins/polio/api/vaccines/repository.py @@ -83,6 +83,9 @@ def filter_queryset(self, request, queryset, view): return queryset.distinct() +FILE_ORDERING = ["vrf_data", "pre_alert_data", "form_a_data", "-vrf_data", "-pre_alert_data", "-form_a_data"] + + class VaccineRepositorySerializer(serializers.Serializer): country_name = serializers.CharField(source="campaign__country__name") campaign_obr_name = serializers.CharField(source="campaign__obr_name") @@ -100,6 +103,10 @@ def get_vrf_data(self, obj): vrfs = VaccineRequestForm.objects.filter( campaign__id=obj["campaign__id"], rounds=obj["id"], vaccine_type=obj["vaccine_name"] ) + order = self.context.get("request").query_params["order"] + if order in FILE_ORDERING: + ordering_key = "-date_vrf_reception" if "-" in order else "date_vrf_reception" + vrfs = vrfs.order_by(ordering_key) return [ { "date": vrf.date_vrf_reception, @@ -117,6 +124,10 @@ def get_pre_alert_data(self, obj): request_form__rounds=obj["id"], request_form__vaccine_type=obj["vaccine_name"], ) + order = self.context.get("request").query_params["order"] + if order in FILE_ORDERING: + ordering_key = "-date_pre_alert_reception" if "-" in order else "date_pre_alert_reception" + pre_alerts = pre_alerts.order_by(ordering_key) return [ { "date": pa.date_pre_alert_reception, @@ -130,6 +141,11 @@ def get_form_a_data(self, obj): form_as = OutgoingStockMovement.objects.filter( campaign=obj["campaign__id"], round=obj["id"], vaccine_stock__vaccine=obj["vaccine_name"] ) + order = self.context.get("request").query_params["order"] + if order in FILE_ORDERING: + ordering_key = "-form_a_reception_date" if "-" in order else "form_a_reception_date" + form_as = form_as.order_by(ordering_key) + return [ { "date": fa.form_a_reception_date, @@ -142,6 +158,31 @@ def get_form_a_data(self, obj): ] +class CustomOrderingFilter(OrderingFilter): + def filter_queryset(self, request, queryset, view): + ordering = self.get_ordering(request, queryset, view) + print("ordering", ordering) + if ordering and ordering not in FILE_ORDERING: + return queryset.order_by(*ordering) + else: + if "vrf_data" in ordering: + if "-" in ordering: + return queryset.order_by("-last_vrf") + else: + return queryset.order_by("first_vrf") + if "pre_alert_data" in ordering: + if "-" in ordering: + return queryset.order_by("-last_pre_alert") + else: + return queryset.order_by("first_pr_alert") + if "form_a_data" in ordering: + if "-" in ordering: + return queryset.order_by("-last_form_a") + else: + return queryset.order_by("first_form_a") + return queryset + + class VaccineRepositoryViewSet(GenericViewSet, ListModelMixin): """ ViewSet for retrieving vaccine repository data. @@ -149,8 +190,18 @@ class VaccineRepositoryViewSet(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"] + filter_backends = [CustomOrderingFilter, SearchFilter, VaccineReportingFilterBackend] + # TODO rename field in front-end to match first/last_vrf etc + ordering_fields = [ + "campaign__country__name", + "campaign__obr_name", + "started_at", + "vaccine_name", + "number", + "form_a_data", + "pre_alert_data", + "vrf_data", + ] ordering = ["-started_at"] search_fields = ["campaign__country__name", "campaign__obr_name"] permission_classes = [permissions.IsAuthenticatedOrReadOnly] @@ -303,6 +354,44 @@ 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))) + + # Annotate queryset with vrf, pre-alert and formA dates (oldest and latest) to enable ordering + vrfs = ( + VaccineRequestForm.objects.filter( + campaign__id=OuterRef("campaign__id"), + vaccine_type=OuterRef("vaccine_name"), + ) + .filter(Exists(Round.objects.filter(id=OuterRef("id"), vaccinerequestform__id=OuterRef("pk")))) + .annotate(first_vrf=Min("date_vrf_reception"), last_vrf=Max("date_vrf_reception")) + ) + + form_as = ( + OutgoingStockMovement.objects.filter( + campaign=OuterRef("campaign__id"), vaccine_stock__vaccine=OuterRef("vaccine_name") + ) + .filter(Exists(Round.objects.filter(id=OuterRef("id"), outgoingstockmovement__id=OuterRef("pk")))) + .annotate(first_form_a=Min("form_a_reception_date"), last_form_a=Max("form_a_reception_date")) + ) + + pre_alerts = ( + VaccinePreAlert.objects.filter( + request_form__campaign=OuterRef("campaign_id"), + request_form__vaccine_type=OuterRef("vaccine_name"), + ) + .filter( + Exists(Round.objects.filter(id=OuterRef("id"), vaccinerequestform__id=OuterRef("request_form__id"))) + ) + .annotate(first_pre_alert=Min("date_pre_alert_reception"), last_pre_alert=Max("date_pre_alert_reception")) + ) + + rounds_queryset = rounds_queryset.annotate( + first_vrf=Subquery(vrfs.values("first_vrf")), + last_vrf=Subquery(vrfs.values("last_vrf")), + first_form_a=Subquery(form_as.values("first_form_a")), + last_form_a=Subquery(form_as.values("last_form_a")), + first_pre_alert=Subquery(pre_alerts.values("first_pre_alert")), + last_pre_alert=Subquery(pre_alerts.values("last_pre_alert")), + ) return rounds_queryset From f154b885dc853e8a7ddeacb799a45e7f0ffa0b87 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Tue, 10 Dec 2024 13:29:48 +0200 Subject: [PATCH 15/78] filter groups on datasource and versions --- .../orgUnits/hooks/requests/useGetGroups.ts | 2 +- .../Filter/ReviewOrgUnitChangesFilter.tsx | 84 ++++++++++--------- 2 files changed, 47 insertions(+), 39 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups.ts b/hat/assets/js/apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups.ts index 140fe4ef29..9868e7722a 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups.ts +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups.ts @@ -100,7 +100,7 @@ export const useGetGroupDropdown = ( return data.map(group => { return { value: group.id, - label: group.name, + label: group.display_name, original: group, }; }); diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index 67512bc608..1de20e6c0a 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -46,28 +46,34 @@ const useStyles = makeStyles(theme => ({ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ params, }) => { - const defaultSourceVersion = useDefaultSourceVersion(); - const [selectedVersionId, setSelectedVersionId] = useState( - defaultSourceVersion.version.id, - ); const classes = useStyles(); - const { formatMessage } = useSafeIntl(); + + const defaultSourceVersion = useDefaultSourceVersion(); const { filters, handleSearch, handleChange, filtersUpdated } = useFilterState({ baseUrl, params }); - const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); - - const { data: initialOrgUnit } = useGetOrgUnit(params.parent_id); - const { - data: groupOptions, - isLoading: isLoadingGroups, - refetch: refetchGroups, - } = useGetGroupDropdown( - selectedVersionId - ? { defaultVersion: selectedVersionId.toString() } - : {}, - ); const { data: dataSources, isFetching: isFetchingDataSources } = useGetDataSources(true); + const { data: initialOrgUnit } = useGetOrgUnit(params.parent_id); + const { data: orgUnitTypeOptions, isLoading: isLoadingTypes } = + useGetOrgUnitTypesDropdownOptions(); + const { data: forms, isFetching: isLoadingForms } = useGetForms(); + const { data: selectedUsers } = useGetProfilesDropdown(filters.userIds); + const { data: userRoles, isFetching: isFetchingUserRoles } = + useGetUserRolesDropDown(); + + const { data: allProjects, isFetching: isFetchingProjects } = + useGetProjectsDropdownOptions(); + const { data: paymentStatuses, isFetching: isFetchingPaymentStatuses } = + usePaymentStatusOptions(); + + const formOptions = useMemo( + () => + forms?.map(form => ({ + label: form.name, + value: form.id, + })) || [], + [forms], + ); const initialDataSource = useMemo( () => dataSources?.find( @@ -77,14 +83,28 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ [dataSources, defaultSourceVersion.source.id], ); + const [selectedVersionId, setSelectedVersionId] = useState( + defaultSourceVersion.version.id, + ); + const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); + const [dataSource, setDataSource] = useState(initialDataSource); + + const { formatMessage } = useSafeIntl(); + + const { + data: groupOptions, + isLoading: isLoadingGroups, + refetch: refetchGroups, + } = useGetGroupDropdown( + selectedVersionId ? { defaultVersion: selectedVersionId } : {}, + ); + useEffect(() => { if (selectedVersionId) { refetchGroups(); } }, [selectedVersionId, refetchGroups]); - const [dataSource, setDataSource] = useState(initialDataSource); - useEffect(() => { const updatedDataSource = dataSources?.find( source => @@ -96,25 +116,6 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ } }, [dataSources, defaultSourceVersion.source.id]); - const { data: orgUnitTypeOptions, isLoading: isLoadingTypes } = - useGetOrgUnitTypesDropdownOptions(); - const { data: forms, isFetching: isLoadingForms } = useGetForms(); - const { data: selectedUsers } = useGetProfilesDropdown(filters.userIds); - const { data: userRoles, isFetching: isFetchingUserRoles } = - useGetUserRolesDropDown(); - - const { data: allProjects, isFetching: isFetchingProjects } = - useGetProjectsDropdownOptions(); - const { data: paymentStatuses, isFetching: isFetchingPaymentStatuses } = - usePaymentStatusOptions(); - const formOptions = useMemo( - () => - forms?.map(form => ({ - label: form.name, - value: form.id, - })) || [], - [forms], - ); const statusOptions: DropdownOptions[] = useMemo( () => [ { @@ -150,6 +151,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ } else { setSelectedVersionId(newValue); } + filters.groups = []; }; const getVersionLabel = useGetVersionLabel(dataSources); @@ -260,6 +262,12 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ { handleChange('parent_id', orgUnit?.id); }} From 47e451409f6631d94532b36f6740d3d5f1c259c5 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Tue, 10 Dec 2024 12:38:01 +0100 Subject: [PATCH 16/78] adding geopoint support --- .../components/maps/MarkerMapComponent.tsx | 2 +- .../Iaso/components/tables/PaperTableRow.tsx | 37 +++--- .../js/apps/Iaso/domains/entities/details.tsx | 123 ++++++++---------- .../components/EntityTypesDialog.tsx | 8 +- .../entities/hooks/useGetFieldValue.ts | 103 --------------- .../entities/hooks/useGetFieldValue.tsx | 112 ++++++++++++++++ .../{useGetFields.ts => useGetFields.tsx} | 0 .../Iaso/domains/entities/types/fields.ts | 7 +- hat/assets/js/apps/Iaso/utils/index.ts | 33 +++++ 9 files changed, 226 insertions(+), 199 deletions(-) delete mode 100644 hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.ts create mode 100644 hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx rename hat/assets/js/apps/Iaso/domains/entities/hooks/{useGetFields.ts => useGetFields.tsx} (100%) diff --git a/hat/assets/js/apps/Iaso/components/maps/MarkerMapComponent.tsx b/hat/assets/js/apps/Iaso/components/maps/MarkerMapComponent.tsx index 8a64333ab7..68cd12231e 100644 --- a/hat/assets/js/apps/Iaso/components/maps/MarkerMapComponent.tsx +++ b/hat/assets/js/apps/Iaso/components/maps/MarkerMapComponent.tsx @@ -65,7 +65,7 @@ export const MarkerMap: FunctionComponent = ({ const { formatMessage } = useSafeIntl(); const styles: Record> = useStyles(); - + console.log('maxZoom', maxZoom); const boundsOptions: Record = { padding: [50, 50], maxZoom: maxZoom || currentTile.maxZoom, diff --git a/hat/assets/js/apps/Iaso/components/tables/PaperTableRow.tsx b/hat/assets/js/apps/Iaso/components/tables/PaperTableRow.tsx index fc256fba42..b1ff438190 100644 --- a/hat/assets/js/apps/Iaso/components/tables/PaperTableRow.tsx +++ b/hat/assets/js/apps/Iaso/components/tables/PaperTableRow.tsx @@ -1,29 +1,27 @@ import { TableCell, TableRow } from '@mui/material'; -import { makeStyles } from '@mui/styles'; 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; }; +const styles: SxStyles = { + withBorder: (theme: any) => ({ + borderRight: `1px solid ${theme.palette.ligthGray.border}`, + }), + boldTitle: { + fontWeight: 'bold', + }, +}; + export const PaperTableRow: FunctionComponent = ({ label, value, @@ -32,14 +30,13 @@ export const PaperTableRow: FunctionComponent = ({ boldLeftCellText = true, className, }) => { - const classes = useStyles(); - const borderClass = withLeftCellBorder ? classes.withBorder : ''; - const boldTitle = boldLeftCellText ? classes.boldTitle : ''; + const cellStyles = { + ...(withLeftCellBorder ? styles.withBorder : {}), + ...(boldLeftCellText ? styles.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/domains/entities/details.tsx b/hat/assets/js/apps/Iaso/domains/entities/details.tsx index 3e6f9ff511..e330b84826 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/details.tsx +++ b/hat/assets/js/apps/Iaso/domains/entities/details.tsx @@ -1,26 +1,26 @@ -import React, { FunctionComponent, useMemo } from 'react'; +import { Box, Divider, Grid } from '@mui/material'; +import { makeStyles } from '@mui/styles'; import { - useSafeIntl, commonStyles, - useGoBack, - LoadingSpinner, LinkButton, + LoadingSpinner, + 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), @@ -73,17 +73,58 @@ export const Details: FunctionComponent = () => { {!isLoading && ( - + + + + + + + + + + + + + + + {/* + + + {formatMessage(MESSAGES.seeDuplicates)} + + + */} {duplicates.length > 0 && ( @@ -93,57 +134,7 @@ export const Details: FunctionComponent = () => { )} - {/* 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/useGetFieldValue.ts b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.ts deleted file mode 100644 index e9f280a569..0000000000 --- a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { useSafeIntl } from 'bluesquare-components'; -import moment from 'moment'; - -import { Beneficiary, FileContent } from '../types/beneficiary'; - -import { FieldType, FormDescriptor } from '../../forms/types/forms'; - -import { findDescriptorInChildren } from '../../../utils'; -import { formatLabel } from '../../instances/utils'; -import MESSAGES from '../messages'; - -const textPlaceholder = '--'; - -export const useGetFieldValue = ( - formDescriptors?: FormDescriptor[], -): (( - 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 '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': { - let value = textPlaceholder; - if (fileContent[fieldKey]) { - formDescriptors?.forEach(formDescriptor => { - const descriptor = findDescriptorInChildren( - fieldKey, - formDescriptor, - ); - if (descriptor?.children) { - const descriptorValue = descriptor.children.find( - child => { - return fileContent[fieldKey] === child.name; - }, - ); - if (descriptorValue) { - value = formatLabel(descriptorValue); - } - } - }); - } - return value; - } - case 'select_all_that_apply': - case 'select all that apply': - case 'select_multiple': - case 'select multiple': { - if (fileContent[fieldKey]) { - const fieldsKeys = fileContent[fieldKey].split(' '); - let listValues = []; - formDescriptors?.forEach(formDescriptor => { - const descriptor = findDescriptorInChildren( - fieldKey, - formDescriptor, - ); - if (descriptor?.children) { - listValues = - descriptor.children - .filter(child => { - return fieldsKeys.includes(child.name); - }) - .map(child => formatLabel(child)) || []; - } - }); - return listValues.length > 0 - ? listValues.join(' - ') - : textPlaceholder; - } - return 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..5b26c1fcec --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx @@ -0,0 +1,112 @@ +import { 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 textPlaceholder = '--'; + +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 100% rename from hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFields.ts rename to hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFields.tsx 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..0a609e729d 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/utils/index.ts b/hat/assets/js/apps/Iaso/utils/index.ts index 7efb8ace51..3282cfc1dc 100644 --- a/hat/assets/js/apps/Iaso/utils/index.ts +++ b/hat/assets/js/apps/Iaso/utils/index.ts @@ -1,6 +1,12 @@ 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 +94,30 @@ export const findDescriptorInChildren = (field: any, descriptor: any): any => { return undefined; }, null); }; + +const textPlaceholder = '--'; + +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; +}; From a6a0d3a5e096766e5f613487a30de99e4f948881 Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Tue, 10 Dec 2024 13:17:51 +0100 Subject: [PATCH 17/78] WC2-523 Add storage logs to bulk update --- iaso/api/storage.py | 106 +++++++++--------- iaso/tasks/process_mobile_bulk_upload.py | 7 ++ ...-aefb-98f47fb2ccaf_2024-12-03_12-46-06.xml | 15 +++ .../instances.json | 13 +++ .../storageLogs.json | 21 ++++ .../tasks/test_process_mobile_bulk_upload.py | 56 +++++++++ 6 files changed, 166 insertions(+), 52 deletions(-) create mode 100644 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 create mode 100644 iaso/tests/fixtures/mobile_bulk_uploads/storage_logs_and_change_requests/instances.json create mode 100644 iaso/tests/fixtures/mobile_bulk_uploads/storage_logs_and_change_requests/storageLogs.json 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/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/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]) From 6c72b43bd25d90cda6d97da70846b5c0bdf80727 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Tue, 10 Dec 2024 14:24:02 +0200 Subject: [PATCH 18/78] display the right group's name --- .../apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups.ts b/hat/assets/js/apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups.ts index 9868e7722a..140fe4ef29 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups.ts +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups.ts @@ -100,7 +100,7 @@ export const useGetGroupDropdown = ( return data.map(group => { return { value: group.id, - label: group.display_name, + label: group.name, original: group, }; }); From 2500d4c3d664796e7f38a31f4cb3e927e6301971 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Tue, 10 Dec 2024 14:24:23 +0100 Subject: [PATCH 19/78] make it pretty --- .../components/maps/MarkerMapComponent.tsx | 9 ++-- .../Iaso/components/tables/PaperTableRow.tsx | 24 ++++----- .../components/BeneficiaryBaseInfo.tsx | 52 +++++++++++++++---- .../BeneficiaryBaseInfoContents.tsx | 11 ++-- .../js/apps/Iaso/domains/entities/details.tsx | 40 +++++--------- .../Iaso/domains/entities/hooks/requests.ts | 20 +++---- .../entities/hooks/useGetBeneficiaryFields.ts | 2 +- .../entities/hooks/useGetFieldValue.tsx | 1 + .../Iaso/domains/entities/types/fields.ts | 2 +- 9 files changed, 93 insertions(+), 68 deletions(-) diff --git a/hat/assets/js/apps/Iaso/components/maps/MarkerMapComponent.tsx b/hat/assets/js/apps/Iaso/components/maps/MarkerMapComponent.tsx index 68cd12231e..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(); - console.log('maxZoom', maxZoom); 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 b1ff438190..7d92c4ac71 100644 --- a/hat/assets/js/apps/Iaso/components/tables/PaperTableRow.tsx +++ b/hat/assets/js/apps/Iaso/components/tables/PaperTableRow.tsx @@ -1,4 +1,4 @@ -import { TableCell, TableRow } from '@mui/material'; +import { TableCell, TableRow, Theme } from '@mui/material'; import { LoadingSpinner } from 'bluesquare-components'; import React, { FunctionComponent } from 'react'; import { SxStyles } from '../../types/general'; @@ -8,17 +8,18 @@ type RowProps = { label?: string; value?: string | number | React.ReactNode; isLoading?: boolean; - withLeftCellBorder?: boolean; - boldLeftCellText?: boolean; className?: string; + withoutPadding?: boolean; }; const styles: SxStyles = { - withBorder: (theme: any) => ({ + label: (theme: Theme) => ({ + fontWeight: 'bold', + // @ts-ignore borderRight: `1px solid ${theme.palette.ligthGray.border}`, }), - boldTitle: { - fontWeight: 'bold', + cellNoPadding: { + padding: 0, }, }; @@ -26,18 +27,13 @@ export const PaperTableRow: FunctionComponent = ({ label, value, isLoading = false, - withLeftCellBorder = true, - boldLeftCellText = true, className, + withoutPadding = false, }) => { - const cellStyles = { - ...(withLeftCellBorder ? styles.withBorder : {}), - ...(boldLeftCellText ? styles.boldTitle : {}), - }; return ( - {label} - + {label} + {isLoading && ( ({ ...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 e330b84826..2dcb2e25e5 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/details.tsx +++ b/hat/assets/js/apps/Iaso/domains/entities/details.tsx @@ -2,7 +2,6 @@ import { Box, Divider, Grid } from '@mui/material'; import { makeStyles } from '@mui/styles'; import { commonStyles, - LinkButton, LoadingSpinner, useGoBack, useSafeIntl, @@ -25,7 +24,7 @@ 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,21 +71,25 @@ export const Details: FunctionComponent = () => { {isLoading && } {!isLoading && ( - - + + 0} + duplicateUrl={duplicateUrl} /> - + + { }} /> - + { - {/* - - - {formatMessage(MESSAGES.seeDuplicates)} - - - */} - {duplicates.length > 0 && ( - - - - {formatMessage(MESSAGES.seeDuplicates)} - - - - )} )} 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 8bfd12c0b1..674eb2ed36 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetBeneficiaryFields.ts +++ b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetBeneficiaryFields.ts @@ -41,7 +41,7 @@ export const useGetBeneficiaryFields = ( formDescriptors, ); - const staticFields = useMemo( + const staticFields: Field[] = useMemo( () => [ { label: formatMessage(MESSAGES.nfcCards), diff --git a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx index 5b26c1fcec..c90699eb0d 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx +++ b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx @@ -95,6 +95,7 @@ export const useGetFieldValue = ( longitude={longitude} latitude={latitude} maxZoom={8} + mapHeight={200} /> ); 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 0a609e729d..8768ced0f2 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/types/fields.ts +++ b/hat/assets/js/apps/Iaso/domains/entities/types/fields.ts @@ -4,7 +4,7 @@ import { FieldType } from '../../forms/types/forms'; export type Field = { value: string | number | React.ReactNode; label: string; - type: string; + type?: string; key: string; }; From 0100ac3cf76e45a6afea59f89ab6f9eca3f38ad3 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Tue, 10 Dec 2024 16:18:59 +0200 Subject: [PATCH 20/78] filter orgUnit changes request by default on default_version --- iaso/api/org_unit_change_requests/filters.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/iaso/api/org_unit_change_requests/filters.py b/iaso/api/org_unit_change_requests/filters.py index 35b1f37fa2..acd432f69a 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,17 @@ 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(method="filter_source_version_id", label=_("Source version ID")) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + + # Apply default source version filtering when `source_version_id` is not in query params + if not self.request.query_params.get("source_version_id"): + self.queryset = self.queryset.filter( + org_unit__version=self.request.user.iaso_profile.account.default_version + ) + 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 @@ -127,6 +136,12 @@ def filter_payment_status(self, queryset: QuerySet, name: str, value: str) -> Qu else: return queryset.filter(payment__status=value) + def filter_source_version_id(self, queryset: QuerySet, name: str, value: str) -> QuerySet: + source_version = SourceVersion.objects.get(pk=value) + if source_version: + queryset = queryset.filter(org_unit__version=value) + return queryset + # This filter is used when redirecting from potential payments to see related change requests. It is not otherwise visible in the UI def filter_potential_payments(self, queryset: QuerySet, name: str, value: str) -> QuerySet: potential_payment_ids = parse_comma_separated_numeric_values(value, name) From 78e2452967ce509f32dea5f0cf60a62a8187aaef Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Tue, 10 Dec 2024 17:37:59 +0200 Subject: [PATCH 21/78] transfer source_version_id from frontend to backend --- hat/assets/js/apps/Iaso/constants/urls.ts | 1 + .../reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx | 8 +++++++- .../reviewChanges/hooks/api/useGetApprovalProposals.ts | 1 + .../js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx | 2 ++ .../js/apps/Iaso/domains/orgUnits/reviewChanges/types.ts | 1 + 5 files changed, 12 insertions(+), 1 deletion(-) diff --git a/hat/assets/js/apps/Iaso/constants/urls.ts b/hat/assets/js/apps/Iaso/constants/urls.ts index cfc0ee368e..c6de893d11 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/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index 1de20e6c0a..d7d329fe1d 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -49,6 +49,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ const classes = useStyles(); const defaultSourceVersion = useDefaultSourceVersion(); + const { filters, handleSearch, handleChange, filtersUpdated } = useFilterState({ baseUrl, params }); const { data: dataSources, isFetching: isFetchingDataSources } = @@ -148,8 +149,13 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ source => source.value === newValue, )[0]; setSelectedVersionId(selectedSource?.original?.default_version.id); + handleChange( + 'source_version_id', + selectedSource?.original?.default_version.id, + ); } else { setSelectedVersionId(newValue); + handleChange('source_version_id', newValue); } filters.groups = []; }; @@ -233,7 +239,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ disabled={isFetchingDataSources} keyValue="version" onChange={handleDataSourceVersionChange} - value={selectedVersionId} + value={selectedVersionId.toString()} label={MESSAGES.sourceVersion} options={versionsDropDown} clearable={false} diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/hooks/api/useGetApprovalProposals.ts b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/hooks/api/useGetApprovalProposals.ts index d01e31f66a..50f9d27dbb 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/hooks/api/useGetApprovalProposals.ts +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/hooks/api/useGetApprovalProposals.ts @@ -34,6 +34,7 @@ export const useGetApprovalProposals = ( projects: params.projectIds, payment_status: params.paymentStatus, payment_ids: params.paymentIds, + source_version_id: params.source_version_id, potential_payment_ids: params.potentialPaymentIds, }; diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx index c8a28b8859..5bc590499e 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx @@ -79,6 +79,7 @@ export const ReviewOrgUnitChanges: FunctionComponent = () => { 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/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 & { From 1e08b40b89b24e83302bf04a7501ca55a3ce9b32 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Tue, 10 Dec 2024 18:18:43 +0200 Subject: [PATCH 22/78] improving the source version filter --- iaso/api/org_unit_change_requests/filters.py | 6 ------ iaso/api/org_unit_change_requests/views.py | 6 ++++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/iaso/api/org_unit_change_requests/filters.py b/iaso/api/org_unit_change_requests/filters.py index acd432f69a..3b14beea97 100644 --- a/iaso/api/org_unit_change_requests/filters.py +++ b/iaso/api/org_unit_change_requests/filters.py @@ -42,12 +42,6 @@ class OrgUnitChangeRequestListFilter(django_filters.rest_framework.FilterSet): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Apply default source version filtering when `source_version_id` is not in query params - if not self.request.query_params.get("source_version_id"): - self.queryset = self.queryset.filter( - org_unit__version=self.request.user.iaso_profile.account.default_version - ) - 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..baee20d11b 100644 --- a/iaso/api/org_unit_change_requests/views.py +++ b/iaso/api/org_unit_change_requests/views.py @@ -88,6 +88,12 @@ def get_queryset(self): ) .exclude_soft_deleted_new_reference_instances() ) + + if not self.request.query_params.get("source_version_id"): + org_units_change_requests = org_units_change_requests.filter( + org_unit__version=self.request.user.iaso_profile.account.default_version + ) + return org_units_change_requests.filter(org_unit__in=org_units) def has_org_unit_permission(self, org_unit_to_change: OrgUnit) -> None: From a8f2a370b20e1d9f4cddeb8d5114d35b8d0c8479 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Tue, 10 Dec 2024 19:01:03 +0200 Subject: [PATCH 23/78] fix a frontend bug n selectedVersionId --- .../reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index d7d329fe1d..2babec2aad 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -239,7 +239,11 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ disabled={isFetchingDataSources} keyValue="version" onChange={handleDataSourceVersionChange} - value={selectedVersionId.toString()} + value={ + selectedVersionId + ? selectedVersionId?.toString() + : '' + } label={MESSAGES.sourceVersion} options={versionsDropDown} clearable={false} From 5e51efc3b7aadb77efa1fd29fb1f024a38e4bc42 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Wed, 11 Dec 2024 08:36:07 +0200 Subject: [PATCH 24/78] refactor the code --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index 2babec2aad..a18d03e719 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -85,7 +85,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ ); const [selectedVersionId, setSelectedVersionId] = useState( - defaultSourceVersion.version.id, + defaultSourceVersion.version.id.toString(), ); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [dataSource, setDataSource] = useState(initialDataSource); @@ -148,13 +148,15 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ const selectedSource = dataSources?.filter( source => source.value === newValue, )[0]; - setSelectedVersionId(selectedSource?.original?.default_version.id); + setSelectedVersionId( + selectedSource?.original?.default_version.id.toString(), + ); handleChange( 'source_version_id', selectedSource?.original?.default_version.id, ); } else { - setSelectedVersionId(newValue); + setSelectedVersionId(newValue.toString()); handleChange('source_version_id', newValue); } filters.groups = []; @@ -239,11 +241,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ disabled={isFetchingDataSources} keyValue="version" onChange={handleDataSourceVersionChange} - value={ - selectedVersionId - ? selectedVersionId?.toString() - : '' - } + value={selectedVersionId || ''} label={MESSAGES.sourceVersion} options={versionsDropDown} clearable={false} From 46eac01e0a5ecbdec28cf58a27e8e6f15e893328 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Wed, 11 Dec 2024 08:55:22 +0200 Subject: [PATCH 25/78] use style instead of classes --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index a18d03e719..0dda2dcf40 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -6,8 +6,7 @@ import React, { useState, } from 'react'; import { Box, Grid, Typography } from '@mui/material'; -import { commonStyles, useSafeIntl } from 'bluesquare-components'; -import { makeStyles } from '@mui/styles'; +import { useSafeIntl } from 'bluesquare-components'; import { FilterButton } from '../../../../components/FilterButton'; import { useFilterState } from '../../../../hooks/useFilterState'; import InputComponent from '../../../../components/forms/InputComponent'; @@ -33,21 +32,20 @@ import { useGetVersionLabel } from '../../hooks/useGetVersionLabel'; const baseUrl = baseUrls.orgUnitsChangeRequest; type Props = { params: ApproveOrgUnitParams }; -const useStyles = makeStyles(theme => ({ - ...commonStyles(theme), + +const styles = { advancedSettings: { - color: theme.palette.primary.main, + color: theme => theme.palette.primary.main, alignSelf: 'center', textAlign: 'right', flex: '1', cursor: 'pointer', }, -})); +}; + export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ params, }) => { - const classes = useStyles(); - const defaultSourceVersion = useDefaultSourceVersion(); const { filters, handleSearch, handleChange, filtersUpdated } = @@ -218,7 +216,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ setShowAdvancedSettings(true)} > {formatMessage(MESSAGES.showAdvancedSettings)} From da4f083c6791e1f94515be5d5555d1e40df20da8 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Wed, 11 Dec 2024 09:04:10 +0200 Subject: [PATCH 26/78] useCallBack on handleDataSourceVersionChange --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index 0dda2dcf40..e43e351687 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -140,25 +140,28 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ [handleChange], ); - const handleDataSourceVersionChange = (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); - } - filters.groups = []; - }; + 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); + } + filters.groups = []; + }, + [dataSources, filters, handleChange], + ); const getVersionLabel = useGetVersionLabel(dataSources); From db40a8c46abf15f0eae9822fff81161ee1a0275a Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Wed, 11 Dec 2024 09:35:46 +0100 Subject: [PATCH 27/78] WC2-615 Delete instances and entities: Add option to... filter on entity_type_id. --- .../delete_all_instances_and_entities.py | 25 +++++++++++++++---- .../delete_all_instances_and_entities.html | 20 ++++++++++----- plugins/wfp/views.py | 2 ++ 3 files changed, 36 insertions(+), 11 deletions(-) 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/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() From 62f8bb5a3c5e60479ce2728d35841cc2cedf95ed Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Wed, 11 Dec 2024 11:32:29 +0100 Subject: [PATCH 28/78] POLIO-1752 fix embed redirection --- .../src/domains/VaccineModule/Repository/VaccineRepository.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ( From 56c59faa4d985cc686e902aa4526fecb4f6b9158 Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Wed, 11 Dec 2024 12:25:06 +0100 Subject: [PATCH 29/78] POLIO-1716: fix type error in task --- .../tasks/archive_vaccine_stock_for_rounds.py | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) 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", + ) From 17e029e90855f875ee0957f29e0f2b5336224248 Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Wed, 11 Dec 2024 15:08:38 +0100 Subject: [PATCH 30/78] POLIO-1789: migrate ORPG fields to DG fields Co-authored-by: fleury --- .../0208_migrate_vrf_orpg_fields.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 plugins/polio/migrations/0208_migrate_vrf_orpg_fields.py 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..52ff523399 --- /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_dg_approval and vrf.date_rrt_orpg_approval: + vrf.date_dg_approval = vrf.date_rrt_orpg_approval + if not vrf.quantities_approved_by_dg_in_doses and vrf.quantities_approved_by_orpg_in_doses: + vrf.quantities_approved_by_dg_in_doses = vrf.quantities_approved_by_orpg_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)] From 1139aafb92d0bbb93b06fb0cf3a712cf2b212110 Mon Sep 17 00:00:00 2001 From: hakifran Date: Thu, 12 Dec 2024 08:38:00 +0200 Subject: [PATCH 31/78] Update iaso/api/groups.py Co-authored-by: Marc Hertzog --- iaso/api/groups.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/iaso/api/groups.py b/iaso/api/groups.py index 06c15ff6c6..2f7c6cec6f 100644 --- a/iaso/api/groups.py +++ b/iaso/api/groups.py @@ -164,11 +164,7 @@ def dropdown(self, request, *args): if user and user.is_authenticated: account = user.iaso_profile.account # Filter on version ids (linked to the account)"" - default_version = ( - self.request.query_params.get("defaultVersion") - if self.request.query_params.get("defaultVersion") - else account.default_version.id - ) + 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: From fd7e331c04cea3eccdee85ff8fbc9d1bf6b67980 Mon Sep 17 00:00:00 2001 From: hakifran Date: Thu, 12 Dec 2024 08:43:49 +0200 Subject: [PATCH 32/78] Update iaso/api/org_unit_change_requests/filters.py Co-authored-by: Marc Hertzog --- iaso/api/org_unit_change_requests/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iaso/api/org_unit_change_requests/filters.py b/iaso/api/org_unit_change_requests/filters.py index 3b14beea97..b965adc393 100644 --- a/iaso/api/org_unit_change_requests/filters.py +++ b/iaso/api/org_unit_change_requests/filters.py @@ -37,7 +37,7 @@ 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(method="filter_source_version_id", label=_("Source version ID")) + source_version_id = django_filters.NumberFilter(field_name="org_unit__version", label=_("Source version ID")) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) From 06169773de2f0abe7712629d78eacb48ee178573 Mon Sep 17 00:00:00 2001 From: hakifran Date: Thu, 12 Dec 2024 08:46:14 +0200 Subject: [PATCH 33/78] Update iaso/api/org_unit_change_requests/filters.py Co-authored-by: Marc Hertzog --- iaso/api/org_unit_change_requests/filters.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/iaso/api/org_unit_change_requests/filters.py b/iaso/api/org_unit_change_requests/filters.py index b965adc393..ab2f287fd2 100644 --- a/iaso/api/org_unit_change_requests/filters.py +++ b/iaso/api/org_unit_change_requests/filters.py @@ -130,12 +130,6 @@ def filter_payment_status(self, queryset: QuerySet, name: str, value: str) -> Qu else: return queryset.filter(payment__status=value) - def filter_source_version_id(self, queryset: QuerySet, name: str, value: str) -> QuerySet: - source_version = SourceVersion.objects.get(pk=value) - if source_version: - queryset = queryset.filter(org_unit__version=value) - return queryset - # This filter is used when redirecting from potential payments to see related change requests. It is not otherwise visible in the UI def filter_potential_payments(self, queryset: QuerySet, name: str, value: str) -> QuerySet: potential_payment_ids = parse_comma_separated_numeric_values(value, name) From 52ef6e94e901c0a3a2f8695eea0b470d224dec06 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Thu, 12 Dec 2024 08:53:58 +0200 Subject: [PATCH 34/78] Fix code formatting --- iaso/api/org_unit_change_requests/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iaso/api/org_unit_change_requests/filters.py b/iaso/api/org_unit_change_requests/filters.py index ab2f287fd2..35a7b67877 100644 --- a/iaso/api/org_unit_change_requests/filters.py +++ b/iaso/api/org_unit_change_requests/filters.py @@ -37,7 +37,7 @@ 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")) + source_version_id = django_filters.NumberFilter(field_name="org_unit__version", label=_("Source version ID")) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) From cce736d734acdf5669e67e5a8cd9ce021a544ba8 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Thu, 12 Dec 2024 09:08:43 +0200 Subject: [PATCH 35/78] Not use project before its definition --- iaso/api/groups.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/iaso/api/groups.py b/iaso/api/groups.py index 2f7c6cec6f..549430aefb 100644 --- a/iaso/api/groups.py +++ b/iaso/api/groups.py @@ -169,14 +169,10 @@ def dropdown(self, request, *args): else: # this check if project need auth - default_version = ( - self.request.query_params.get("defaultVersion") - if self.request.query_params.get("defaultVersion") - else project.account.default_version.id - ) - project = Project.objects.get_for_user_and_app_id(user, app_id) + 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) From 5416156a6789b5c113b57afd83553b7e369fa480 Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Thu, 12 Dec 2024 10:42:34 +0100 Subject: [PATCH 36/78] POLIO-1791: migrate in the correct order --- plugins/polio/migrations/0208_migrate_vrf_orpg_fields.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/polio/migrations/0208_migrate_vrf_orpg_fields.py b/plugins/polio/migrations/0208_migrate_vrf_orpg_fields.py index 52ff523399..880a75557c 100644 --- a/plugins/polio/migrations/0208_migrate_vrf_orpg_fields.py +++ b/plugins/polio/migrations/0208_migrate_vrf_orpg_fields.py @@ -8,10 +8,10 @@ def copy_orpg_fields_to_dg_fields(apps, schema_editor): vrfs = VaccineRequestForm.objects.filter(deleted_at__isnull=True) for vrf in vrfs: - if not vrf.date_dg_approval and vrf.date_rrt_orpg_approval: - vrf.date_dg_approval = vrf.date_rrt_orpg_approval - if not vrf.quantities_approved_by_dg_in_doses and vrf.quantities_approved_by_orpg_in_doses: - vrf.quantities_approved_by_dg_in_doses = vrf.quantities_approved_by_orpg_in_doses + 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() From 514b3d4ae19f5d797a31dd15e971f86c8b886e99 Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Thu, 12 Dec 2024 11:41:58 +0100 Subject: [PATCH 37/78] POLIO-1755: remove file type ordering --- .../polio/api/vaccines/repository_forms.py | 89 +------------------ 1 file changed, 3 insertions(+), 86 deletions(-) diff --git a/plugins/polio/api/vaccines/repository_forms.py b/plugins/polio/api/vaccines/repository_forms.py index 86224deaa7..4c4734dcc9 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, @@ -83,9 +82,6 @@ def filter_queryset(self, request, queryset, view): return queryset.distinct() -FILE_ORDERING = ["vrf_data", "pre_alert_data", "form_a_data", "-vrf_data", "-pre_alert_data", "-form_a_data"] - - class VaccineRepositorySerializer(serializers.Serializer): country_name = serializers.CharField(source="campaign__country__name") campaign_obr_name = serializers.CharField(source="campaign__obr_name") @@ -103,10 +99,6 @@ def get_vrf_data(self, obj): vrfs = VaccineRequestForm.objects.filter( campaign__id=obj["campaign__id"], rounds=obj["id"], vaccine_type=obj["vaccine_name"] ) - order = self.context.get("request").query_params["order"] - if order in FILE_ORDERING: - ordering_key = "-date_vrf_reception" if "-" in order else "date_vrf_reception" - vrfs = vrfs.order_by(ordering_key) return [ { "date": vrf.date_vrf_reception, @@ -124,10 +116,6 @@ def get_pre_alert_data(self, obj): request_form__rounds=obj["id"], request_form__vaccine_type=obj["vaccine_name"], ) - order = self.context.get("request").query_params["order"] - if order in FILE_ORDERING: - ordering_key = "-date_pre_alert_reception" if "-" in order else "date_pre_alert_reception" - pre_alerts = pre_alerts.order_by(ordering_key) return [ { "date": pa.date_pre_alert_reception, @@ -141,11 +129,6 @@ def get_form_a_data(self, obj): form_as = OutgoingStockMovement.objects.filter( campaign=obj["campaign__id"], round=obj["id"], vaccine_stock__vaccine=obj["vaccine_name"] ) - order = self.context.get("request").query_params["order"] - if order in FILE_ORDERING: - ordering_key = "-form_a_reception_date" if "-" in order else "form_a_reception_date" - form_as = form_as.order_by(ordering_key) - return [ { "date": fa.form_a_reception_date, @@ -158,31 +141,6 @@ def get_form_a_data(self, obj): ] -class CustomOrderingFilter(OrderingFilter): - def filter_queryset(self, request, queryset, view): - ordering = self.get_ordering(request, queryset, view) - print("ordering", ordering) - if ordering and ordering not in FILE_ORDERING: - return queryset.order_by(*ordering) - else: - if "vrf_data" in ordering: - if "-" in ordering: - return queryset.order_by("-last_vrf") - else: - return queryset.order_by("first_vrf") - if "pre_alert_data" in ordering: - if "-" in ordering: - return queryset.order_by("-last_pre_alert") - else: - return queryset.order_by("first_pr_alert") - if "form_a_data" in ordering: - if "-" in ordering: - return queryset.order_by("-last_form_a") - else: - return queryset.order_by("first_form_a") - return queryset - - class VaccineRepositoryFormsViewSet(GenericViewSet, ListModelMixin): """ ViewSet for retrieving vaccine repository data. @@ -190,17 +148,13 @@ class VaccineRepositoryFormsViewSet(GenericViewSet, ListModelMixin): serializer_class = VaccineRepositorySerializer pagination_class = Paginator - filter_backends = [CustomOrderingFilter, SearchFilter, VaccineReportingFilterBackend] - # TODO rename field in front-end to match first/last_vrf etc + filter_backends = [OrderingFilter, SearchFilter, VaccineReportingFilterBackend] ordering_fields = [ "campaign__country__name", "campaign__obr_name", "started_at", "vaccine_name", "number", - "form_a_data", - "pre_alert_data", - "vrf_data", ] ordering = ["-started_at"] search_fields = ["campaign__country__name", "campaign__obr_name"] @@ -357,41 +311,4 @@ def get_queryset(self): rounds_queryset = rounds_queryset.filter(Q(Exists(vrf_subquery)) | Q(Exists(forma_subquery))) - # Annotate queryset with vrf, pre-alert and formA dates (oldest and latest) to enable ordering - vrfs = ( - VaccineRequestForm.objects.filter( - campaign__id=OuterRef("campaign__id"), - vaccine_type=OuterRef("vaccine_name"), - ) - .filter(Exists(Round.objects.filter(id=OuterRef("id"), vaccinerequestform__id=OuterRef("pk")))) - .annotate(first_vrf=Min("date_vrf_reception"), last_vrf=Max("date_vrf_reception")) - ) - - form_as = ( - OutgoingStockMovement.objects.filter( - campaign=OuterRef("campaign__id"), vaccine_stock__vaccine=OuterRef("vaccine_name") - ) - .filter(Exists(Round.objects.filter(id=OuterRef("id"), outgoingstockmovement__id=OuterRef("pk")))) - .annotate(first_form_a=Min("form_a_reception_date"), last_form_a=Max("form_a_reception_date")) - ) - - pre_alerts = ( - VaccinePreAlert.objects.filter( - request_form__campaign=OuterRef("campaign_id"), - request_form__vaccine_type=OuterRef("vaccine_name"), - ) - .filter( - Exists(Round.objects.filter(id=OuterRef("id"), vaccinerequestform__id=OuterRef("request_form__id"))) - ) - .annotate(first_pre_alert=Min("date_pre_alert_reception"), last_pre_alert=Max("date_pre_alert_reception")) - ) - - rounds_queryset = rounds_queryset.annotate( - first_vrf=Subquery(vrfs.values("first_vrf")), - last_vrf=Subquery(vrfs.values("last_vrf")), - first_form_a=Subquery(form_as.values("first_form_a")), - last_form_a=Subquery(form_as.values("last_form_a")), - first_pre_alert=Subquery(pre_alerts.values("first_pre_alert")), - last_pre_alert=Subquery(pre_alerts.values("last_pre_alert")), - ) return rounds_queryset From c4f2fe90afe41a4378b1e0e28872c1aa0743fa08 Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Thu, 12 Dec 2024 11:52:02 +0100 Subject: [PATCH 38/78] POLIO-1755: handle soft delete in filters --- plugins/polio/api/vaccines/repository_forms.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/polio/api/vaccines/repository_forms.py b/plugins/polio/api/vaccines/repository_forms.py index 4c4734dcc9..a45379459f 100644 --- a/plugins/polio/api/vaccines/repository_forms.py +++ b/plugins/polio/api/vaccines/repository_forms.py @@ -61,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": @@ -76,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() From 146a0dc95940944da7e6dd5f8b624f327a591c1d Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Thu, 12 Dec 2024 12:13:31 +0100 Subject: [PATCH 39/78] POLIO-1755: fix filters in UI - Make file type non-clearable - Only show selected file type in table --- .../Repository/forms/Filters.tsx | 1 + .../hooks/useVaccineRepositoryColumns.tsx | 53 ++++++++++++++----- .../VaccineModule/Repository/forms/index.tsx | 2 +- 3 files changed, 43 insertions(+), 13 deletions(-) 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/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 ( <> From 1ed199e9fad5e80527fbffbc4fc420c363f498ec Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Thu, 12 Dec 2024 12:24:20 +0100 Subject: [PATCH 40/78] POLIO-1755: add test on deleted vrf --- .../tests/test_vaccine_repository_forms.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plugins/polio/tests/test_vaccine_repository_forms.py b/plugins/polio/tests/test_vaccine_repository_forms.py index 9f09655a39..39bd0b383a 100644 --- a/plugins/polio/tests/test_vaccine_repository_forms.py +++ b/plugins/polio/tests/test_vaccine_repository_forms.py @@ -42,6 +42,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="No 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, + 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 +141,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): From 339301e8bf7e7ad2760ffaaaffde912e22fb94bd Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Thu, 12 Dec 2024 12:57:51 +0100 Subject: [PATCH 41/78] block group api call while loading ou detail --- hat/assets/js/apps/Iaso/domains/orgUnits/details.js | 2 +- hat/assets/js/apps/Iaso/domains/orgUnits/hooks.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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, }, }, From 03013160c3e713435c4626f199b66af83c17e1a0 Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Thu, 12 Dec 2024 13:14:38 +0100 Subject: [PATCH 42/78] fix test --- plugins/polio/tests/test_vaccine_repository_forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/polio/tests/test_vaccine_repository_forms.py b/plugins/polio/tests/test_vaccine_repository_forms.py index 39bd0b383a..f16f855fe6 100644 --- a/plugins/polio/tests/test_vaccine_repository_forms.py +++ b/plugins/polio/tests/test_vaccine_repository_forms.py @@ -43,7 +43,7 @@ def setUp(cls): 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="No VRF", + obr_name="DELETED VRF", account=cls.account, source_version=cls.source_version_1, country_ou_type=cls.org_unit_type_country, @@ -55,7 +55,7 @@ def setUp(cls): cls.campaign_deleted_vrf.save() cls.deleted_vaccine_request_form = pm.VaccineRequestForm.objects.create( - campaign=cls.campaign, + 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), From 9be22c6217fe32bf47d9488fa848ceeb14c8459c Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Thu, 12 Dec 2024 14:25:32 +0100 Subject: [PATCH 43/78] POLIO-1755: fix tests --- plugins/polio/tests/test_vaccine_repository_forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/polio/tests/test_vaccine_repository_forms.py b/plugins/polio/tests/test_vaccine_repository_forms.py index f16f855fe6..4a16d9ee0e 100644 --- a/plugins/polio/tests/test_vaccine_repository_forms.py +++ b/plugins/polio/tests/test_vaccine_repository_forms.py @@ -61,7 +61,7 @@ def setUp(cls): 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(), + deleted_at=cls.now, ) cls.deleted_vaccine_request_form.rounds.set([cls.campaign_deleted_vrf_round_1]) From 25ec32007826ced383520bd993755bd548599708 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Thu, 12 Dec 2024 14:59:14 +0100 Subject: [PATCH 44/78] remove unit count keep it only on list page --- .../orgUnitTypes/hooks/useGetOrgUnitTypes.ts | 4 +-- .../domains/orgUnits/orgUnitTypes/index.tsx | 17 +++++++------ .../domains/orgUnits/types/orgunitTypes.ts | 1 + iaso/api/org_unit_types/serializers.py | 25 +++++++++++++++++-- 4 files changed, 36 insertions(+), 11 deletions(-) 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..7d63d1c414 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; @@ -32,7 +32,10 @@ const OrgUnitTypes: FunctionComponent = () => { 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 ( <> Date: Thu, 12 Dec 2024 15:26:04 +0100 Subject: [PATCH 45/78] python test --- iaso/tests/api/test_org_unit_types_v2.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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""" From 641d11f6fdc5ee51216cd2987c7af343c991149e Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Thu, 12 Dec 2024 16:08:38 +0100 Subject: [PATCH 46/78] code review --- .../js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx | 4 +--- hat/assets/js/apps/Iaso/utils/index.ts | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx index c90699eb0d..a8a531ebc7 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx +++ b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetFieldValue.tsx @@ -1,4 +1,4 @@ -import { useSafeIntl } from 'bluesquare-components'; +import { textPlaceholder, useSafeIntl } from 'bluesquare-components'; import moment from 'moment'; import React from 'react'; @@ -13,8 +13,6 @@ import { MarkerMap } from '../../../components/maps/MarkerMapComponent'; import { formatLabel } from '../../instances/utils'; import MESSAGES from '../messages'; -const textPlaceholder = '--'; - const getDescriptorListValues = ( fieldKey: string, fileContent: FileContent | Beneficiary, diff --git a/hat/assets/js/apps/Iaso/utils/index.ts b/hat/assets/js/apps/Iaso/utils/index.ts index 3282cfc1dc..db706197e9 100644 --- a/hat/assets/js/apps/Iaso/utils/index.ts +++ b/hat/assets/js/apps/Iaso/utils/index.ts @@ -1,3 +1,4 @@ +import { textPlaceholder } from 'bluesquare-components'; import { createContext } from 'react'; import pluginsConfigs from '../../../../../../plugins'; import { Plugin } from '../domains/app/types'; @@ -95,8 +96,6 @@ export const findDescriptorInChildren = (field: any, descriptor: any): any => { }, null); }; -const textPlaceholder = '--'; - export const getDescriptorValue = ( fieldKey: string, fileContent: FileContent | Beneficiary, From 3dc6ebe1816e35fcf2cfc694fa6c33c2df3cc3e6 Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Thu, 12 Dec 2024 18:00:54 +0100 Subject: [PATCH 47/78] POLIO-1794: add PagiatedModelViewSet - forces GET requests to send pagination params - Forces use of Paginator for pagination --- iaso/api/common.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/iaso/api/common.py b/iaso/api/common.py index c0dac17677..a3b06f79a0 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 @@ -304,6 +304,32 @@ def perform_destroy(self, instance): ) +class PaginatedModelViewset(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 = Paginator + + def get_pagination_class(self): + return self._pagination_class + + def __setattr__(self, name, value): + if name == "pagination_class": + logging.warning("You cannot override the 'pagination_class' attribute.") + super().__setattr__(name, value) + + def list(self, request, *args, **kwargs): + limit = request.query_params.get("limit", None) + page = request.query_params.get("page", None) + + if not limit or not page: + return HttpResponseBadRequest("'page' and 'limit' query parameters are both required.") + return super().list(request, *args, **kwargs) + + class ChoiceEnum(enum.Enum): active = "active" all = "all" From 16d98bdefa6d45af74e46ce6f0ae794e78c5d66d Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Thu, 12 Dec 2024 18:03:23 +0100 Subject: [PATCH 48/78] POLIO-1794: add dasboard endpoints for subactivities - 1 endpoint for SubActivity - 1 endpoint for SubActivityScope - tests --- plugins/polio/api/dashboards/subactivities.py | 71 ++++++++++++++++++ plugins/polio/api/urls.py | 11 +++ .../tests/api/dashboards/subactivities.py | 70 ++++++++++++++++++ .../api/dashboards/subactivitiescopes.py | 74 +++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 plugins/polio/api/dashboards/subactivities.py create mode 100644 plugins/polio/tests/api/dashboards/subactivities.py create mode 100644 plugins/polio/tests/api/dashboards/subactivitiescopes.py diff --git a/plugins/polio/api/dashboards/subactivities.py b/plugins/polio/api/dashboards/subactivities.py new file mode 100644 index 0000000000..27093f53fb --- /dev/null +++ b/plugins/polio/api/dashboards/subactivities.py @@ -0,0 +1,71 @@ +from iaso.api.common import PaginatedModelViewset +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(PaginatedModelViewset): + """ + 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(PaginatedModelViewset): + """ + 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/tests/api/dashboards/subactivities.py b/plugins/polio/tests/api/dashboards/subactivities.py new file mode 100644 index 0000000000..87635cb3d0 --- /dev/null +++ b/plugins/polio/tests/api/dashboards/subactivities.py @@ -0,0 +1,70 @@ +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_get_pagination_params_mandatory(self): + self.client.force_authenticate(self.user) + response = self.client.get(f"{BASE_URL}/") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.content.decode("utf-8"), "'page' and 'limit' query parameters are both required.") + response = self.client.get(f"{BASE_URL}/?limit=20") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.content.decode("utf-8"), "'page' and 'limit' query parameters are both required.") + response = self.client.get(f"{BASE_URL}/?page=1") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.content.decode("utf-8"), "'page' and 'limit' query parameters are both required.") + response = self.client.get(f"{BASE_URL}/?limit=20&page=1") + self.assertEqual(response.status_code, 200) + + def test_get_sub_activities(self): + self.client.force_authenticate(self.user) + response = self.client.get(f"{BASE_URL}/?limit=20&page=1") + 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/subactivitiescopes.py b/plugins/polio/tests/api/dashboards/subactivitiescopes.py new file mode 100644 index 0000000000..04b0f7bfdd --- /dev/null +++ b/plugins/polio/tests/api/dashboards/subactivitiescopes.py @@ -0,0 +1,74 @@ +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_get_pagination_params_mandatory(self): + self.client.force_authenticate(self.user) + response = self.client.get(f"{BASE_URL}/") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.content.decode("utf-8"), "'page' and 'limit' query parameters are both required.") + response = self.client.get(f"{BASE_URL}/?limit=20") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.content.decode("utf-8"), "'page' and 'limit' query parameters are both required.") + response = self.client.get(f"{BASE_URL}/?page=1") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.content.decode("utf-8"), "'page' and 'limit' query parameters are both required.") + response = self.client.get(f"{BASE_URL}/?limit=20&page=1") + self.assertEqual(response.status_code, 200) + + def test_get_sub_activity_scopes(self): + self.client.force_authenticate(self.user) + response = self.client.get(f"{BASE_URL}/?limit=20&page=1") + 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) From 9f509921112e61028ec52b935176d206d3a239f4 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Fri, 13 Dec 2024 10:06:02 +0200 Subject: [PATCH 49/78] get selectedVersionId from index.tsx --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 31 ++++++++++++++----- .../domains/orgUnits/reviewChanges/index.tsx | 19 ++++++++++-- iaso/api/org_unit_change_requests/views.py | 6 +--- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index e43e351687..f2ba3b8a29 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -29,9 +29,16 @@ import { useGetGroupDropdown } from '../../hooks/requests/useGetGroups'; import { useGetDataSources } from '../../hooks/requests/useGetDataSources'; import { useDefaultSourceVersion } from '../../../dataSources/utils'; import { useGetVersionLabel } from '../../hooks/useGetVersionLabel'; +import { DataSource } from '../../types/dataSources'; const baseUrl = baseUrls.orgUnitsChangeRequest; -type Props = { params: ApproveOrgUnitParams }; +type Props = { + params: ApproveOrgUnitParams; + selectedVersionId: string; + setSelectedVersionId: (id: string) => void; // Define setter prop + dataSource: string; + setDataSource: (id: DataSource) => void; +}; const styles = { advancedSettings: { @@ -45,6 +52,10 @@ const styles = { export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ params, + selectedVersionId, + setSelectedVersionId, + dataSource, + setDataSource, }) => { const defaultSourceVersion = useDefaultSourceVersion(); @@ -82,11 +93,11 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ [dataSources, defaultSourceVersion.source.id], ); - const [selectedVersionId, setSelectedVersionId] = useState( - defaultSourceVersion.version.id.toString(), - ); + // const [selectedVersionId, setSelectedVersionId] = useState( + // defaultSourceVersion.version.id.toString(), + // ); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); - const [dataSource, setDataSource] = useState(initialDataSource); + // const [dataSource, setDataSource] = useState(initialDataSource); const { formatMessage } = useSafeIntl(); @@ -113,7 +124,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ if (updatedDataSource) { setDataSource(updatedDataSource); } - }, [dataSources, defaultSourceVersion.source.id]); + }, [dataSources, defaultSourceVersion.source.id, setDataSource]); const statusOptions: DropdownOptions[] = useMemo( () => [ @@ -160,7 +171,13 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ } filters.groups = []; }, - [dataSources, filters, handleChange], + [ + dataSources, + filters, + handleChange, + setDataSource, + setSelectedVersionId, + ], ); const getVersionLabel = useGetVersionLabel(dataSources); diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx index 5bc590499e..a5554b610f 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx @@ -1,7 +1,7 @@ import { Box } from '@mui/material'; import { makeStyles } from '@mui/styles'; import { commonStyles, getTableUrl, useSafeIntl } from 'bluesquare-components'; -import React, { FunctionComponent, useMemo } from 'react'; +import React, { FunctionComponent, useMemo, useState } from 'react'; import DownloadButtonsComponent from '../../../components/DownloadButtonsComponent'; import TopBar from '../../../components/nav/TopBarComponent'; import { ReviewOrgUnitChangesFilter } from './Filter/ReviewOrgUnitChangesFilter'; @@ -11,6 +11,7 @@ import MESSAGES from './messages'; import { ApproveOrgUnitParams } from './types'; import { useParamsObject } from '../../../routing/hooks/useParamsObject'; import { baseUrls } from '../../../constants/urls'; +import { useDefaultSourceVersion } from '../../dataSources/utils'; /* # Org Unit Change Request @@ -97,12 +98,26 @@ export const ReviewOrgUnitChanges: FunctionComponent = () => { ); const csv_url = getTableUrl(endPointUrl, csv_params); + const defaultSourceVersion = useDefaultSourceVersion(); + const [selectedVersionId, setSelectedVersionId] = useState( + defaultSourceVersion.version.id.toString(), + ); + const [dataSource, setDataSource] = useState( + defaultSourceVersion.source.id.toString(), + ); + params.source_version_id = selectedVersionId; return (
- + diff --git a/iaso/api/org_unit_change_requests/views.py b/iaso/api/org_unit_change_requests/views.py index baee20d11b..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", @@ -89,11 +90,6 @@ def get_queryset(self): .exclude_soft_deleted_new_reference_instances() ) - if not self.request.query_params.get("source_version_id"): - org_units_change_requests = org_units_change_requests.filter( - org_unit__version=self.request.user.iaso_profile.account.default_version - ) - return org_units_change_requests.filter(org_unit__in=org_units) def has_org_unit_permission(self, org_unit_to_change: OrgUnit) -> None: From d506a2a95208c0b34e3f3279ae8aefbb3b4aef99 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Fri, 13 Dec 2024 10:17:16 +0200 Subject: [PATCH 50/78] refactor the code --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 14 +++++--------- .../Iaso/domains/orgUnits/reviewChanges/index.tsx | 6 +++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index f2ba3b8a29..2f2b4f9e19 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -29,7 +29,6 @@ import { useGetGroupDropdown } from '../../hooks/requests/useGetGroups'; import { useGetDataSources } from '../../hooks/requests/useGetDataSources'; import { useDefaultSourceVersion } from '../../../dataSources/utils'; import { useGetVersionLabel } from '../../hooks/useGetVersionLabel'; -import { DataSource } from '../../types/dataSources'; const baseUrl = baseUrls.orgUnitsChangeRequest; type Props = { @@ -37,7 +36,7 @@ type Props = { selectedVersionId: string; setSelectedVersionId: (id: string) => void; // Define setter prop dataSource: string; - setDataSource: (id: DataSource) => void; + setDataSource: (id: string) => void; }; const styles = { @@ -93,12 +92,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ [dataSources, defaultSourceVersion.source.id], ); - // const [selectedVersionId, setSelectedVersionId] = useState( - // defaultSourceVersion.version.id.toString(), - // ); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); - // const [dataSource, setDataSource] = useState(initialDataSource); - const { formatMessage } = useSafeIntl(); const { @@ -122,7 +116,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ )?.value; if (updatedDataSource) { - setDataSource(updatedDataSource); + setDataSource(updatedDataSource as unknown as string); } }, [dataSources, defaultSourceVersion.source.id, setDataSource]); @@ -186,7 +180,9 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ if (!dataSources || !dataSource) return []; return ( dataSources - .filter(src => src.value === dataSource)[0] + .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), diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx index a5554b610f..a20dc549b2 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx @@ -99,10 +99,10 @@ export const ReviewOrgUnitChanges: FunctionComponent = () => { const csv_url = getTableUrl(endPointUrl, csv_params); const defaultSourceVersion = useDefaultSourceVersion(); - const [selectedVersionId, setSelectedVersionId] = useState( + const [selectedVersionId, setSelectedVersionId] = useState( defaultSourceVersion.version.id.toString(), ); - const [dataSource, setDataSource] = useState( + const [dataSource, setDataSource] = useState( defaultSourceVersion.source.id.toString(), ); @@ -114,7 +114,7 @@ export const ReviewOrgUnitChanges: FunctionComponent = () => { From 9e1f1e952264368911157c628ab4722e03dfcee3 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Fri, 13 Dec 2024 11:27:02 +0200 Subject: [PATCH 51/78] fix cypress test --- .../reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx | 2 +- .../05 - orgUnits/reviewChanges/changeRequest.spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index 2f2b4f9e19..8948d42498 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -34,7 +34,7 @@ const baseUrl = baseUrls.orgUnitsChangeRequest; type Props = { params: ApproveOrgUnitParams; selectedVersionId: string; - setSelectedVersionId: (id: string) => void; // Define setter prop + setSelectedVersionId: (id: string) => void; dataSource: string; setDataSource: (id: string) => void; }; 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`, ); }); }); From 64a85cf727efb74892d77182b5567d64b56d98aa Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Fri, 13 Dec 2024 13:12:04 +0100 Subject: [PATCH 52/78] remove ts error --- hat/assets/js/apps/Iaso/domains/orgUnits/orgUnitTypes/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7d63d1c414..edb542a646 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/orgUnitTypes/index.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/orgUnitTypes/index.tsx @@ -28,7 +28,7 @@ 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(); From c26bba92e8ee298187d33eb459e17833c075549c Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Fri, 13 Dec 2024 15:12:53 +0100 Subject: [PATCH 53/78] POLIO-1794: refactor PaginatedModelViewset - rename as EtlModelViewset - Add default EtlPaginator, with a default `page_size` of 20 and a `max_page_size` of 1000 - Allow overriding paginator if it is a subclass of EtlPaginator, to make sure there's always a `page_size` and a `max_page_size` - update tests --- iaso/api/common.py | 25 +++++++++++-------- plugins/polio/api/dashboards/subactivities.py | 6 ++--- ...subactivities.py => test_subactivities.py} | 17 ++++--------- ...iescopes.py => test_subactivitiescopes.py} | 17 ++++--------- 4 files changed, 27 insertions(+), 38 deletions(-) rename plugins/polio/tests/api/dashboards/{subactivities.py => test_subactivities.py} (74%) rename plugins/polio/tests/api/dashboards/{subactivitiescopes.py => test_subactivitiescopes.py} (78%) diff --git a/iaso/api/common.py b/iaso/api/common.py index a3b06f79a0..041e42a548 100644 --- a/iaso/api/common.py +++ b/iaso/api/common.py @@ -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,31 +309,29 @@ def perform_destroy(self, instance): ) -class PaginatedModelViewset(ModelViewSet): +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 = Paginator + pagination_class = EtlPaginator def get_pagination_class(self): - return self._pagination_class + 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 def __setattr__(self, name, value): if name == "pagination_class": logging.warning("You cannot override the 'pagination_class' attribute.") super().__setattr__(name, value) - def list(self, request, *args, **kwargs): - limit = request.query_params.get("limit", None) - page = request.query_params.get("page", None) - - if not limit or not page: - return HttpResponseBadRequest("'page' and 'limit' query parameters are both required.") - return super().list(request, *args, **kwargs) - class ChoiceEnum(enum.Enum): active = "active" diff --git a/plugins/polio/api/dashboards/subactivities.py b/plugins/polio/api/dashboards/subactivities.py index 27093f53fb..6c1468a5f4 100644 --- a/plugins/polio/api/dashboards/subactivities.py +++ b/plugins/polio/api/dashboards/subactivities.py @@ -1,4 +1,4 @@ -from iaso.api.common import PaginatedModelViewset +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 @@ -14,7 +14,7 @@ class Meta: fields = "__all__" -class SubActivityDashboardViewSet(PaginatedModelViewset): +class SubActivityDashboardViewSet(EtlModelViewset): """ GET /api/polio/dashboards/subactivities/ Returns all subactivities for the user's account, excluding those related to deleted campaig ns @@ -52,7 +52,7 @@ class Meta: fields = "__all__" -class SubActivityScopeDashboardViewSet(PaginatedModelViewset): +class SubActivityScopeDashboardViewSet(EtlModelViewset): """ GET /api/polio/dashboards/subactivityscopes/ Returns all subactivityscopes for the user's account, excluding those related to deleted campaigns diff --git a/plugins/polio/tests/api/dashboards/subactivities.py b/plugins/polio/tests/api/dashboards/test_subactivities.py similarity index 74% rename from plugins/polio/tests/api/dashboards/subactivities.py rename to plugins/polio/tests/api/dashboards/test_subactivities.py index 87635cb3d0..53761ea19a 100644 --- a/plugins/polio/tests/api/dashboards/subactivities.py +++ b/plugins/polio/tests/api/dashboards/test_subactivities.py @@ -48,23 +48,16 @@ def test_anonymous_user_cannot_get(self): response = self.client.get(f"{BASE_URL}/") self.assertEqual(response.status_code, 403) - def test_get_pagination_params_mandatory(self): + def test_default_pagination_is_added(self): self.client.force_authenticate(self.user) response = self.client.get(f"{BASE_URL}/") - self.assertEqual(response.status_code, 400) - self.assertEqual(response.content.decode("utf-8"), "'page' and 'limit' query parameters are both required.") - response = self.client.get(f"{BASE_URL}/?limit=20") - self.assertEqual(response.status_code, 400) - self.assertEqual(response.content.decode("utf-8"), "'page' and 'limit' query parameters are both required.") - response = self.client.get(f"{BASE_URL}/?page=1") - self.assertEqual(response.status_code, 400) - self.assertEqual(response.content.decode("utf-8"), "'page' and 'limit' query parameters are both required.") - response = self.client.get(f"{BASE_URL}/?limit=20&page=1") - self.assertEqual(response.status_code, 200) + data = self.assertJSONResponse(response, 200) + self.assertEqual(data["page"], 1) + self.assertEqual(data["limit"], 20) def test_get_sub_activities(self): self.client.force_authenticate(self.user) - response = self.client.get(f"{BASE_URL}/?limit=20&page=1") + 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/subactivitiescopes.py b/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py similarity index 78% rename from plugins/polio/tests/api/dashboards/subactivitiescopes.py rename to plugins/polio/tests/api/dashboards/test_subactivitiescopes.py index 04b0f7bfdd..5bd8b69458 100644 --- a/plugins/polio/tests/api/dashboards/subactivitiescopes.py +++ b/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py @@ -43,23 +43,16 @@ def test_anonymous_user_cannot_get(self): response = self.client.get(f"{BASE_URL}/") self.assertEqual(response.status_code, 403) - def test_get_pagination_params_mandatory(self): + def test_default_pagination_is_added(self): self.client.force_authenticate(self.user) response = self.client.get(f"{BASE_URL}/") - self.assertEqual(response.status_code, 400) - self.assertEqual(response.content.decode("utf-8"), "'page' and 'limit' query parameters are both required.") - response = self.client.get(f"{BASE_URL}/?limit=20") - self.assertEqual(response.status_code, 400) - self.assertEqual(response.content.decode("utf-8"), "'page' and 'limit' query parameters are both required.") - response = self.client.get(f"{BASE_URL}/?page=1") - self.assertEqual(response.status_code, 400) - self.assertEqual(response.content.decode("utf-8"), "'page' and 'limit' query parameters are both required.") - response = self.client.get(f"{BASE_URL}/?limit=20&page=1") - self.assertEqual(response.status_code, 200) + data = self.assertJSONResponse(response, 200) + self.assertEqual(data["page"], 1) + self.assertEqual(data["limit"], 20) def test_get_sub_activity_scopes(self): self.client.force_authenticate(self.user) - response = self.client.get(f"{BASE_URL}/?limit=20&page=1") + 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) From 2528bf08d54748e7d6269f9a16e52a60c3e6c8c4 Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Fri, 13 Dec 2024 15:37:22 +0100 Subject: [PATCH 54/78] POLIO-1785, POLIO-1786: change table defaults - POLIO-1785: defauklt order = campaign start date desc - POLIO-1786: default limit = 50 --- .../polio/api/vaccines/repository_forms.py | 3 ++- .../forms/hooks/useGetVaccineReporting.ts | 4 ++-- .../tests/test_vaccine_repository_forms.py | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/plugins/polio/api/vaccines/repository_forms.py b/plugins/polio/api/vaccines/repository_forms.py index a45379459f..d0285c5a38 100644 --- a/plugins/polio/api/vaccines/repository_forms.py +++ b/plugins/polio/api/vaccines/repository_forms.py @@ -159,8 +159,9 @@ class VaccineRepositoryFormsViewSet(GenericViewSet, ListModelMixin): "started_at", "vaccine_name", "number", + "campaign_started_at", ] - ordering = ["-started_at"] + ordering = ["-campaign_started_at"] search_fields = ["campaign__country__name", "campaign__obr_name"] permission_classes = [permissions.IsAuthenticatedOrReadOnly] default_page_size = 20 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/tests/test_vaccine_repository_forms.py b/plugins/polio/tests/test_vaccine_repository_forms.py index 4a16d9ee0e..81e83393a7 100644 --- a/plugins/polio/tests/test_vaccine_repository_forms.py +++ b/plugins/polio/tests/test_vaccine_repository_forms.py @@ -31,6 +31,7 @@ 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_no_vrf, cls.campaign_no_vrf_round_1, _, _, _, _ = cls.create_campaign( obr_name="No VRF", account=cls.account, @@ -252,6 +253,24 @@ 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 start date + response = self.client.get(f"{BASE_URL}?order=campaign_started_at") + data = response.json() + self.assertEqual(data["results"][0]["campaign_obr_name"], "Another Campaign") + self.assertEqual(data["results"][3]["campaign_obr_name"], "Test Campaign") + + # Test reverse ordering by campaign start date + response = self.client.get(f"{BASE_URL}?order=-campaign_started_at") + 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() From e6df7d27f693332699c198e2859c3fe79dba1aac Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Fri, 13 Dec 2024 16:15:13 +0100 Subject: [PATCH 55/78] POLIO_1794: add more tests - Test that `max-page_size` is enforced - Test EtlModelViewset Paginator subclassing constraint --- iaso/api/common.py | 5 -- iaso/tests/api/test_etl_viewset.py | 54 +++++++++++++++++++ .../api/dashboards/test_subactivities.py | 7 +++ .../api/dashboards/test_subactivitiescopes.py | 7 +++ 4 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 iaso/tests/api/test_etl_viewset.py diff --git a/iaso/api/common.py b/iaso/api/common.py index 041e42a548..ea165b484c 100644 --- a/iaso/api/common.py +++ b/iaso/api/common.py @@ -327,11 +327,6 @@ def get_pagination_class(self): ) return custom_pagination_class - def __setattr__(self, name, value): - if name == "pagination_class": - logging.warning("You cannot override the 'pagination_class' attribute.") - super().__setattr__(name, value) - class ChoiceEnum(enum.Enum): active = "active" diff --git a/iaso/tests/api/test_etl_viewset.py b/iaso/tests/api/test_etl_viewset.py new file mode 100644 index 0000000000..5650573290 --- /dev/null +++ b/iaso/tests/api/test_etl_viewset.py @@ -0,0 +1,54 @@ +from django.test import TestCase +from rest_framework.pagination import LimitOffsetPagination +from rest_framework.viewsets import ModelViewSet +from rest_framework.exceptions import APIException + +# Import your custom base paginator and ViewSet +from iaso.api.common import EtlModelViewset, EtlPaginator + + +# Dummy paginator classes +class ValidCustomPaginator(EtlPaginator): + default_limit = 20 + + +class InvalidPaginator(LimitOffsetPagination): # Not inheriting from MyBasePaginator + 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/plugins/polio/tests/api/dashboards/test_subactivities.py b/plugins/polio/tests/api/dashboards/test_subactivities.py index 53761ea19a..1af7279d56 100644 --- a/plugins/polio/tests/api/dashboards/test_subactivities.py +++ b/plugins/polio/tests/api/dashboards/test_subactivities.py @@ -55,6 +55,13 @@ def test_default_pagination_is_added(self): self.assertEqual(data["page"], 1) self.assertEqual(data["limit"], 20) + def test_max_page_size_is_enforced(self): + 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"], 1000) + def test_get_sub_activities(self): self.client.force_authenticate(self.user) response = self.client.get(f"{BASE_URL}/") diff --git a/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py b/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py index 5bd8b69458..5c88761eba 100644 --- a/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py +++ b/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py @@ -50,6 +50,13 @@ def test_default_pagination_is_added(self): self.assertEqual(data["page"], 1) self.assertEqual(data["limit"], 20) + def test_max_page_size_is_enforced(self): + 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"], 1000) + def test_get_sub_activity_scopes(self): self.client.force_authenticate(self.user) response = self.client.get(f"{BASE_URL}/") From dcf0df9d5d74dc71ec40216b8670f47a5cff3de5 Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Fri, 13 Dec 2024 16:16:49 +0100 Subject: [PATCH 56/78] POLIO-1794: remove unused imports --- iaso/tests/api/test_etl_viewset.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/iaso/tests/api/test_etl_viewset.py b/iaso/tests/api/test_etl_viewset.py index 5650573290..bf8ad1c99a 100644 --- a/iaso/tests/api/test_etl_viewset.py +++ b/iaso/tests/api/test_etl_viewset.py @@ -1,7 +1,5 @@ from django.test import TestCase from rest_framework.pagination import LimitOffsetPagination -from rest_framework.viewsets import ModelViewSet -from rest_framework.exceptions import APIException # Import your custom base paginator and ViewSet from iaso.api.common import EtlModelViewset, EtlPaginator From 1c408bc418efc7d1b5e3f463aecfce582e45ea33 Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Fri, 13 Dec 2024 16:22:30 +0100 Subject: [PATCH 57/78] POLIO-1794: remove comments --- iaso/tests/api/test_etl_viewset.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/iaso/tests/api/test_etl_viewset.py b/iaso/tests/api/test_etl_viewset.py index bf8ad1c99a..7d1946534f 100644 --- a/iaso/tests/api/test_etl_viewset.py +++ b/iaso/tests/api/test_etl_viewset.py @@ -1,7 +1,5 @@ from django.test import TestCase from rest_framework.pagination import LimitOffsetPagination - -# Import your custom base paginator and ViewSet from iaso.api.common import EtlModelViewset, EtlPaginator @@ -10,7 +8,7 @@ class ValidCustomPaginator(EtlPaginator): default_limit = 20 -class InvalidPaginator(LimitOffsetPagination): # Not inheriting from MyBasePaginator +class InvalidPaginator(LimitOffsetPagination): # Not inheriting from EtlPaginator pass From 37894a016d026d86e214a1e8f96165cb6ee0b011 Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Fri, 13 Dec 2024 17:01:54 +0100 Subject: [PATCH 58/78] POLIO-1785: fix tests --- .../polio/api/vaccines/repository_forms.py | 2 +- .../tests/test_vaccine_repository_forms.py | 25 ++++++------------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/plugins/polio/api/vaccines/repository_forms.py b/plugins/polio/api/vaccines/repository_forms.py index d0285c5a38..95dc177947 100644 --- a/plugins/polio/api/vaccines/repository_forms.py +++ b/plugins/polio/api/vaccines/repository_forms.py @@ -164,7 +164,7 @@ class VaccineRepositoryFormsViewSet(GenericViewSet, ListModelMixin): 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", diff --git a/plugins/polio/tests/test_vaccine_repository_forms.py b/plugins/polio/tests/test_vaccine_repository_forms.py index 81e83393a7..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, @@ -32,6 +32,8 @@ def setUp(cls): 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, @@ -253,24 +255,12 @@ def test_ordering(self): self.client.force_authenticate(user=self.user) - # Test default ordering => campaign start date + # 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 start date - response = self.client.get(f"{BASE_URL}?order=campaign_started_at") - data = response.json() - self.assertEqual(data["results"][0]["campaign_obr_name"], "Another Campaign") - self.assertEqual(data["results"][3]["campaign_obr_name"], "Test Campaign") - - # Test reverse ordering by campaign start date - response = self.client.get(f"{BASE_URL}?order=-campaign_started_at") - 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() @@ -310,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""" From 9084731c0eea20ed04027ef1b7f5299047033a90 Mon Sep 17 00:00:00 2001 From: Quang Son Le Date: Fri, 13 Dec 2024 17:04:42 +0100 Subject: [PATCH 59/78] POLIO-1794: explicit default max size in test --- plugins/polio/tests/api/dashboards/test_subactivities.py | 3 ++- plugins/polio/tests/api/dashboards/test_subactivitiescopes.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/polio/tests/api/dashboards/test_subactivities.py b/plugins/polio/tests/api/dashboards/test_subactivities.py index 1af7279d56..81bf6f62ce 100644 --- a/plugins/polio/tests/api/dashboards/test_subactivities.py +++ b/plugins/polio/tests/api/dashboards/test_subactivities.py @@ -56,11 +56,12 @@ def test_default_pagination_is_added(self): 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"], 1000) + self.assertEqual(data["limit"], default_max_page_size) def test_get_sub_activities(self): self.client.force_authenticate(self.user) diff --git a/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py b/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py index 5c88761eba..1c752f8338 100644 --- a/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py +++ b/plugins/polio/tests/api/dashboards/test_subactivitiescopes.py @@ -51,11 +51,12 @@ def test_default_pagination_is_added(self): 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"], 1000) + self.assertEqual(data["limit"], default_max_page_size) def test_get_sub_activity_scopes(self): self.client.force_authenticate(self.user) From 1bdd252db1091fc1f63ded4cf77481c4ba2ec5e7 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Sun, 15 Dec 2024 14:16:14 +0200 Subject: [PATCH 60/78] remove datasource from advancedSettings --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index 8948d42498..646b281204 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -228,6 +228,16 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ labelString={formatMessage(MESSAGES.group)} /> + {!showAdvancedSettings && ( = ({ )} {showAdvancedSettings && ( <> - Date: Mon, 16 Dec 2024 09:47:50 +0100 Subject: [PATCH 61/78] SLEEP-1499 Remove unneeded fragment --- .../pages/components/CreateEditDialog.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) 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 bd8ec5dac5..5ff3952d0a 100644 --- a/hat/assets/js/apps/Iaso/domains/pages/components/CreateEditDialog.js +++ b/hat/assets/js/apps/Iaso/domains/pages/components/CreateEditDialog.js @@ -234,16 +234,14 @@ const CreateEditDialog = ({ isOpen, onClose, selectedPage }) => { {type === SUPERSET && ( - <> - - + )} {type !== SUPERSET && ( Date: Mon, 16 Dec 2024 10:03:58 +0100 Subject: [PATCH 62/78] Cypress fix fixture --- .../js/cypress/fixtures/forms/detail.json | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) 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 From 92aedc70b7aee6b3ee0254bea0cd97319653f032 Mon Sep 17 00:00:00 2001 From: Math VDH Date: Mon, 16 Dec 2024 10:10:28 +0000 Subject: [PATCH 63/78] POLIO-1732 Fix performances issues --- .../polio/api/vaccines/repository_reports.py | 46 ++++++++--------- plugins/polio/models/base.py | 49 ++++++++++++++++++- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/plugins/polio/api/vaccines/repository_reports.py b/plugins/polio/api/vaccines/repository_reports.py index f55deb86d7..1c5eca93f3 100644 --- a/plugins/polio/api/vaccines/repository_reports.py +++ b/plugins/polio/api/vaccines/repository_reports.py @@ -47,10 +47,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 +75,26 @@ class VaccineRepositoryReportSerializer(serializers.Serializer): destruction_report_data = serializers.SerializerMethodField() def get_incident_report_data(self, obj): - return [ + pir = IncidentReport.objects.filter(vaccine_stock=obj.id) + 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 = DestructionReport.objects.filter(vaccine_stock=obj.id) + 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): @@ -99,24 +113,10 @@ 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", + ).filter( + account=self.request.user.iaso_profile.account, ) - - destruction_qs = DestructionReport.objects.only( - "vaccine_stock_id", - "destruction_report_date", - "document", - ) - - 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/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): """ From 8c1a5cbe67e49b8fd75ad64ab586f0b7938e0e1f Mon Sep 17 00:00:00 2001 From: Math VDH Date: Mon, 16 Dec 2024 10:13:52 +0000 Subject: [PATCH 64/78] POLIO-1732 More perf improvements --- .../polio/api/vaccines/repository_reports.py | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/plugins/polio/api/vaccines/repository_reports.py b/plugins/polio/api/vaccines/repository_reports.py index 1c5eca93f3..d4c31712ef 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 @@ -75,7 +71,7 @@ class VaccineRepositoryReportSerializer(serializers.Serializer): destruction_report_data = serializers.SerializerMethodField() def get_incident_report_data(self, obj): - pir = IncidentReport.objects.filter(vaccine_stock=obj.id) + pir = obj.incidentreport_set.all() data = [ { "date": ir.date_of_incident_report, @@ -86,7 +82,7 @@ def get_incident_report_data(self, obj): return data def get_destruction_report_data(self, obj): - drs = DestructionReport.objects.filter(vaccine_stock=obj.id) + drs = obj.destructionreport_set.all() data = [ { "date": dr.destruction_report_date, @@ -111,10 +107,17 @@ class VaccineRepositoryReportsViewSet(GenericViewSet, ListModelMixin): def get_queryset(self): """Get the queryset for VaccineStock objects.""" - base_qs = VaccineStock.objects.select_related( - "country", - ).filter( - account=self.request.user.iaso_profile.account, + base_qs = ( + VaccineStock.objects.select_related( + "country", + ) + .filter( + account=self.request.user.iaso_profile.account, + ) + .prefetch_related( + "incidentreport_set", + "destructionreport_set", + ) ) return base_qs From 80eeeaa7acd0f6d9071e443aacd4c804e8ad1850 Mon Sep 17 00:00:00 2001 From: Math VDH Date: Mon, 16 Dec 2024 10:23:03 +0000 Subject: [PATCH 65/78] POLIO-1732 fix problem with anon user filter --- .../polio/api/vaccines/repository_reports.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/plugins/polio/api/vaccines/repository_reports.py b/plugins/polio/api/vaccines/repository_reports.py index d4c31712ef..27e1095034 100644 --- a/plugins/polio/api/vaccines/repository_reports.py +++ b/plugins/polio/api/vaccines/repository_reports.py @@ -107,18 +107,17 @@ class VaccineRepositoryReportsViewSet(GenericViewSet, ListModelMixin): def get_queryset(self): """Get the queryset for VaccineStock objects.""" - base_qs = ( - VaccineStock.objects.select_related( - "country", - ) - .filter( - account=self.request.user.iaso_profile.account, - ) - .prefetch_related( - "incidentreport_set", - "destructionreport_set", - ) + + base_qs = VaccineStock.objects.select_related( + "country", + ).prefetch_related( + "incidentreport_set", + "destructionreport_set", ) + + 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 @swagger_auto_schema( From c2def9686ee5891262c3c48c39dce4335423bed3 Mon Sep 17 00:00:00 2001 From: Math VDH Date: Mon, 16 Dec 2024 10:55:54 +0000 Subject: [PATCH 66/78] POLIO-1732 Add migrations --- ...lter_vaccinerequestform_rounds_and_more.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 plugins/polio/migrations/0208_alter_vaccinerequestform_rounds_and_more.py 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"), + ), + ] From 963de45af2e4b69fd837c66c6a3ba162561f617a Mon Sep 17 00:00:00 2001 From: Math VDH Date: Mon, 16 Dec 2024 11:00:21 +0000 Subject: [PATCH 67/78] POLIO-1732 Merge migrations --- plugins/polio/migrations/0209_merge_20241216_1100.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 plugins/polio/migrations/0209_merge_20241216_1100.py 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 = [] From 900eee1e87858fb168b40af1b417810f17437394 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Mon, 16 Dec 2024 15:00:59 +0200 Subject: [PATCH 68/78] take in account also when the user role has Organisation units management write permission --- .../Iaso/domains/userRoles/types/userRoles.ts | 1 + .../domains/users/components/UsersDialog.tsx | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) 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..1f17773ba1 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'; @@ -156,6 +157,21 @@ const UserDialogComponent: FunctionComponent = ({ } return ''; }, [formatMessage, isPhoneNumberUpdated, isUserWithoutPermissions]); + + const allUserUserRolesPermissions = useMemo(() => { + const allUserPermissions = user.user_permissions.value; + const allUserRolesPermissions = + user.user_roles_permissions.value.flatMap(role => role.permissions); + return [ + ...new Set([...allUserPermissions, ...allUserRolesPermissions]), + ]; + }, [user.user_permissions.value, user.user_roles_permissions.value]); + + useEffect(() => { + setHasNoOrgUnitManagementWrite( + !allUserUserRolesPermissions.includes(Permissions.ORG_UNITS), + ); + }, [allUserUserRolesPermissions]); return ( <> Date: Mon, 16 Dec 2024 14:13:23 +0100 Subject: [PATCH 69/78] apply queryset to my profile tto --- iaso/api/profiles/profiles.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index 202347b67a..4a5cc3e06b 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 @@ -28,7 +28,8 @@ from iaso.api.common import CONTENT_TYPE_CSV, CONTENT_TYPE_XLSX, FileFormatEnum from iaso.api.profiles.audit import ProfileAuditLogger from iaso.api.profiles.bulk_create_users import BULK_CREATE_USER_COLUMNS_LIST -from iaso.models import OrgUnit, OrgUnitType, Profile, Project, TenantUser, UserRole +from iaso.models import (OrgUnit, OrgUnitType, Profile, Project, TenantUser, + UserRole) from iaso.utils import is_mobile_request from iaso.utils.module_permissions import account_module_permissions @@ -232,9 +233,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) - + queryset = self.get_queryset() try: - profile = request.user.iaso_profile + profile = queryset.get(user=request.user) profile_dict = profile.as_dict() return Response(profile_dict) except Profile.DoesNotExist: From e81c079a7d533d3b86e442cbe5130033626e9135 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Mon, 16 Dec 2024 15:21:33 +0200 Subject: [PATCH 70/78] refactor the code --- .../domains/users/components/UsersDialog.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) 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 1f17773ba1..f9612df24c 100644 --- a/hat/assets/js/apps/Iaso/domains/users/components/UsersDialog.tsx +++ b/hat/assets/js/apps/Iaso/domains/users/components/UsersDialog.tsx @@ -75,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 => { @@ -158,20 +156,25 @@ 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; - const allUserRolesPermissions = - user.user_roles_permissions.value.flatMap(role => role.permissions); + return [ ...new Set([...allUserPermissions, ...allUserRolesPermissions]), ]; - }, [user.user_permissions.value, user.user_roles_permissions.value]); + }, [allUserRolesPermissions, user.user_permissions.value]); useEffect(() => { setHasNoOrgUnitManagementWrite( !allUserUserRolesPermissions.includes(Permissions.ORG_UNITS), ); - }, [allUserUserRolesPermissions]); + }, [allUserRolesPermissions.length, allUserUserRolesPermissions]); return ( <> = ({ currentUser={user} handleChange={permissions => { setFieldValue('user_permissions', permissions); - setHasNoOrgUnitManagementWrite( - !permissions.includes( - Permissions.ORG_UNITS, - ), - ); }} setFieldValue={(key, value) => setFieldValue(key, value) From 9c42a0779314604fea970adb72fdbcc49b6654b0 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Mon, 16 Dec 2024 14:41:55 +0100 Subject: [PATCH 71/78] black --- iaso/api/profiles/profiles.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index 4a5cc3e06b..17e8ecd1e5 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -28,8 +28,7 @@ from iaso.api.common import CONTENT_TYPE_CSV, CONTENT_TYPE_XLSX, FileFormatEnum from iaso.api.profiles.audit import ProfileAuditLogger from iaso.api.profiles.bulk_create_users import BULK_CREATE_USER_COLUMNS_LIST -from iaso.models import (OrgUnit, OrgUnitType, Profile, Project, TenantUser, - UserRole) +from iaso.models import OrgUnit, OrgUnitType, Profile, Project, TenantUser, UserRole from iaso.utils import is_mobile_request from iaso.utils.module_permissions import account_module_permissions From d736af9d984b3d9d47f68f7824744172f520850c Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Tue, 17 Dec 2024 09:12:45 +0200 Subject: [PATCH 72/78] move some code from index to reviewOrgUnitChangesFilter --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 25 ++++++++++++------- .../domains/orgUnits/reviewChanges/index.tsx | 19 ++------------ 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index 646b281204..9e11485c6e 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -6,7 +6,7 @@ import React, { useState, } from 'react'; import { Box, Grid, Typography } from '@mui/material'; -import { useSafeIntl } from 'bluesquare-components'; +import { useRedirectToReplace, useSafeIntl } from 'bluesquare-components'; import { FilterButton } from '../../../../components/FilterButton'; import { useFilterState } from '../../../../hooks/useFilterState'; import InputComponent from '../../../../components/forms/InputComponent'; @@ -33,10 +33,6 @@ import { useGetVersionLabel } from '../../hooks/useGetVersionLabel'; const baseUrl = baseUrls.orgUnitsChangeRequest; type Props = { params: ApproveOrgUnitParams; - selectedVersionId: string; - setSelectedVersionId: (id: string) => void; - dataSource: string; - setDataSource: (id: string) => void; }; const styles = { @@ -51,12 +47,23 @@ const styles = { export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ params, - selectedVersionId, - setSelectedVersionId, - dataSource, - setDataSource, }) => { const defaultSourceVersion = useDefaultSourceVersion(); + const [selectedVersionId, setSelectedVersionId] = useState( + defaultSourceVersion.version.id.toString(), + ); + const [dataSource, setDataSource] = useState( + defaultSourceVersion.source.id.toString(), + ); + + const redirectToReplace = useRedirectToReplace(); + const newParams = { + ...params, + }; + if (!newParams.source_version_id) { + newParams.source_version_id = selectedVersionId; + redirectToReplace(baseUrl, newParams); + } const { filters, handleSearch, handleChange, filtersUpdated } = useFilterState({ baseUrl, params }); diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx index a20dc549b2..5bc590499e 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/index.tsx @@ -1,7 +1,7 @@ import { Box } from '@mui/material'; import { makeStyles } from '@mui/styles'; import { commonStyles, getTableUrl, useSafeIntl } from 'bluesquare-components'; -import React, { FunctionComponent, useMemo, useState } from 'react'; +import React, { FunctionComponent, useMemo } from 'react'; import DownloadButtonsComponent from '../../../components/DownloadButtonsComponent'; import TopBar from '../../../components/nav/TopBarComponent'; import { ReviewOrgUnitChangesFilter } from './Filter/ReviewOrgUnitChangesFilter'; @@ -11,7 +11,6 @@ import MESSAGES from './messages'; import { ApproveOrgUnitParams } from './types'; import { useParamsObject } from '../../../routing/hooks/useParamsObject'; import { baseUrls } from '../../../constants/urls'; -import { useDefaultSourceVersion } from '../../dataSources/utils'; /* # Org Unit Change Request @@ -98,26 +97,12 @@ export const ReviewOrgUnitChanges: FunctionComponent = () => { ); const csv_url = getTableUrl(endPointUrl, csv_params); - const defaultSourceVersion = useDefaultSourceVersion(); - const [selectedVersionId, setSelectedVersionId] = useState( - defaultSourceVersion.version.id.toString(), - ); - const [dataSource, setDataSource] = useState( - defaultSourceVersion.source.id.toString(), - ); - params.source_version_id = selectedVersionId; return (
- + From c235c37dd2303e3465e569ece3f50c75245c3ce4 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Tue, 17 Dec 2024 09:31:30 +0200 Subject: [PATCH 73/78] pass selectedVersionId to useGetGroupsDropdown and put it in the query key instead of this useEffect --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index 9e11485c6e..d241093dff 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -102,19 +102,10 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const { formatMessage } = useSafeIntl(); - const { - data: groupOptions, - isLoading: isLoadingGroups, - refetch: refetchGroups, - } = useGetGroupDropdown( - selectedVersionId ? { defaultVersion: selectedVersionId } : {}, - ); - - useEffect(() => { - if (selectedVersionId) { - refetchGroups(); - } - }, [selectedVersionId, refetchGroups]); + const { data: groupOptions, isLoading: isLoadingGroups } = + useGetGroupDropdown( + selectedVersionId ? { defaultVersion: selectedVersionId } : {}, + ); useEffect(() => { const updatedDataSource = dataSources?.find( From aebdb4117319a2830494ec8db2d6267d90614f74 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Tue, 17 Dec 2024 10:17:19 +0200 Subject: [PATCH 74/78] fix the deep link --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index d241093dff..bbd27ba2bb 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -48,18 +48,18 @@ const styles = { export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ params, }) => { + const newParams = { + ...params, + }; const defaultSourceVersion = useDefaultSourceVersion(); const [selectedVersionId, setSelectedVersionId] = useState( - defaultSourceVersion.version.id.toString(), - ); - const [dataSource, setDataSource] = useState( - defaultSourceVersion.source.id.toString(), + newParams.source_version_id + ? newParams.source_version_id + : defaultSourceVersion.version.id.toString(), ); const redirectToReplace = useRedirectToReplace(); - const newParams = { - ...params, - }; + if (!newParams.source_version_id) { newParams.source_version_id = selectedVersionId; redirectToReplace(baseUrl, newParams); @@ -69,6 +69,23 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ useFilterState({ baseUrl, params }); const { data: dataSources, isFetching: isFetchingDataSources } = useGetDataSources(true); + + 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 { data: initialOrgUnit } = useGetOrgUnit(params.parent_id); const { data: orgUnitTypeOptions, isLoading: isLoadingTypes } = useGetOrgUnitTypesDropdownOptions(); @@ -108,15 +125,22 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ ); useEffect(() => { - const updatedDataSource = dataSources?.find( - source => - source.value === defaultSourceVersion.source.id.toString(), - )?.value; + 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]); + }, [ + dataSources, + defaultSourceVersion.source.id, + setDataSource, + sourceParam, + ]); const statusOptions: DropdownOptions[] = useMemo( () => [ From aa5538a919786262e2db6e90bba28bf1fd7ce515 Mon Sep 17 00:00:00 2001 From: HAKIZIMANA Franck Date: Tue, 17 Dec 2024 10:33:41 +0200 Subject: [PATCH 75/78] reset orgUnit tree when the version or the source is changed --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index bbd27ba2bb..8ea4687eba 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, + useRef, useState, } from 'react'; import { Box, Grid, Typography } from '@mui/material'; @@ -83,9 +84,11 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ : undefined, [dataSources, newParams.source_version_id], ); + const [dataSource, setDataSource] = useState( sourceParam || defaultSourceVersion.source.id.toString(), ); + const { data: initialOrgUnit } = useGetOrgUnit(params.parent_id); const { data: orgUnitTypeOptions, isLoading: isLoadingTypes } = useGetOrgUnitTypesDropdownOptions(); @@ -185,15 +188,12 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ setSelectedVersionId(newValue.toString()); handleChange('source_version_id', newValue); } - filters.groups = []; + + delete newParams.parent_id; + delete newParams.groups; + redirectToReplace(baseUrl, newParams); }, - [ - dataSources, - filters, - handleChange, - setDataSource, - setSelectedVersionId, - ], + [dataSources, handleChange, newParams, redirectToReplace], ); const getVersionLabel = useGetVersionLabel(dataSources); @@ -213,6 +213,13 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ ); }, [dataSource, dataSources, getVersionLabel]); + const sourceTreeviewResetControl = useRef(selectedVersionId); + useEffect(() => { + if (sourceTreeviewResetControl.current !== selectedVersionId) { + sourceTreeviewResetControl.current = selectedVersionId; + } + }, [selectedVersionId]); + return ( @@ -316,6 +323,10 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ handleChange('parent_id', orgUnit?.id); }} initialSelection={initialOrgUnit} + resetTrigger={ + sourceTreeviewResetControl.current !== + selectedVersionId + } /> Date: Tue, 17 Dec 2024 10:59:07 +0200 Subject: [PATCH 76/78] refactor the code --- .../Filter/ReviewOrgUnitChangesFilter.tsx | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index 8ea4687eba..b1f0e65fe9 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -49,9 +49,9 @@ const styles = { export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ params, }) => { - const newParams = { - ...params, - }; + const redirectToReplace = useRedirectToReplace(); + const newParams = useMemo(() => params, [params]); + const defaultSourceVersion = useDefaultSourceVersion(); const [selectedVersionId, setSelectedVersionId] = useState( newParams.source_version_id @@ -59,18 +59,32 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ : defaultSourceVersion.version.id.toString(), ); - const redirectToReplace = useRedirectToReplace(); - - if (!newParams.source_version_id) { - newParams.source_version_id = selectedVersionId; - redirectToReplace(baseUrl, newParams); - } - const { filters, handleSearch, handleChange, filtersUpdated } = useFilterState({ baseUrl, params }); const { data: dataSources, isFetching: isFetchingDataSources } = useGetDataSources(true); + const { data: initialOrgUnit } = useGetOrgUnit(params.parent_id); + const { data: orgUnitTypeOptions, isLoading: isLoadingTypes } = + useGetOrgUnitTypesDropdownOptions(); + const { data: forms, isFetching: isLoadingForms } = useGetForms(); + const { data: selectedUsers } = useGetProfilesDropdown(filters.userIds); + const { data: userRoles, isFetching: isFetchingUserRoles } = + useGetUserRolesDropDown(); + + const { data: allProjects, isFetching: isFetchingProjects } = + 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 @@ -88,20 +102,6 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ const [dataSource, setDataSource] = useState( sourceParam || defaultSourceVersion.source.id.toString(), ); - - const { data: initialOrgUnit } = useGetOrgUnit(params.parent_id); - const { data: orgUnitTypeOptions, isLoading: isLoadingTypes } = - useGetOrgUnitTypesDropdownOptions(); - const { data: forms, isFetching: isLoadingForms } = useGetForms(); - const { data: selectedUsers } = useGetProfilesDropdown(filters.userIds); - const { data: userRoles, isFetching: isFetchingUserRoles } = - useGetUserRolesDropDown(); - - const { data: allProjects, isFetching: isFetchingProjects } = - useGetProjectsDropdownOptions(); - const { data: paymentStatuses, isFetching: isFetchingPaymentStatuses } = - usePaymentStatusOptions(); - const formOptions = useMemo( () => forms?.map(form => ({ @@ -110,6 +110,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ })) || [], [forms], ); + // Get the initial data source id const initialDataSource = useMemo( () => dataSources?.find( @@ -127,6 +128,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ selectedVersionId ? { defaultVersion: selectedVersionId } : {}, ); + // Change the selected dataSource useEffect(() => { const updatedDataSource = sourceParam || @@ -170,6 +172,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ [handleChange], ); + // handle dataSource and sourceVersion change const handleDataSourceVersionChange = useCallback( (key, newValue) => { if (key === 'source') { @@ -188,7 +191,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ setSelectedVersionId(newValue.toString()); handleChange('source_version_id', newValue); } - + // Reset the parent_id and groups params delete newParams.parent_id; delete newParams.groups; redirectToReplace(baseUrl, newParams); @@ -197,7 +200,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ ); const getVersionLabel = useGetVersionLabel(dataSources); - + // Get the versions dropdown options based on the selected dataSource const versionsDropDown = useMemo(() => { if (!dataSources || !dataSource) return []; return ( @@ -212,7 +215,7 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ })) ?? [] ); }, [dataSource, dataSources, getVersionLabel]); - + // Reset the OrgUnitTreeviewModal when the sourceVersion changed const sourceTreeviewResetControl = useRef(selectedVersionId); useEffect(() => { if (sourceTreeviewResetControl.current !== selectedVersionId) { From 9cc8e380702e0f1637df569e11f83bb5b92614a4 Mon Sep 17 00:00:00 2001 From: hakifran Date: Tue, 17 Dec 2024 11:50:29 +0200 Subject: [PATCH 77/78] apply changes suggested Co-authored-by: Quang Son Le <38907762+quang-le@users.noreply.github.com> --- .../reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx index b1f0e65fe9..9c2009fae4 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/reviewChanges/Filter/ReviewOrgUnitChangesFilter.tsx @@ -191,6 +191,8 @@ export const ReviewOrgUnitChangesFilter: FunctionComponent = ({ 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; From a3db856bbd59771917c953a2e6693c85c0e3d4e9 Mon Sep 17 00:00:00 2001 From: Thibault Dethier Date: Tue, 17 Dec 2024 16:00:31 +0100 Subject: [PATCH 78/78] Fixed error profile/me without profile --- iaso/api/profiles/profiles.py | 2 +- iaso/tests/api/test_profiles.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index 17e8ecd1e5..1a0e6e9561 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -232,8 +232,8 @@ 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) - queryset = self.get_queryset() try: + queryset = self.get_queryset() profile = queryset.get(user=request.user) profile_dict = profile.as_dict() return Response(profile_dict) 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)"""