From 3107f2d76232f7c941c3ccfc1659984d6ab2ea93 Mon Sep 17 00:00:00 2001 From: Benjamin Monjoie Date: Tue, 15 Oct 2024 13:03:09 +0200 Subject: [PATCH 01/60] Clean up feature flags IA-3550: Remove "ORG_UNIT_CHANGE_REQUEST" feature flag IA-3553: Remove "NEEDS_AUTHENTICATION" feature flag IA-3556: Make "MOBILE_ORG_UNIT_REGISTRY" require "REQUIRE_AUTHENTICATION" IA-3493, IA-3550, IA-3553, IA-3556 --- iaso/migrations/0304_auto_20241015_1017.py | 72 ++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 iaso/migrations/0304_auto_20241015_1017.py diff --git a/iaso/migrations/0304_auto_20241015_1017.py b/iaso/migrations/0304_auto_20241015_1017.py new file mode 100644 index 0000000000..74acbfe92c --- /dev/null +++ b/iaso/migrations/0304_auto_20241015_1017.py @@ -0,0 +1,72 @@ +from django.db import migrations + + +# region IA-3550 +def remove_org_unit_change_request_feature_flag(apps, schema_editor): + FeatureFlag = apps.get_model("iaso", "FeatureFlag") + FeatureFlag.objects.get(code="ORG_UNIT_CHANGE_REQUEST").delete() + + +def re_add_org_unit_change_request_feature_flag(apps, schema_editor): + FeatureFlag = apps.get_model("iaso", "FeatureFlag") + FeatureFlag.objects.create(code="ORG_UNIT_CHANGE_REQUEST", name="Request changes to org units.") + + +# endregion + + +# region IA-3553 +def merge_require_authentication_flags(apps, schema_editor): + FeatureFlag = apps.get_model("iaso", "FeatureFlag") + Project = apps.get_model("iaso", "Project") + try: + needs_authentication = FeatureFlag.objects.get(code="NEEDS_AUTHENTICATION") + require_authentication = FeatureFlag.objects.get(code="REQUIRE_AUTHENTICATION") + for project in Project.objects.filter(feature_flags__code="NEEDS_AUTHENTICATION"): + project.feature_flags.remove(needs_authentication) + project.feature_flags.add(require_authentication) + project.save() + needs_authentication.delete() + except FeatureFlag.DoesNotExist: + pass + + +def re_add_authentication_flag(apps, schema_editor): + FeatureFlag = apps.get_model("iaso", "FeatureFlag") + FeatureFlag.objects.create(code="NEEDS_AUTHENTICATION", name="Mobile: Enforce authentication") + + +# endregion + + +# region IA-3556 +def make_change_request_require_authentication(apps, schema_editor): + FeatureFlag = apps.get_model("iaso", "FeatureFlag") + ff = FeatureFlag.objects.get(code="MOBILE_ORG_UNIT_REGISTRY") + ff.requires_authentication = True + ff.save() + + +def unmake_change_request_require_authentication(apps, schema_editor): + FeatureFlag = apps.get_model("iaso", "FeatureFlag") + ff = FeatureFlag.objects.get(code="MOBILE_ORG_UNIT_REGISTRY") + ff.requires_authentication = False + ff.save() + + +# endregion + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0303_merge_20241003_1256"), + ] + + operations = [ + # IA-3550 + migrations.RunPython(remove_org_unit_change_request_feature_flag, re_add_org_unit_change_request_feature_flag), + # IA-3553 + migrations.RunPython(merge_require_authentication_flags, re_add_authentication_flag), + # IA-3556 + migrations.RunPython(make_change_request_require_authentication, unmake_change_request_require_authentication), + ] From aa078a38840276e1642f8b1aa2f9cc29aa4a838e Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Thu, 17 Oct 2024 13:00:20 +0200 Subject: [PATCH 02/60] Remove redux to fetch instance detail (reassign too) --- .../entities/hooks/useGetBeneficiaryFields.ts | 8 +-- .../js/apps/Iaso/domains/instances/actions.js | 44 +--------------- .../Iaso/domains/instances/actions.spec.js | 14 ----- .../CreateReAssignDialogComponent.tsx | 16 +++--- .../InstancePopUp/InstancePopUp.tsx | 28 ++++++---- .../components/InstancesMap/InstancesMap.tsx | 52 ++++++++----------- .../components/SpeedDialInstance.tsx | 15 +++++- .../apps/Iaso/domains/instances/details.tsx | 22 ++++++-- .../instances/hooks/speedDialActions.tsx | 23 ++++---- .../instances/hooks/useReassignInstance.tsx | 30 +++++++++++ .../orgUnitMap/OrgUnitMap/FormsMarkers.tsx | 6 --- .../orgUnitMap/OrgUnitMap/MarkersList.tsx | 14 +++-- .../orgUnitMap/OrgUnitMap/OrgUnitMap.tsx | 3 +- .../orgUnitMap/OrgUnitMap/useRedux.ts | 22 +------- hat/assets/js/apps/Iaso/utils/requests.js | 2 +- 15 files changed, 139 insertions(+), 160 deletions(-) create mode 100644 hat/assets/js/apps/Iaso/domains/instances/hooks/useReassignInstance.tsx 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 a3729a40f8..66a7d34bf9 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetBeneficiaryFields.ts +++ b/hat/assets/js/apps/Iaso/domains/entities/hooks/useGetBeneficiaryFields.ts @@ -1,14 +1,14 @@ -import { useMemo } from 'react'; import { useSafeIntl } from 'bluesquare-components'; +import { useMemo } from 'react'; import { useGetPossibleFields } from '../../forms/hooks/useGetPossibleFields'; +import MESSAGES from '../messages'; import { Beneficiary } from '../types/beneficiary'; import { Field } from '../types/fields'; -import MESSAGES from '../messages'; -import { useGetFields } from './useGetFields'; import { useGetBeneficiaryTypesDropdown } from './requests'; +import { useGetFields } from './useGetFields'; -export const useGetBeneficiaryFields = (beneficiary: Beneficiary) => { +export const useGetBeneficiaryFields = (beneficiary?: Beneficiary) => { const { formatMessage } = useSafeIntl(); const { data: beneficiaryTypes } = useGetBeneficiaryTypesDropdown(); diff --git a/hat/assets/js/apps/Iaso/domains/instances/actions.js b/hat/assets/js/apps/Iaso/domains/instances/actions.js index 8415caf599..d355a1b8b5 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/actions.js +++ b/hat/assets/js/apps/Iaso/domains/instances/actions.js @@ -1,9 +1,4 @@ -import { - getRequest, - patchRequest, - postRequest, - putRequest, -} from 'Iaso/libs/Api.ts'; +import { getRequest, postRequest, putRequest } from 'Iaso/libs/Api.ts'; import { openSnackBar } from '../../components/snackBars/EventDispatcher.ts'; import { errorSnackBar, succesfullSnackBar } from '../../constants/snackBars'; @@ -21,11 +16,6 @@ export const setInstancesFetching = isFetching => ({ payload: isFetching, }); -export const setCurrentInstance = instance => ({ - type: SET_CURRENT_INSTANCE, - payload: instance, -}); - export const fetchEditUrl = (currentInstance, location) => dispatch => { dispatch(setInstancesFetching(true)); const url = `/api/enketo/edit/${currentInstance.uuid}?return_url=${location}`; @@ -41,38 +31,6 @@ export const fetchEditUrl = (currentInstance, location) => dispatch => { }); }; -export const fetchInstanceDetail = instanceId => dispatch => { - dispatch(setInstancesFetching(true)); - return getRequest(`/api/instances/${instanceId}/`) - .then(res => { - dispatch(setCurrentInstance(res)); - return res; - }) - .catch(err => - openSnackBar(errorSnackBar('fetchInstanceError', null, err)), - ) - .then(res => { - dispatch(setInstancesFetching(false)); - return res; - }); -}; - -export const reAssignInstance = (currentInstance, payload) => dispatch => { - dispatch(setInstancesFetching(true)); - const effectivePayload = { ...payload }; - if (!payload.period) delete effectivePayload.period; - patchRequest(`/api/instances/${currentInstance.id}/`, effectivePayload) - .then(() => { - dispatch(fetchInstanceDetail(currentInstance.id)); - }) - .catch(err => - openSnackBar(errorSnackBar('assignInstanceError', null, err)), - ) - .then(() => { - dispatch(setInstancesFetching(false)); - }); -}; - /* Submission Creation workflow * 1. this function call backend create Instance in DB * 2. backend contact enketo to generate a Form page diff --git a/hat/assets/js/apps/Iaso/domains/instances/actions.spec.js b/hat/assets/js/apps/Iaso/domains/instances/actions.spec.js index 8596d60e9f..1909b7241c 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/actions.spec.js +++ b/hat/assets/js/apps/Iaso/domains/instances/actions.spec.js @@ -1,7 +1,5 @@ import { - SET_CURRENT_INSTANCE, SET_INSTANCES_FILTER_UDPATED, - setCurrentInstance, setInstancesFilterUpdated, } from './actions'; @@ -21,18 +19,6 @@ describe('Instances actions', () => { const action = setInstancesFilterUpdated(payload); expect(action).to.eql(expectedAction); }); - it('should create an action to set current instance', () => { - const payload = { - id: 0, - name: 'LINK', - }; - const expectedAction = { - type: SET_CURRENT_INSTANCE, - payload, - }; - const action = setCurrentInstance(payload); - expect(action).to.eql(expectedAction); - }); // it('should call getRequest on fetchEditUrl', () => { // const resp = { // edit_url: 'https://www.nintendo.be/', diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/CreateReAssignDialogComponent.tsx b/hat/assets/js/apps/Iaso/domains/instances/components/CreateReAssignDialogComponent.tsx index 237804c701..01b8e89165 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/CreateReAssignDialogComponent.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/components/CreateReAssignDialogComponent.tsx @@ -6,12 +6,13 @@ import { useSafeIntl, } from 'bluesquare-components'; import React, { FunctionComponent, useState } from 'react'; +import { UseMutateAsyncFunction } from 'react-query'; import { OrgUnitTreeviewModal } from '../../orgUnits/components/TreeView/OrgUnitTreeviewModal'; import PeriodPicker from '../../periods/components/PeriodPicker'; import { Period } from '../../periods/models'; import { isValidPeriod } from '../../periods/utils'; +import { ReassignInstancePayload } from '../hooks/useReassignInstance'; import MESSAGES from '../messages'; -import { Instance } from '../types/instance'; type Props = { titleMessage: any; @@ -27,10 +28,12 @@ type Props = { // eslint-disable-next-line camelcase org_unit?: any; }; - onCreateOrReAssign: ( - instanceOrForm: Instance | { id: number }, - payload: { period: any; org_unit: any }, - ) => void; + onCreateOrReAssign: UseMutateAsyncFunction< + unknown, + unknown, + ReassignInstancePayload, + unknown + >; orgUnitTypes: number[]; isOpen: boolean; closeDialog: () => void; @@ -87,7 +90,8 @@ export const CreateReAssignDialogComponent: FunctionComponent = ({ const onConfirm = () => { const currentFormOrInstanceProp = currentInstance || formType; - onCreateOrReAssign(currentFormOrInstanceProp, { + onCreateOrReAssign({ + currentInstance: currentFormOrInstanceProp, period: fieldValue.period.value, org_unit: fieldValue.orgUnit.value?.id, }); diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/InstancePopUp/InstancePopUp.tsx b/hat/assets/js/apps/Iaso/domains/instances/components/InstancePopUp/InstancePopUp.tsx index 32a017be9a..f048e216d5 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/InstancePopUp/InstancePopUp.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/components/InstancePopUp/InstancePopUp.tsx @@ -1,6 +1,11 @@ -import React, { FunctionComponent, useCallback, useMemo, useRef } from 'react'; +import { Theme } from '@mui/material/styles'; +import React, { + createRef, + FunctionComponent, + useCallback, + useMemo, +} from 'react'; import { Popup, useMap } from 'react-leaflet'; -import { useSelector } from 'react-redux'; import { Box, Card, CardContent, CardMedia, Grid } from '@mui/material'; import { makeStyles } from '@mui/styles'; @@ -21,9 +26,9 @@ import { getOrgUnitsTree } from '../../../orgUnits/utils'; import MESSAGES from '../../messages'; import { Instance } from '../../types/instance'; -const useStyles = makeStyles(theme => ({ - ...commonStyles(theme), - ...mapPopupStyles(theme), +const useStyles = makeStyles((theme: Theme) => ({ + ...(commonStyles(theme) as Record), + ...(mapPopupStyles(theme) as Record), actionBox: { padding: theme.spacing(1, 0, 0, 0), }, @@ -38,24 +43,25 @@ const useStyles = makeStyles(theme => ({ type Props = { replaceLocation?: (instance: Instance) => void; displayUseLocation?: boolean; + currentInstance: Instance; + isLoading: boolean; }; export const InstancePopup: FunctionComponent = ({ replaceLocation = () => null, displayUseLocation = false, + currentInstance, + isLoading, }) => { const { formatMessage } = useSafeIntl(); const classes: Record = useStyles(); - const popup: any = useRef(); + const popup = createRef(); const map = useMap(); - const currentInstance = useSelector( - (state: any) => state.instances.current, - ); const confirmDialog = useCallback(() => { replaceLocation(currentInstance); map.closePopup(popup.current); - }, [currentInstance, map, replaceLocation]); + }, [currentInstance, map, popup, replaceLocation]); const hasHero = (currentInstance?.files?.length ?? 0) > 0; @@ -68,7 +74,7 @@ export const InstancePopup: FunctionComponent = ({ return ( - {!currentInstance && } + {isLoading && } {currentInstance && ( {hasHero && ( diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/InstancesMap.tsx b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/InstancesMap.tsx index 5e15dd51c5..e775d2305f 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/InstancesMap.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/InstancesMap.tsx @@ -1,17 +1,13 @@ import { Box } from '@mui/material'; import { makeStyles } from '@mui/styles'; import { commonStyles } from 'bluesquare-components'; -import React, { - FunctionComponent, - useCallback, - useMemo, - useState, -} from 'react'; +import React, { FunctionComponent, useMemo, useState } from 'react'; import { MapContainer, ScaleControl } from 'react-leaflet'; import MarkerClusterGroup from 'react-leaflet-markercluster'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { + circleColorMarkerOptions, clusterCustomMarker, defaultCenter, defaultZoom, @@ -23,13 +19,12 @@ import { CustomTileLayer } from '../../../../components/maps/tools/CustomTileLay import { CustomZoomControl } from '../../../../components/maps/tools/CustomZoomControl'; import { MapToggleCluster } from '../../../../components/maps/tools/MapToggleCluster'; import { Tile } from '../../../../components/maps/tools/TilesSwitchControl'; -import { fetchInstanceDetail } from '../../../../utils/requests'; -import { setCurrentInstance } from '../../actions'; import { Instance } from '../../types/instance'; import { InstancePopup } from '../InstancePopUp/InstancePopUp'; import { useShowWarning } from './useShowWarning'; import tiles from '../../../../constants/mapTiles'; +import { useGetInstance } from '../../../registry/hooks/useGetInstances'; const boundsOptions = { padding: [50, 50] }; @@ -55,29 +50,16 @@ export const InstancesMap: FunctionComponent = ({ }) => { const classes = useStyles(); const [isClusterActive, setIsClusterActive] = useState(true); + + const [currentInstanceId, setCurrentInstanceId] = useState< + number | undefined + >(); + const { data: currentInstance, isLoading } = + useGetInstance(currentInstanceId); const [currentTile, setCurrentTile] = useState(tiles.osm); const notifications = useSelector((state: PartialReduxState) => state.snackBar ? state.snackBar.notifications : [], ); - - const dispatch = useDispatch(); - const dispatchInstance = useCallback( - instance => { - dispatch(setCurrentInstance(instance)); - }, - [dispatch], - ); - // We need redux here for the PopUp. Refactoring the pop up may be complex since it is used in MarkerClusterGroup, - // which is itself widely used - const fetchAndDispatchDetail = useCallback( - instance => { - dispatchInstance(null); - fetchInstanceDetail(instance.id).then((i: Instance) => - dispatchInstance(i), - ); - }, - [dispatchInstance], - ); useShowWarning({ instances, notifications, fetching }); const bounds = useMemo(() => { @@ -89,7 +71,7 @@ export const InstancesMap: FunctionComponent = ({ if (fetching) return null; return ( - + = ({ iconCreateFunction={clusterCustomMarker} > ({ + ...circleColorMarkerOptions('red'), + })} + popupProps={() => ({ + currentInstance, + isLoading, + })} items={instances} - onMarkerClick={fetchAndDispatchDetail} + onMarkerClick={i => setCurrentInstanceId(i.id)} PopupComponent={InstancePopup} + isCircle /> )} {!isClusterActive && ( setCurrentInstanceId(i.id)} PopupComponent={InstancePopup} /> )} diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/SpeedDialInstance.tsx b/hat/assets/js/apps/Iaso/domains/instances/components/SpeedDialInstance.tsx index dac587b3b1..79bd36e33e 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/SpeedDialInstance.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/components/SpeedDialInstance.tsx @@ -1,6 +1,7 @@ import React, { FunctionComponent } from 'react'; import { LoadingSpinner } from 'bluesquare-components'; +import { UseMutateAsyncFunction } from 'react-query'; import { hasFeatureFlag, SHOW_LINK_INSTANCE_REFERENCE, @@ -24,6 +25,7 @@ import { useLockAction, } from '../hooks/speedDialActions'; import { useGetFormDefForInstance } from '../hooks/speeddials'; +import { ReassignInstancePayload } from '../hooks/useReassignInstance'; import { Instance } from '../types/instance'; import SpeedDialInstanceActions from './SpeedDialInstanceActions'; @@ -33,6 +35,12 @@ type Props = { instanceId: string; referenceFormId?: string; }; + reassignInstance: UseMutateAsyncFunction< + unknown, + unknown, + ReassignInstancePayload, + unknown + >; }; const SpeedDialInstance: FunctionComponent = props => { @@ -44,6 +52,7 @@ const SpeedDialInstance: FunctionComponent = props => { is_instance_of_reference_form: isInstanceOfReferenceForm, is_reference_instance: isReferenceInstance, }, + reassignInstance, } = props; const { data: formDef } = useGetFormDefForInstance(formId); const currentUser = useCurrentUser(); @@ -79,7 +88,11 @@ const SpeedDialInstance: FunctionComponent = props => { const isLinkActionEnabled = hasfeatureFlag && isInstanceOfReferenceForm && hasOrgUnitPermission; - const baseActions = useBaseActions(currentInstance, formDef); + const baseActions = useBaseActions( + currentInstance, + reassignInstance, + formDef, + ); const editLocationWithInstanceGps = useEditLocationWithGpsAction(currentInstance); diff --git a/hat/assets/js/apps/Iaso/domains/instances/details.tsx b/hat/assets/js/apps/Iaso/domains/instances/details.tsx index ff8b65ff52..d158824e10 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/details.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/details.tsx @@ -18,7 +18,10 @@ import WidgetPaper from '../../components/papers/WidgetPaperComponent'; import { baseUrls } from '../../constants/urls'; import { getRequest } from '../../libs/Api'; import { useSnackQuery } from '../../libs/apiHooks'; -import { useParamsObject } from '../../routing/hooks/useParamsObject'; +import { + ParamsWithAccountId, + useParamsObject, +} from '../../routing/hooks/useParamsObject'; import { ClassNames } from '../../types/utils'; import { BeneficiaryBaseInfo } from '../entities/components/BeneficiaryBaseInfo'; import { useGetBeneficiaryFields } from '../entities/hooks/useGetBeneficiaryFields'; @@ -30,6 +33,10 @@ import InstanceDetailsLocksHistory from './components/InstanceDetailsLocksHistor import InstanceFileContent from './components/InstanceFileContent'; import InstancesFilesList from './components/InstancesFilesListComponent'; import SpeedDialInstance from './components/SpeedDialInstance'; +import { + ReassignInstancePayload, + useReassignInstance, +} from './hooks/useReassignInstance'; import MESSAGES from './messages'; import { getInstancesFilesList } from './utils'; @@ -72,20 +79,26 @@ export const useGetInstanceLogs = ( const InstanceDetails: FunctionComponent = () => { const [showDial, setShowDial] = useState(true); + + const { mutateAsync: reassignInstance, isLoading: isReassigning } = + useReassignInstance(); + const { formatMessage } = useSafeIntl(); const classes: ClassNames = useStyles(); const goBack = useGoBack(baseUrls.instances); - - const params = useParamsObject(baseUrls.instanceDetail) as { + const params = useParamsObject( + baseUrls.instanceDetail, + ) as ParamsWithAccountId & { instanceId: string; }; const { instanceId } = params; const { data: currentInstance, isLoading: isLoadingInstance } = useGetInstance(instanceId); const { isLoading: isLoadingBeneficiaryFields, fields: beneficiaryFields } = - useGetBeneficiaryFields(currentInstance && currentInstance.entity); + useGetBeneficiaryFields(currentInstance?.entity); const isLoading = + isReassigning || isLoadingInstance || (currentInstance?.entity && isLoadingBeneficiaryFields); @@ -113,6 +126,7 @@ const InstanceDetails: FunctionComponent = () => { )} diff --git a/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx b/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx index 18e1476e90..27223c7127 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx @@ -6,12 +6,11 @@ import LockIcon from '@mui/icons-material/Lock'; import RestoreFromTrashIcon from '@mui/icons-material/RestoreFromTrash'; import { DialogContentText } from '@mui/material'; import { ExportButton, useSafeIntl } from 'bluesquare-components'; -import React, { ReactElement, useCallback, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; +import React, { ReactElement, useMemo } from 'react'; +import { UseMutateAsyncFunction } from 'react-query'; import ConfirmCancelDialogComponent from '../../../components/dialogs/ConfirmCancelDialogComponent'; import { Nullable } from '../../../types/utils'; import { useSaveOrgUnit } from '../../orgUnits/hooks'; -import { reAssignInstance } from '../actions'; import { ReAssignDialog } from '../components/CreateReAssignDialogComponent'; import EnketoIcon from '../components/EnketoIcon'; import ExportInstancesDialogComponent from '../components/ExportInstancesDialogComponent'; @@ -19,6 +18,7 @@ import { usePostLockInstance } from '../hooks'; import MESSAGES from '../messages'; import { Instance } from '../types/instance'; import { FormDef, useLinkOrgUnitToReferenceSubmission } from './speeddials'; +import { ReassignInstancePayload } from './useReassignInstance'; export type SpeedDialAction = { id: string; @@ -28,15 +28,14 @@ export type SpeedDialAction = { export const useBaseActions = ( currentInstance: Instance, + reassignInstance: UseMutateAsyncFunction< + unknown, + unknown, + ReassignInstancePayload, + unknown + >, formDef?: FormDef, ): SpeedDialAction[] => { - const dispatch = useDispatch(); - const onReAssignInstance = useCallback( - (...props) => { - dispatch(reAssignInstance(...props)); - }, - [dispatch], - ); return useMemo(() => { return [ { @@ -71,13 +70,13 @@ export const useBaseActions = ( currentInstance={currentInstance} orgUnitTypes={formDef?.orgUnitTypeIds} formType={formDef} - onCreateOrReAssign={onReAssignInstance} + onCreateOrReAssign={reassignInstance} /> ), disabled: currentInstance && currentInstance.deleted, }, ]; - }, [currentInstance, formDef, onReAssignInstance]); + }, [currentInstance, formDef, reassignInstance]); }; export const useEditLocationWithGpsAction = ( diff --git a/hat/assets/js/apps/Iaso/domains/instances/hooks/useReassignInstance.tsx b/hat/assets/js/apps/Iaso/domains/instances/hooks/useReassignInstance.tsx new file mode 100644 index 0000000000..5a88c1eff6 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/instances/hooks/useReassignInstance.tsx @@ -0,0 +1,30 @@ +import MESSAGES from '../../../components/snackBars/messages'; +import { patchRequest } from '../../../libs/Api'; +import { useSnackMutation } from '../../../libs/apiHooks'; + +const reassignInstance = (currentInstance, body) => { + const effectivePayload = { ...body }; + if (!body.period) delete effectivePayload.period; + return patchRequest( + `/api/instances/${currentInstance.id}/`, + effectivePayload, + ); +}; + +export type ReassignInstancePayload = { + currentInstance: { + id: number; + period?: string; + org_unit?: any; + }; + period?: string; + org_unit?: number; +}; + +export const useReassignInstance = () => + useSnackMutation({ + mutationFn: ({ currentInstance, period, org_unit }: T) => + reassignInstance(currentInstance, { period, org_unit }), + snackErrorMsg: MESSAGES.assignInstanceError, + invalidateQueryKey: ['instance'], + }); diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx index 9cd9245cd4..1bfbf6e5b6 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx @@ -3,20 +3,17 @@ import { Pane } from 'react-leaflet'; import MarkerClusterGroup from 'react-leaflet-markercluster'; import { colorClusterCustomMarker } from '../../../../../utils/map/mapUtils'; import { InstancePopup } from '../../../../instances/components/InstancePopUp/InstancePopUp'; -import { Instance } from '../../../../instances/types/instance'; import { OrgUnit } from '../../../types/orgUnit'; import { clusterSize, orgunitsPane } from './constants'; import { MarkerList } from './MarkersList'; type Props = { forms: any[]; - fetchInstanceDetail: (instance: Instance) => void; updateOrgUnitLocation: (orgUnit: OrgUnit) => void; }; export const FormsMarkers: FunctionComponent = ({ forms, - fetchInstanceDetail, updateOrgUnitLocation, }) => { return ( @@ -35,11 +32,8 @@ export const FormsMarkers: FunctionComponent = ({ > fetchInstanceDetail(a)} color={f.color} keyId={f.id} - // The underlying Marker components are all js components, Ts compiler infers a lot and sees errors - // @ts-ignore PopupComponent={InstancePopup} updateOrgUnitLocation={updateOrgUnitLocation} /> diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx index 3299ce6048..8075d3fdf9 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx @@ -1,6 +1,7 @@ -import React, { Component, FunctionComponent } from 'react'; +import React, { Component, FunctionComponent, useState } from 'react'; import MarkersListComponent from '../../../../../components/maps/markers/MarkersListComponent'; import { circleColorMarkerOptions } from '../../../../../utils/map/mapUtils'; +import { useGetInstance } from '../../../../registry/hooks/useGetInstances'; import { OrgUnit } from '../../../types/orgUnit'; import OrgUnitPopupComponent from '../../OrgUnitPopupComponent'; @@ -9,25 +10,30 @@ type Props = { locationsList: any[]; color?: string; keyId: string | number; - fetchDetail: (orgUnit: OrgUnit) => void; updateOrgUnitLocation: (orgUnit: OrgUnit) => void; }; export const MarkerList: FunctionComponent = ({ locationsList, - fetchDetail, color = '#000000', keyId, updateOrgUnitLocation, PopupComponent = OrgUnitPopupComponent, }) => { + const [currentInstanceId, setCurrentInstanceId] = useState< + number | undefined + >(); + const { data: currentInstance, isLoading } = + useGetInstance(currentInstanceId); return ( setCurrentInstanceId(i.id)} PopupComponent={PopupComponent} popupProps={() => ({ + currentInstance, + isLoading, displayUseLocation: true, replaceLocation: selectedOrgUnit => updateOrgUnitLocation(selectedOrgUnit), diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx index 3f40087627..9e72650016 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx @@ -106,7 +106,7 @@ export const OrgUnitMap: FunctionComponent = ({ const [state, setStateField, , setState] = useFormState( initialState(currentUser), ); - const { fetchInstanceDetail, fetchSubOrgUnitDetail } = useRedux(); + const { fetchSubOrgUnitDetail } = useRedux(); const setAncestor = useCallback(() => { const ancestor = getAncestorWithGeojson(currentOrgUnit); if (ancestor) { @@ -581,7 +581,6 @@ export const OrgUnitMap: FunctionComponent = ({ diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/useRedux.ts b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/useRedux.ts index 25366f5de5..563c4212b2 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/useRedux.ts +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/useRedux.ts @@ -1,10 +1,6 @@ import { useCallback } from 'react'; import { useDispatch } from 'react-redux'; -import { - fetchInstanceDetail as fetchInstanceDetailRequest, - fetchOrgUnitDetail, -} from '../../../../../utils/requests'; -import { setCurrentInstance as setCurrentInstanceAction } from '../../../../instances/actions'; +import { fetchOrgUnitDetail } from '../../../../../utils/requests'; import { setCurrentSubOrgUnit as setCurrentSubOrgUnitAction } from '../../../actions'; export const useRedux = () => { @@ -14,11 +10,6 @@ export const useRedux = () => { [dispatch], ); - const setCurrentInstance = useCallback( - i => dispatch(setCurrentInstanceAction(i)), - [dispatch], - ); - const fetchSubOrgUnitDetail = useCallback( orgUnit => { setCurrentSubOrgUnit(null); @@ -29,18 +20,7 @@ export const useRedux = () => { [setCurrentSubOrgUnit], ); - const fetchInstanceDetail = useCallback( - instance => { - setCurrentInstance(null); - fetchInstanceDetailRequest(instance.id).then(i => - setCurrentInstance(i), - ); - }, - [setCurrentInstance], - ); - return { fetchSubOrgUnitDetail, - fetchInstanceDetail, }; }; diff --git a/hat/assets/js/apps/Iaso/utils/requests.js b/hat/assets/js/apps/Iaso/utils/requests.js index cec8337eef..ed18e5df10 100644 --- a/hat/assets/js/apps/Iaso/utils/requests.js +++ b/hat/assets/js/apps/Iaso/utils/requests.js @@ -52,7 +52,7 @@ export const fetchOrgUnitDetail = orgUnitId => }); export const fetchInstanceDetail = instanceId => - getRequest(`/api/instances/${instanceId}`) + getRequest(`/api/instances/${instanceId}/`) .then(instance => instance) .catch(error => { openSnackBar(errorSnackBar('fetchInstanceError', null, error)); From faeb643ecb45ef9889b8accfa7f632ee086daf01 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Thu, 17 Oct 2024 15:33:40 +0200 Subject: [PATCH 03/60] fix instance popup --- .../maps/markers/CircleMarkerComponent.js | 5 ++- .../maps/markers/MarkerComponent.js | 5 ++- .../InstancePopUp/InstancePopUp.tsx | 22 +++++++----- .../components/InstancesMap/InstancesMap.tsx | 22 ++++-------- .../registry/hooks/useGetInstances.tsx | 2 +- .../js/apps/Iaso/utils/map/usePopupState.ts | 34 +++++++++++++++++++ 6 files changed, 63 insertions(+), 27 deletions(-) create mode 100644 hat/assets/js/apps/Iaso/utils/map/usePopupState.ts diff --git a/hat/assets/js/apps/Iaso/components/maps/markers/CircleMarkerComponent.js b/hat/assets/js/apps/Iaso/components/maps/markers/CircleMarkerComponent.js index a2499eb914..31faa570f4 100644 --- a/hat/assets/js/apps/Iaso/components/maps/markers/CircleMarkerComponent.js +++ b/hat/assets/js/apps/Iaso/components/maps/markers/CircleMarkerComponent.js @@ -31,7 +31,10 @@ const CircleMarkerComponent = props => { draggable={draggable} center={[item.latitude, item.longitude, item.altitude]} eventHandlers={{ - click: () => onClick(item), + click: e => { + e.originalEvent.stopPropagation(); + onClick(item); + }, dragend: e => onDragend(e.target), dblclick: e => onDblclick(e, item), }} diff --git a/hat/assets/js/apps/Iaso/components/maps/markers/MarkerComponent.js b/hat/assets/js/apps/Iaso/components/maps/markers/MarkerComponent.js index d26924ca31..d3a630f901 100644 --- a/hat/assets/js/apps/Iaso/components/maps/markers/MarkerComponent.js +++ b/hat/assets/js/apps/Iaso/components/maps/markers/MarkerComponent.js @@ -31,7 +31,10 @@ const MarkerComponent = props => { icon={marker || customMarker} position={[item.latitude, item.longitude, item.altitude]} eventHandlers={{ - click: () => onClick(item), + click: e => { + e.originalEvent.stopPropagation(); + onClick(item); + }, dragend: e => onDragend(e.target), dblclick: e => onDblclick(e, item), }} diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/InstancePopUp/InstancePopUp.tsx b/hat/assets/js/apps/Iaso/domains/instances/components/InstancePopUp/InstancePopUp.tsx index f048e216d5..e76b5db977 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/InstancePopUp/InstancePopUp.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/components/InstancePopUp/InstancePopUp.tsx @@ -1,4 +1,5 @@ import { Theme } from '@mui/material/styles'; +import L from 'leaflet'; import React, { createRef, FunctionComponent, @@ -22,7 +23,9 @@ import InstanceDetailsField from '../InstanceDetailsField'; import InstanceDetailsInfos from '../InstanceDetailsInfos'; import { baseUrls } from '../../../../constants/urls'; +import { usePopupState } from '../../../../utils/map/usePopupState'; import { getOrgUnitsTree } from '../../../orgUnits/utils'; +import { useGetInstance } from '../../../registry/hooks/useGetInstances'; import MESSAGES from '../../messages'; import { Instance } from '../../types/instance'; @@ -43,24 +46,27 @@ const useStyles = makeStyles((theme: Theme) => ({ type Props = { replaceLocation?: (instance: Instance) => void; displayUseLocation?: boolean; - currentInstance: Instance; - isLoading: boolean; + instanceId: number; }; export const InstancePopup: FunctionComponent = ({ replaceLocation = () => null, displayUseLocation = false, - currentInstance, - isLoading, + instanceId, }) => { const { formatMessage } = useSafeIntl(); const classes: Record = useStyles(); - const popup = createRef(); + const popup = createRef(); + const isOpen = usePopupState(popup); const map = useMap(); - + const { data: currentInstance, isLoading } = useGetInstance( + isOpen ? instanceId : undefined, + ); const confirmDialog = useCallback(() => { - replaceLocation(currentInstance); - map.closePopup(popup.current); + if (currentInstance) { + replaceLocation(currentInstance); + map.closePopup(popup.current); + } }, [currentInstance, map, popup, replaceLocation]); const hasHero = (currentInstance?.files?.length ?? 0) > 0; diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/InstancesMap.tsx b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/InstancesMap.tsx index e775d2305f..d214a25b97 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/InstancesMap.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/InstancesMap.tsx @@ -4,10 +4,10 @@ import { commonStyles } from 'bluesquare-components'; import React, { FunctionComponent, useMemo, useState } from 'react'; import { MapContainer, ScaleControl } from 'react-leaflet'; import MarkerClusterGroup from 'react-leaflet-markercluster'; + import { useSelector } from 'react-redux'; import { - circleColorMarkerOptions, clusterCustomMarker, defaultCenter, defaultZoom, @@ -24,7 +24,6 @@ import { InstancePopup } from '../InstancePopUp/InstancePopUp'; import { useShowWarning } from './useShowWarning'; import tiles from '../../../../constants/mapTiles'; -import { useGetInstance } from '../../../registry/hooks/useGetInstances'; const boundsOptions = { padding: [50, 50] }; @@ -51,11 +50,6 @@ export const InstancesMap: FunctionComponent = ({ const classes = useStyles(); const [isClusterActive, setIsClusterActive] = useState(true); - const [currentInstanceId, setCurrentInstanceId] = useState< - number | undefined - >(); - const { data: currentInstance, isLoading } = - useGetInstance(currentInstanceId); const [currentTile, setCurrentTile] = useState(tiles.osm); const notifications = useSelector((state: PartialReduxState) => state.snackBar ? state.snackBar.notifications : [], @@ -102,24 +96,20 @@ export const InstancesMap: FunctionComponent = ({ iconCreateFunction={clusterCustomMarker} > ({ - ...circleColorMarkerOptions('red'), - })} - popupProps={() => ({ - currentInstance, - isLoading, + popupProps={instance => ({ + instanceId: instance.id, })} items={instances} - onMarkerClick={i => setCurrentInstanceId(i.id)} PopupComponent={InstancePopup} - isCircle /> )} {!isClusterActive && ( setCurrentInstanceId(i.id)} + popupProps={instance => ({ + instanceId: instance.id, + })} PopupComponent={InstancePopup} /> )} diff --git a/hat/assets/js/apps/Iaso/domains/registry/hooks/useGetInstances.tsx b/hat/assets/js/apps/Iaso/domains/registry/hooks/useGetInstances.tsx index 07b7258970..f4815ff4f2 100644 --- a/hat/assets/js/apps/Iaso/domains/registry/hooks/useGetInstances.tsx +++ b/hat/assets/js/apps/Iaso/domains/registry/hooks/useGetInstances.tsx @@ -1,5 +1,5 @@ -import { UseQueryResult } from 'react-query'; import { getSort } from 'bluesquare-components'; +import { UseQueryResult } from 'react-query'; import { getRequest } from '../../../libs/Api'; import { useSnackQuery } from '../../../libs/apiHooks'; diff --git a/hat/assets/js/apps/Iaso/utils/map/usePopupState.ts b/hat/assets/js/apps/Iaso/utils/map/usePopupState.ts new file mode 100644 index 0000000000..e9b9801a72 --- /dev/null +++ b/hat/assets/js/apps/Iaso/utils/map/usePopupState.ts @@ -0,0 +1,34 @@ +import L from 'leaflet'; +import { RefObject, useEffect, useState } from 'react'; +import { useMap } from 'react-leaflet'; + +export const usePopupState = (popupRef: RefObject): boolean => { + const [isOpen, setIsOpen] = useState(false); + const map = useMap(); + + useEffect(() => { + if (!map || !popupRef.current) return; + + const onPopupOpen = (e: L.PopupEvent): void => { + if (e.popup === popupRef.current) { + setIsOpen(true); + } + }; + + const onPopupClose = (e: L.PopupEvent): void => { + if (e.popup === popupRef.current) { + setIsOpen(false); + } + }; + + map.on('popupopen', onPopupOpen); + map.on('popupclose', onPopupClose); + + () => { + map.off('popupopen', onPopupOpen); + map.off('popupclose', onPopupClose); + }; + }, [map, popupRef]); + + return isOpen; +}; From 3cc9304dfe21c42de870c2a5403dc476ef836852 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Thu, 17 Oct 2024 15:56:54 +0200 Subject: [PATCH 04/60] org unit popup fixes --- .../components/OrgUnitPopupComponent.js | 36 +++++---- .../orgUnits/components/OrgUnitsMap.tsx | 77 ++++--------------- .../orgUnitMap/OrgUnitMap/OrgUnitMap.tsx | 4 +- .../OrgUnitMap/OrgUnitTypesSelectedShapes.tsx | 1 + .../orgUnitMap/OrgUnitMap/SourceShape.tsx | 1 + 5 files changed, 40 insertions(+), 79 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitPopupComponent.js b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitPopupComponent.js index cd619a04f2..52b122d454 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitPopupComponent.js +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitPopupComponent.js @@ -1,29 +1,31 @@ -import React, { createRef } from 'react'; -import { useSelector } from 'react-redux'; -import PropTypes from 'prop-types'; -import { Popup } from 'react-leaflet'; -import classNames from 'classnames'; import { + Box, Card, CardContent, + Divider, Grid, - Box, Typography, - Divider, } from '@mui/material'; import { makeStyles } from '@mui/styles'; -import moment from 'moment'; import { - textPlaceholder, - useSafeIntl, + LinkButton, LoadingSpinner, commonStyles, mapPopupStyles, - LinkButton, + textPlaceholder, + useSafeIntl, } from 'bluesquare-components'; -import PopupItemComponent from '../../../components/maps/popups/PopupItemComponent'; +import classNames from 'classnames'; +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { createRef } from 'react'; +import { Popup } from 'react-leaflet'; +import { useSelector } from 'react-redux'; import ConfirmDialog from '../../../components/dialogs/ConfirmDialogComponent'; +import PopupItemComponent from '../../../components/maps/popups/PopupItemComponent'; import { baseUrls } from '../../../constants/urls.ts'; +import { usePopupState } from '../../../utils/map/usePopupState'; +import { useGetOrgUnitDetail } from '../hooks/requests/useGetOrgUnitDetail'; import MESSAGES from '../messages.ts'; const useStyles = makeStyles(theme => ({ @@ -61,7 +63,7 @@ const OrgUnitPopupComponent = ({ displayUseLocation, replaceLocation, titleMessage, - currentOrgUnit, + orgUnitId, }) => { const { formatMessage } = useSafeIntl(); const classes = useStyles(); @@ -69,6 +71,11 @@ const OrgUnitPopupComponent = ({ const reduxCurrentOrgUnit = useSelector( state => state.orgUnits.currentSubOrgUnit, ); + + const isOpen = usePopupState(popup); + const { data: currentOrgUnit } = useGetOrgUnitDetail( + isOpen && !reduxCurrentOrgUnit ? orgUnitId : undefined, + ); const activeOrgUnit = currentOrgUnit || reduxCurrentOrgUnit; const confirmDialog = () => { replaceLocation(activeOrgUnit); @@ -186,17 +193,16 @@ const OrgUnitPopupComponent = ({ }; OrgUnitPopupComponent.defaultProps = { - currentOrgUnit: null, displayUseLocation: false, replaceLocation: () => {}, titleMessage: null, }; OrgUnitPopupComponent.propTypes = { - currentOrgUnit: PropTypes.object, displayUseLocation: PropTypes.bool, replaceLocation: PropTypes.func, titleMessage: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), + orgUnitId: PropTypes.number.isRequired, }; export default OrgUnitPopupComponent; diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsMap.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsMap.tsx index 7ffab44c78..8ec0da15b0 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsMap.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsMap.tsx @@ -1,10 +1,5 @@ import { Box, Grid } from '@mui/material'; -import { makeStyles } from '@mui/styles'; -import { - commonStyles, - IntlFormatMessage, - useSafeIntl, -} from 'bluesquare-components'; +import { IntlFormatMessage, useSafeIntl } from 'bluesquare-components'; import React, { FunctionComponent, useMemo, useState } from 'react'; import { GeoJSON, @@ -15,17 +10,11 @@ import { } from 'react-leaflet'; import MarkerClusterGroup from 'react-leaflet-markercluster'; -// COMPONENTS - import MarkersListComponent from '../../../components/maps/markers/MarkersListComponent'; import { Tile } from '../../../components/maps/tools/TilesSwitchControl'; -import { innerDrawerStyles } from '../../../components/nav/InnerDrawer/styles'; import ErrorPaperComponent from '../../../components/papers/ErrorPaperComponent'; -import { OrgUnitsMapComments } from './orgUnitMap/OrgUnitsMapComments'; import OrgUnitPopupComponent from './OrgUnitPopupComponent'; -// COMPONENTS -// UTILS import { Bounds, circleColorMarkerOptions, @@ -33,16 +22,10 @@ import { getLatLngBounds, getShapesBounds, } from '../../../utils/map/mapUtils'; -// UTILS -// TYPES import { DropdownOptions } from '../../../types/utils'; import { OrgUnit } from '../types/orgUnit'; -// TYPES -// HOOKS -import { useGetOrgUnitDetail } from '../hooks/requests/useGetOrgUnitDetail'; -// HOOKS import { CustomTileLayer } from '../../../components/maps/tools/CustomTileLayer'; import { CustomZoomControl } from '../../../components/maps/tools/CustomZoomControl'; import { MapToggleCluster } from '../../../components/maps/tools/MapToggleCluster'; @@ -64,21 +47,6 @@ type Props = { orgUnits: Locations; }; -const useStyles = makeStyles(theme => ({ - ...commonStyles(theme), - ...innerDrawerStyles(theme), - innerDrawerToolbar: { - ...innerDrawerStyles(theme).innerDrawerToolbar, - '& section': { - width: '100%', - }, - }, - commentContainer: { - height: '60vh', - overflowY: 'auto', - }, -})); - const getFullOrgUnits = orgUnits => { let fullOrUnits = []; Object.values(orgUnits).forEach(searchOrgUnits => { @@ -111,14 +79,9 @@ export const OrgUnitsMap: FunctionComponent = ({ getSearchColor, orgUnits, }) => { - const classes: Record = useStyles(); const { data: orgUnitTypes } = useGetOrgUnitTypes(); const [currentTile, setCurrentTile] = useState(tiles.osm); const [isClusterActive, setIsClusterActive] = useState(true); - const [currentOrgUnitId, setCurrentOrgUnitId] = useState< - number | undefined - >(); - const { data: currentOrgUnit } = useGetOrgUnitDetail(currentOrgUnitId); const { formatMessage }: { formatMessage: IntlFormatMessage } = useSafeIntl(); @@ -151,9 +114,8 @@ export const OrgUnitsMap: FunctionComponent = ({ ...circleColorMarkerOptions(color), })} items={orgUnitsBySearch} - onMarkerClick={o => setCurrentOrgUnitId(o.id)} - popupProps={() => ({ - currentOrgUnit, + popupProps={instance => ({ + orgUnitId: instance.id, })} PopupComponent={OrgUnitPopupComponent} tooltipProps={e => ({ @@ -181,9 +143,8 @@ export const OrgUnitsMap: FunctionComponent = ({ ), })} items={orgUnitsBySearch} - onMarkerClick={o => setCurrentOrgUnitId(o.id)} - popupProps={() => ({ - currentOrgUnit, + popupProps={instance => ({ + orgUnitId: instance.id, })} PopupComponent={OrgUnitPopupComponent} tooltipProps={e => ({ @@ -194,7 +155,7 @@ export const OrgUnitsMap: FunctionComponent = ({ /> )); - }, [currentOrgUnit, getSearchColor, isClusterActive, orgUnits.locations]); + }, [getSearchColor, isClusterActive, orgUnits.locations]); if (!bounds && orgUnitsTotal.length > 0) { return ( @@ -218,13 +179,13 @@ export const OrgUnitsMap: FunctionComponent = ({ - } + // commentsOptionComponent={ + // + // } > = ({ ), }} data={o.geo_json} - eventHandlers={{ - click: () => - setCurrentOrgUnitId(o.id), - }} > {o.name} @@ -305,13 +262,9 @@ export const OrgUnitsMap: FunctionComponent = ({ ), }} data={o.geo_json} - eventHandlers={{ - click: () => - setCurrentOrgUnitId(o.id), - }} > {o.name} diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx index 9e72650016..1afb8e5bf2 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx @@ -540,8 +540,8 @@ export const OrgUnitMap: FunctionComponent = ({ titleMessage={formatMessage( MESSAGES.ouParent, )} - currentOrgUnit={ - state.ancestorWithGeoJson.value + orgUnitId={ + state.ancestorWithGeoJson.value.id } /> diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitTypesSelectedShapes.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitTypesSelectedShapes.tsx index 92f96d12e0..e0ad04d9ce 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitTypesSelectedShapes.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitTypesSelectedShapes.tsx @@ -62,6 +62,7 @@ export const OrgUnitTypesSelectedShapes: FunctionComponent = ({ MESSAGES.ouChild, )} displayUseLocation + orgUnitId={o.id} replaceLocation={selectedOrgUnit => updateOrgUnitLocation( selectedOrgUnit, diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourceShape.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourceShape.tsx index e5500d995e..06cecb8927 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourceShape.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourceShape.tsx @@ -32,6 +32,7 @@ export const SourceShape: FunctionComponent = ({ titleMessage={formatMessage(MESSAGES.ouLinked)} displayUseLocation replaceLocation={replaceLocation} + orgUnitId={shape.id} /> ); From 515bb6dce2e015f9b0ce29b6bcf80cbe58d4932e Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Fri, 18 Oct 2024 14:01:35 +0200 Subject: [PATCH 05/60] fixing other popup, fixing comments, removing redux getSubOrgunit --- .../js/apps/Iaso/domains/orgUnits/actions.js | 6 --- .../components/OrgUnitPopupComponent.js | 38 ++++++++---------- .../orgUnits/components/OrgUnitsMap.tsx | 40 +++++++++++++++---- .../orgUnitMap/OrgUnitMap/FormsMarkers.tsx | 3 ++ .../orgUnitMap/OrgUnitMap/MarkersList.tsx | 14 ++----- .../orgUnitMap/OrgUnitMap/OrgUnitMap.tsx | 6 --- .../OrgUnitMap/OrgUnitTypesSelectedShapes.tsx | 9 ----- .../orgUnitMap/OrgUnitMap/SelectedMarkers.tsx | 6 +-- .../orgUnitMap/OrgUnitMap/SourceShape.tsx | 5 --- .../OrgUnitMap/SourcesSelectedShapes.tsx | 5 --- .../orgUnitMap/OrgUnitMap/useRedux.ts | 26 ------------ .../orgUnitMap/OrgUnitsMapComments.js | 23 ++++------- .../js/apps/Iaso/domains/orgUnits/reducer.js | 13 +----- hat/assets/js/apps/Iaso/utils/map/mapUtils.ts | 2 +- hat/assets/js/apps/Iaso/utils/requests.js | 8 ---- 15 files changed, 67 insertions(+), 137 deletions(-) delete mode 100644 hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/useRedux.ts diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/actions.js b/hat/assets/js/apps/Iaso/domains/orgUnits/actions.js index 6ee64091ed..b35e8d9d78 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/actions.js +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/actions.js @@ -1,5 +1,4 @@ export const RESET_ORG_UNITS = 'RESET_ORG_UNITS'; -export const SET_SUB_ORG_UNIT = 'SET_SUB_ORG_UNIT'; export const SET_ORG_UNIT_TYPES = 'SET_ORG_UNIT_TYPES'; export const SET_SOURCES = 'SET_SOURCES'; @@ -7,11 +6,6 @@ export const resetOrgUnits = () => ({ type: RESET_ORG_UNITS, }); -export const setCurrentSubOrgUnit = orgUnit => ({ - type: SET_SUB_ORG_UNIT, - payload: orgUnit, -}); - export const setOrgUnitTypes = orgUnitTypes => ({ type: SET_ORG_UNIT_TYPES, payload: orgUnitTypes, diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitPopupComponent.js b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitPopupComponent.js index 52b122d454..11adce49f4 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitPopupComponent.js +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitPopupComponent.js @@ -20,7 +20,6 @@ import moment from 'moment'; import PropTypes from 'prop-types'; import React, { createRef } from 'react'; import { Popup } from 'react-leaflet'; -import { useSelector } from 'react-redux'; import ConfirmDialog from '../../../components/dialogs/ConfirmDialogComponent'; import PopupItemComponent from '../../../components/maps/popups/PopupItemComponent'; import { baseUrls } from '../../../constants/urls.ts'; @@ -68,26 +67,21 @@ const OrgUnitPopupComponent = ({ const { formatMessage } = useSafeIntl(); const classes = useStyles(); const popup = createRef(); - const reduxCurrentOrgUnit = useSelector( - state => state.orgUnits.currentSubOrgUnit, - ); - const isOpen = usePopupState(popup); const { data: currentOrgUnit } = useGetOrgUnitDetail( - isOpen && !reduxCurrentOrgUnit ? orgUnitId : undefined, + isOpen ? orgUnitId : undefined, ); - const activeOrgUnit = currentOrgUnit || reduxCurrentOrgUnit; const confirmDialog = () => { - replaceLocation(activeOrgUnit); + replaceLocation(currentOrgUnit); }; let groups = null; - if (activeOrgUnit && activeOrgUnit.groups.length > 0) { - groups = activeOrgUnit.groups.map(g => g.name).join(', '); + if (currentOrgUnit && currentOrgUnit.groups.length > 0) { + groups = currentOrgUnit.groups.map(g => g.name).join(', '); } return ( - {!activeOrgUnit && } - {activeOrgUnit && ( + {!currentOrgUnit && } + {currentOrgUnit && ( - {!activeOrgUnit.has_geo_json && ( + {!currentOrgUnit.has_geo_json && ( <> )} @@ -174,7 +168,7 @@ const OrgUnitPopupComponent = ({ )} & { search_index: number; @@ -56,6 +62,14 @@ const getFullOrgUnits = orgUnits => { return fullOrUnits; }; +const useStyles = makeStyles(theme => ({ + ...commonStyles(theme), + commentContainer: { + height: '60vh', + overflowY: 'auto', + }, +})); + const getOrgUnitsBounds = (orgUnits: Locations): Bounds | undefined => { const orgUnitsLocations = getFullOrgUnits(orgUnits.locations); const locationsBounds = @@ -79,10 +93,14 @@ export const OrgUnitsMap: FunctionComponent = ({ getSearchColor, orgUnits, }) => { + const classes = useStyles(); const { data: orgUnitTypes } = useGetOrgUnitTypes(); const [currentTile, setCurrentTile] = useState(tiles.osm); const [isClusterActive, setIsClusterActive] = useState(true); + const [selectedOrgUnit, setSelectedOrgUnit] = useState< + OrgUnit | undefined + >(); const { formatMessage }: { formatMessage: IntlFormatMessage } = useSafeIntl(); @@ -123,6 +141,7 @@ export const OrgUnitsMap: FunctionComponent = ({ })} TooltipComponent={Tooltip} isCircle + onMarkerClick={o => setSelectedOrgUnit(o)} /> @@ -152,6 +171,7 @@ export const OrgUnitsMap: FunctionComponent = ({ })} TooltipComponent={Tooltip} isCircle + onMarkerClick={o => setSelectedOrgUnit(o)} /> )); @@ -179,13 +199,13 @@ export const OrgUnitsMap: FunctionComponent = ({ - // } + commentsOptionComponent={ + + } > = ({ ), }} data={o.geo_json} + onClick={() => setSelectedOrgUnit(o)} > = ({ ), }} data={o.geo_json} + onClick={() => + setSelectedOrgUnit(o) + } > = ({ color={f.color} keyId={f.id} PopupComponent={InstancePopup} + popupProps={i => ({ + instanceId: i.id, + })} updateOrgUnitLocation={updateOrgUnitLocation} /> diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx index 8075d3fdf9..b54a10e4bd 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx @@ -1,7 +1,6 @@ -import React, { Component, FunctionComponent, useState } from 'react'; +import React, { Component, FunctionComponent } from 'react'; import MarkersListComponent from '../../../../../components/maps/markers/MarkersListComponent'; import { circleColorMarkerOptions } from '../../../../../utils/map/mapUtils'; -import { useGetInstance } from '../../../../registry/hooks/useGetInstances'; import { OrgUnit } from '../../../types/orgUnit'; import OrgUnitPopupComponent from '../../OrgUnitPopupComponent'; @@ -11,6 +10,7 @@ type Props = { color?: string; keyId: string | number; updateOrgUnitLocation: (orgUnit: OrgUnit) => void; + popupProps?: any; }; export const MarkerList: FunctionComponent = ({ @@ -19,24 +19,18 @@ export const MarkerList: FunctionComponent = ({ keyId, updateOrgUnitLocation, PopupComponent = OrgUnitPopupComponent, + popupProps, }) => { - const [currentInstanceId, setCurrentInstanceId] = useState< - number | undefined - >(); - const { data: currentInstance, isLoading } = - useGetInstance(currentInstanceId); return ( setCurrentInstanceId(i.id)} PopupComponent={PopupComponent} popupProps={() => ({ - currentInstance, - isLoading, displayUseLocation: true, replaceLocation: selectedOrgUnit => updateOrgUnitLocation(selectedOrgUnit), + ...popupProps, })} isCircle markerProps={() => ({ diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx index 1afb8e5bf2..c60c4dd6cf 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx @@ -48,7 +48,6 @@ import { SourcesSelectedShapes } from './SourcesSelectedShapes'; import { buttonsInitialState } from './constants'; import { MappedOrgUnit } from './types'; import { useGetBounds } from './useGetBounds'; -import { useRedux } from './useRedux'; import { getAncestorWithGeojson, initialState } from './utils'; export const zoom = 5; @@ -106,7 +105,6 @@ export const OrgUnitMap: FunctionComponent = ({ const [state, setStateField, , setState] = useFormState( initialState(currentUser), ); - const { fetchSubOrgUnitDetail } = useRedux(); const setAncestor = useCallback(() => { const ancestor = getAncestorWithGeojson(currentOrgUnit); if (ancestor) { @@ -553,7 +551,6 @@ export const OrgUnitMap: FunctionComponent = ({ = ({ mappedOrgUnitTypesSelected } mappedSourcesSelected={mappedSourcesSelected} - fetchSubOrgUnitDetail={fetchSubOrgUnitDetail} updateOrgUnitLocation={updateOrgUnitLocation} /> @@ -570,12 +566,10 @@ export const OrgUnitMap: FunctionComponent = ({ <> diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitTypesSelectedShapes.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitTypesSelectedShapes.tsx index e0ad04d9ce..cc5163099a 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitTypesSelectedShapes.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/OrgUnitTypesSelectedShapes.tsx @@ -13,7 +13,6 @@ type Props = { orgUnitTypes: (OrgunitType & { color: string })[]; mappedOrgUnitTypesSelected: MappedOrgUnit[]; mappedSourcesSelected: MappedOrgUnit[]; - fetchSubOrgUnitDetail: (orgUnit: OrgUnit) => void; updateOrgUnitLocation: (orgUnit: OrgUnit) => void; }; @@ -21,7 +20,6 @@ export const OrgUnitTypesSelectedShapes: FunctionComponent = ({ orgUnitTypes, mappedOrgUnitTypesSelected, mappedSourcesSelected, - fetchSubOrgUnitDetail, updateOrgUnitLocation, }) => { const { formatMessage } = useSafeIntl(); @@ -49,10 +47,6 @@ export const OrgUnitTypesSelectedShapes: FunctionComponent = ({ - fetchSubOrgUnitDetail(o), - }} style={() => ({ color: ot.color, })} @@ -79,9 +73,6 @@ export const OrgUnitTypesSelectedShapes: FunctionComponent = ({ shape={o} key={o.id} replaceLocation={updateOrgUnitLocation} - onClick={() => { - fetchSubOrgUnitDetail(o); - }} /> )), )} diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx index 1e9888285b..27ef137a55 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx @@ -10,13 +10,11 @@ import { MappedOrgUnit } from './types'; type Props = { data: MappedOrgUnit[]; - fetchSubOrgUnitDetail: (orgUnit: OrgUnit) => void; updateOrgUnitLocation: (orgUnit: OrgUnit) => void; }; export const SelectedMarkers: FunctionComponent = ({ data, - fetchSubOrgUnitDetail, updateOrgUnitLocation, }) => { return ( @@ -39,7 +37,9 @@ export const SelectedMarkers: FunctionComponent = ({ > fetchSubOrgUnitDetail(a)} + popupProps={o => ({ + instanceId: o.id, + })} color={mappedOrgUnit.color} keyId={mappedOrgUnit.id} updateOrgUnitLocation={updateOrgUnitLocation} diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourceShape.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourceShape.tsx index 06cecb8927..25eb58661c 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourceShape.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourceShape.tsx @@ -5,7 +5,6 @@ import MESSAGES from '../../../messages'; import OrgUnitPopupComponent from '../../OrgUnitPopupComponent'; type Props = { - onClick: () => void; replaceLocation: (orgUnit: any) => void; source: any; shape: any; @@ -14,7 +13,6 @@ type Props = { export const SourceShape: FunctionComponent = ({ source, shape, - onClick, replaceLocation, }) => { const { formatMessage } = useSafeIntl(); @@ -24,9 +22,6 @@ export const SourceShape: FunctionComponent = ({ color: source.color, }} data={shape.geo_json} - eventHandlers={{ - click: onClick, - }} > void; - fetchSubOrgUnitDetail: (orgUnit: OrgUnit) => void; }; export const SourcesSelectedShapes: FunctionComponent = ({ mappedSourcesSelected, updateOrgUnitLocation, - fetchSubOrgUnitDetail, }) => { return ( <> @@ -30,9 +28,6 @@ export const SourcesSelectedShapes: FunctionComponent = ({ shape={o} key={o.id} replaceLocation={updateOrgUnitLocation} - onClick={() => { - fetchSubOrgUnitDetail(o); - }} /> ))} diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/useRedux.ts b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/useRedux.ts deleted file mode 100644 index 563c4212b2..0000000000 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/useRedux.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; -import { fetchOrgUnitDetail } from '../../../../../utils/requests'; -import { setCurrentSubOrgUnit as setCurrentSubOrgUnitAction } from '../../../actions'; - -export const useRedux = () => { - const dispatch = useDispatch(); - const setCurrentSubOrgUnit = useCallback( - o => dispatch(setCurrentSubOrgUnitAction(o)), - [dispatch], - ); - - const fetchSubOrgUnitDetail = useCallback( - orgUnit => { - setCurrentSubOrgUnit(null); - fetchOrgUnitDetail(orgUnit.id).then(subOrgUnit => - setCurrentSubOrgUnit(subOrgUnit), - ); - }, - [setCurrentSubOrgUnit], - ); - - return { - fetchSubOrgUnitDetail, - }; -}; diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitsMapComments.js b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitsMapComments.js index 23ec435aea..61c71750e5 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitsMapComments.js +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitsMapComments.js @@ -10,7 +10,6 @@ import React, { useCallback, useState } from 'react'; import { useSnackMutation } from 'Iaso/libs/apiHooks.ts'; import PropTypes from 'prop-types'; -import { useSelector } from 'react-redux'; import { sendComment, useGetComments } from '../../../../utils/requests'; import MESSAGES from '../../messages.ts'; @@ -54,18 +53,13 @@ const OrgUnitsMapComments = ({ className, maxPages, inlineTextAreaButton, - getOrgUnitFromStore, }) => { const { formatMessage } = useSafeIntl(); const classes = useStyles(); - const globalStateOrgUnit = useSelector( - state => state.orgUnits.currentSubOrgUnit, - ); - const orgUnitToUse = getOrgUnitFromStore ? globalStateOrgUnit : orgUnit; const [offset, setOffset] = useState(null); const [pageSize] = useState(maxPages); const commentsParams = { - orgUnitId: orgUnitToUse?.id, + orgUnitId: orgUnit?.id, offset, limit: pageSize, }; @@ -83,11 +77,11 @@ const OrgUnitsMapComments = ({ parent: id, comment: text, content_type: 'iaso-orgunit', - object_pk: orgUnitToUse?.id, + object_pk: orgUnit?.id, }; await postComment(requestBody); }, - [orgUnitToUse, postComment], + [orgUnit, postComment], ); const formatComment = comment => { const mainComment = adaptComment(comment); @@ -114,24 +108,23 @@ const OrgUnitsMapComments = ({ async text => { const comment = { comment: text, - object_pk: orgUnitToUse?.id, + object_pk: orgUnit?.id, parent: null, // content_type: 57, content_type: 'iaso-orgunit', }; await postComment(comment); }, - [orgUnitToUse, postComment], + [orgUnit, postComment], ); return ( <> - {orgUnitToUse?.name ?? - formatMessage(MESSAGES.selectOrgUnit)} + {orgUnit?.name ?? formatMessage(MESSAGES.selectOrgUnit)} - {orgUnitToUse && ( + {orgUnit && ( { switch (action.type) { - case SET_SUB_ORG_UNIT: { - const currentSubOrgUnit = action.payload; - return { ...state, currentSubOrgUnit }; - } - case SET_ORG_UNIT_TYPES: { const orgUnitTypes = action.payload; return { ...state, orgUnitTypes }; diff --git a/hat/assets/js/apps/Iaso/utils/map/mapUtils.ts b/hat/assets/js/apps/Iaso/utils/map/mapUtils.ts index 18fdc923c3..21ac882b5f 100644 --- a/hat/assets/js/apps/Iaso/utils/map/mapUtils.ts +++ b/hat/assets/js/apps/Iaso/utils/map/mapUtils.ts @@ -115,7 +115,7 @@ export const customMarker = L.divIcon(customMarkerOptions); export const circleColorMarkerOptions = ( color: string, - radius = 8, + radius = 10, ): Record => ({ className: 'marker-custom color circle-marker', pathOptions: { diff --git a/hat/assets/js/apps/Iaso/utils/requests.js b/hat/assets/js/apps/Iaso/utils/requests.js index ed18e5df10..9c5ccd8ddb 100644 --- a/hat/assets/js/apps/Iaso/utils/requests.js +++ b/hat/assets/js/apps/Iaso/utils/requests.js @@ -43,14 +43,6 @@ export const fetchAssociatedOrgUnits = ( }); }; -export const fetchOrgUnitDetail = orgUnitId => - getRequest(`/api/orgunits/${orgUnitId}/`) - .then(orgUnit => orgUnit) - .catch(error => { - openSnackBar(errorSnackBar('fetchOrgUnitError', null, error)); - console.error('Error while fetching org unit detail:', error); - }); - export const fetchInstanceDetail = instanceId => getRequest(`/api/instances/${instanceId}/`) .then(instance => instance) From 57a153bca963ac52262719fd94a3f72ab698d381 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Fri, 18 Oct 2024 14:30:01 +0200 Subject: [PATCH 06/60] remove org unit reducer and actions --- .../components/InstancesFiltersComponent.js | 22 +++++-------- .../js/apps/Iaso/domains/instances/hooks.js | 16 --------- .../js/apps/Iaso/domains/orgUnits/actions.js | 12 ------- .../js/apps/Iaso/domains/orgUnits/details.js | 6 +--- .../js/apps/Iaso/domains/orgUnits/reducer.js | 33 ------------------- .../js/apps/Iaso/domains/tasks/actions.js | 13 -------- .../users/components/ProtectedRoute.spec.js | 17 ++++------ hat/assets/js/apps/Iaso/hooks/fetchOnMount.js | 17 ---------- hat/assets/js/apps/Iaso/redux/store.js | 6 ---- hat/assets/js/apps/Iaso/routing/actions.ts | 12 ------- plugins/test/js/index.js | 11 +++---- 11 files changed, 21 insertions(+), 144 deletions(-) delete mode 100644 hat/assets/js/apps/Iaso/domains/orgUnits/actions.js delete mode 100644 hat/assets/js/apps/Iaso/domains/orgUnits/reducer.js delete mode 100644 hat/assets/js/apps/Iaso/domains/tasks/actions.js delete mode 100644 hat/assets/js/apps/Iaso/hooks/fetchOnMount.js delete mode 100644 hat/assets/js/apps/Iaso/routing/actions.ts diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js index 5427501582..62638e462f 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js +++ b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js @@ -28,7 +28,7 @@ import { getInstancesFilterValues, useFormState } from '../../../hooks/form'; import { useGetFormDescriptor } from '../../forms/fields/hooks/useGetFormDescriptor.ts'; import { useGetQueryBuilderListToReplace } from '../../forms/fields/hooks/useGetQueryBuilderListToReplace.ts'; import { useGetQueryBuildersFields } from '../../forms/fields/hooks/useGetQueryBuildersFields.ts'; -import { useGetForms, useInstancesFiltersData } from '../hooks'; +import { useGetForms } from '../hooks'; import { parseJson } from '../utils/jsonLogicParse.ts'; import { Popper } from '../../forms/fields/components/Popper.tsx'; @@ -36,15 +36,16 @@ import { OrgUnitTreeviewModal } from '../../orgUnits/components/TreeView/OrgUnit import { useGetOrgUnit } from '../../orgUnits/components/TreeView/requests.ts'; import MESSAGES from '../messages'; -import { InputWithInfos } from '../../../components/InputWithInfos.tsx'; import { AsyncSelect } from '../../../components/forms/AsyncSelect.tsx'; +import { InputWithInfos } from '../../../components/InputWithInfos.tsx'; import { UserOrgUnitRestriction } from '../../../components/UserOrgUnitRestriction.tsx'; import { LocationLimit } from '../../../utils/map/LocationLimit'; +import { useGetOrgUnitTypes } from '../../orgUnits/hooks/requests/useGetOrgUnitTypes'; import { useGetPlanningsOptions } from '../../plannings/hooks/requests/useGetPlannings.ts'; +import { useGetProjectsDropdownOptions } from '../../projects/hooks/requests.ts'; import { getUsersDropDown } from '../hooks/requests/getUsersDropDown.tsx'; import { useGetProfilesDropdown } from '../hooks/useGetProfilesDropdown.tsx'; import { ColumnSelect } from './ColumnSelect.tsx'; -import { useGetProjectsDropdownOptions } from '../../projects/hooks/requests.ts'; export const instanceStatusOptions = INSTANCE_STATUSES.map(status => ({ value: status, @@ -69,7 +70,6 @@ const filterDefault = params => ({ }); const InstancesFiltersComponent = ({ - params: { formIds }, params, onSearch, possibleFields, @@ -87,7 +87,6 @@ const InstancesFiltersComponent = ({ const classes = useStyles(); const [hasLocationLimitError, setHasLocationLimitError] = useState(false); - const [fetchingOrgUnitTypes, setFetchingOrgUnitTypes] = useState(false); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const defaultFilters = useMemo(() => { @@ -120,8 +119,8 @@ const InstancesFiltersComponent = ({ }); setInitialOrgUnitId(params?.levels); }, [defaultFilters]); - - const orgUnitTypes = useSelector(state => state.orgUnits.orgUnitTypes); + const { data: orgUnitTypes, isFetching: isFetchingOuTypes } = + useGetOrgUnitTypes(); const isInstancesFilterUpdated = useSelector( state => state.instances.isInstancesFilterUpdated, ); @@ -139,7 +138,6 @@ const InstancesFiltersComponent = ({ fields, queryBuilderListToReplace, ); - useInstancesFiltersData(formIds, setFetchingOrgUnitTypes); const handleSearch = useCallback(() => { if (isInstancesFilterUpdated) { dispatch(setInstancesFilterUpdated(false)); @@ -262,7 +260,6 @@ const InstancesFiltersComponent = ({ startPeriodError || endPeriodError || hasLocationLimitError; - return (
@@ -393,12 +390,9 @@ const InstancesFiltersComponent = ({ onChange={handleFormChange} value={formState.orgUnitTypeId.value || null} type="select" - options={orgUnitTypes.map(t => ({ - label: t.name, - value: t.id, - }))} + options={orgUnitTypes || []} label={MESSAGES.org_unit_type_id} - loading={fetchingOrgUnitTypes} + loading={isFetchingOuTypes} /> { - const promisesArray = [ - { - fetch: fetchOrgUnitsTypes, - setFetching: setFetchingOrgUnitTypes, - setData: setOrgUnitTypes, - }, - ]; - - useFetchOnMount(promisesArray); -}; - export const useGetForms = () => { const params = { all: true, diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/actions.js b/hat/assets/js/apps/Iaso/domains/orgUnits/actions.js deleted file mode 100644 index b35e8d9d78..0000000000 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/actions.js +++ /dev/null @@ -1,12 +0,0 @@ -export const RESET_ORG_UNITS = 'RESET_ORG_UNITS'; -export const SET_ORG_UNIT_TYPES = 'SET_ORG_UNIT_TYPES'; -export const SET_SOURCES = 'SET_SOURCES'; - -export const resetOrgUnits = () => ({ - type: RESET_ORG_UNITS, -}); - -export const setOrgUnitTypes = orgUnitTypes => ({ - type: SET_ORG_UNIT_TYPES, - payload: orgUnitTypes, -}); diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/details.js b/hat/assets/js/apps/Iaso/domains/orgUnits/details.js index e3dcaed6ed..f82ae61a0e 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/details.js +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/details.js @@ -23,7 +23,6 @@ import { useParamsObject } from '../../routing/hooks/useParamsObject.tsx'; import { fetchAssociatedOrgUnits } from '../../utils/requests'; import { useCheckUserHasWritePermissionOnOrgunit } from '../../utils/usersUtils.ts'; import { FormsTable } from '../forms/components/FormsTable.tsx'; -import { resetOrgUnits } from './actions'; import { OrgUnitForm } from './components/OrgUnitForm.tsx'; import { OrgUnitImages } from './components/OrgUnitImages.tsx'; import { OrgUnitMap } from './components/orgUnitMap/OrgUnitMap/OrgUnitMap.tsx'; @@ -247,12 +246,11 @@ const OrgUnitDetail = () => { const group_ids = mappedRevision.groups.map(g => g.id); mappedRevision.groups = group_ids; saveOu(mappedRevision).then(res => { - dispatch(resetOrgUnits()); refreshOrgUnitQueryCache(res); onSuccess(); }); }, - [currentOrgUnit, dispatch, refreshOrgUnitQueryCache, saveOu], + [currentOrgUnit, refreshOrgUnitQueryCache, saveOu], ); const handleSaveOrgUnit = useCallback( @@ -270,7 +268,6 @@ const OrgUnitDetail = () => { .then(ou => { setCurrentOrgUnit(ou); setOrgUnitLocationModified(false); - dispatch(resetOrgUnits()); if (isNewOrgunit) { redirectToReplace(baseUrl, { ...params, @@ -284,7 +281,6 @@ const OrgUnitDetail = () => { }, [ currentOrgUnit, - dispatch, isNewOrgunit, params, redirectToReplace, diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/reducer.js b/hat/assets/js/apps/Iaso/domains/orgUnits/reducer.js deleted file mode 100644 index b428fd81df..0000000000 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/reducer.js +++ /dev/null @@ -1,33 +0,0 @@ -import { RESET_ORG_UNITS, SET_ORG_UNIT_TYPES, SET_SOURCES } from './actions'; - -export const orgUnitsInitialState = { - orgUnitTypes: [], - sources: null, - orgUnitLevel: [], - filtersUpdated: false, - groups: [], -}; - -export const orgUnitsReducer = (state = orgUnitsInitialState, action = {}) => { - switch (action.type) { - case SET_ORG_UNIT_TYPES: { - const orgUnitTypes = action.payload; - return { ...state, orgUnitTypes }; - } - case SET_SOURCES: { - const sources = action.payload; - return { ...state, sources, orgUnitLevel: [] }; - } - - case RESET_ORG_UNITS: { - return { - ...state, - orgUnitsPage: orgUnitsInitialState.orgUnitsPage, - orgUnitsLocations: orgUnitsInitialState.orgUnitsLocations, - }; - } - - default: - return state; - } -}; diff --git a/hat/assets/js/apps/Iaso/domains/tasks/actions.js b/hat/assets/js/apps/Iaso/domains/tasks/actions.js deleted file mode 100644 index 592d63628f..0000000000 --- a/hat/assets/js/apps/Iaso/domains/tasks/actions.js +++ /dev/null @@ -1,13 +0,0 @@ -import { saveAction } from '../../redux/actions/formsActions'; - -const apiKey = 'tasks'; - -export const killTask = task => dispatch => - saveAction( - dispatch, - task, - apiKey, - 'patchTaskSuccess', - 'patchTaskError', - null, - ).then(res => res); diff --git a/hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js b/hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js index 0cd80caf04..ba4e5d2a05 100644 --- a/hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js +++ b/hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js @@ -1,18 +1,17 @@ -import { expect } from 'chai'; -import React from 'react'; -import nock from 'nock'; -import { ErrorBoundary, theme } from 'bluesquare-components'; import { ThemeProvider } from '@mui/material'; +import { ErrorBoundary, theme } from 'bluesquare-components'; +import { expect } from 'chai'; import { shallow } from 'enzyme'; +import nock from 'nock'; +import React from 'react'; import { - renderWithMutableStore, mockedStore, + renderWithMutableStore, } from '../../../../../test/utils/redux'; import { mockRequest } from '../../../../../test/utils/requests'; -import ProtectedRoute from './ProtectedRoute'; -import { redirectTo as redirectToAction } from '../../../routing/actions.ts'; -import SidebarMenu from '../../app/components/SidebarMenuComponent'; import * as Permission from '../../../utils/permissions.ts'; +import SidebarMenu from '../../app/components/SidebarMenuComponent'; +import ProtectedRoute from './ProtectedRoute'; const cookieStub = require('../../../utils/cookies'); @@ -63,7 +62,6 @@ const unauthorizedDispatchSpy = sinon.spy( storeWithUnauthorizedUser, 'dispatch', ); -const redirectSpy = sinon.spy(redirectToAction); const fakeGetItem = key => { if (key === 'django_language') { @@ -100,7 +98,6 @@ describe('ProtectedRoutes', () => { setCookieSpy.resetHistory(); updatedDispatchSpy.resetHistory(); unauthorizedDispatchSpy.resetHistory(); - redirectSpy.resetHistory(); nock.cleanAll(); nock.abortPendingRequests(); mockRequest('get', '/api/profiles/me', user); diff --git a/hat/assets/js/apps/Iaso/hooks/fetchOnMount.js b/hat/assets/js/apps/Iaso/hooks/fetchOnMount.js deleted file mode 100644 index 445138b00c..0000000000 --- a/hat/assets/js/apps/Iaso/hooks/fetchOnMount.js +++ /dev/null @@ -1,17 +0,0 @@ -import { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; - -const useFetchOnMount = promisesArray => { - const dispatch = useDispatch(); - useEffect(() => { - promisesArray.forEach(p => { - p.setFetching(true); - p.fetch(...[dispatch, ...(p.args ?? [])]).then(data => { - p.setFetching(false); - p.setData && dispatch(p.setData(data)); - }); - }); - }, []); -}; - -export { useFetchOnMount }; diff --git a/hat/assets/js/apps/Iaso/redux/store.js b/hat/assets/js/apps/Iaso/redux/store.js index b794436243..a0530ec113 100644 --- a/hat/assets/js/apps/Iaso/redux/store.js +++ b/hat/assets/js/apps/Iaso/redux/store.js @@ -9,18 +9,12 @@ import { instancesInitialState, instancesReducer, } from '../domains/instances/reducer'; -import { - orgUnitsInitialState, - orgUnitsReducer, -} from '../domains/orgUnits/reducer'; const store = createStore( { - orgUnits: orgUnitsInitialState, instances: instancesInitialState, }, { - orgUnits: orgUnitsReducer, instances: instancesReducer, }, [ diff --git a/hat/assets/js/apps/Iaso/routing/actions.ts b/hat/assets/js/apps/Iaso/routing/actions.ts deleted file mode 100644 index 2cffaa4d0b..0000000000 --- a/hat/assets/js/apps/Iaso/routing/actions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { push, replace } from 'react-router-redux'; -import { createUrl } from 'bluesquare-components'; - -export const redirectTo = - (key: string, params: Record = {}): ((dispatch) => any) => - dispatch => - dispatch(push(`${key}${createUrl(params, '')}`)); - -export const redirectToReplace = - (key: string, params: Record = {}): ((dispatch) => any) => - dispatch => - dispatch(replace(`${key}${createUrl(params, '')}`)); diff --git a/plugins/test/js/index.js b/plugins/test/js/index.js index 9a6b3d2623..e629543f83 100644 --- a/plugins/test/js/index.js +++ b/plugins/test/js/index.js @@ -1,16 +1,15 @@ -import React, { useState, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; import { Box } from '@mui/material'; import { makeStyles } from '@mui/styles'; import { - useSafeIntl, - LoadingSpinner, commonStyles, + LoadingSpinner, Table, + useSafeIntl, } from 'bluesquare-components'; import TopBar from 'Iaso/components/nav/TopBarComponent'; +import React, { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; -import { redirectTo } from 'Iaso/routing/actions'; import { getRequest } from 'Iaso/libs/Api'; import tableColumns from './columns'; import MESSAGES from './messages'; @@ -56,7 +55,7 @@ const TestApp = () => { baseUrl={baseUrl} params={{}} redirectTo={(key, params) => - dispatch(redirectTo(key, params)) + console.log('redirectTo', key, params) } /> From 838c2edc05e1c0aa7496434470f31512199b4204 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Fri, 18 Oct 2024 14:37:36 +0200 Subject: [PATCH 07/60] cleaning up instances reducer --- .../js/apps/Iaso/domains/instances/actions.js | 38 +++---------------- .../js/apps/Iaso/domains/instances/reducer.js | 18 +-------- 2 files changed, 6 insertions(+), 50 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/instances/actions.js b/hat/assets/js/apps/Iaso/domains/instances/actions.js index d355a1b8b5..017478ca70 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/actions.js +++ b/hat/assets/js/apps/Iaso/domains/instances/actions.js @@ -1,9 +1,8 @@ -import { getRequest, postRequest, putRequest } from 'Iaso/libs/Api.ts'; +import { postRequest, putRequest } from 'Iaso/libs/Api.ts'; import { openSnackBar } from '../../components/snackBars/EventDispatcher.ts'; import { errorSnackBar, succesfullSnackBar } from '../../constants/snackBars'; export const SET_INSTANCES_FETCHING = 'SET_INSTANCES_FETCHING'; -export const SET_CURRENT_INSTANCE = 'SET_CURRENT_INSTANCE'; export const SET_INSTANCES_FILTER_UDPATED = 'SET_INSTANCES_FILTER_UDPATED'; export const setInstancesFilterUpdated = isUpdated => ({ @@ -11,26 +10,6 @@ export const setInstancesFilterUpdated = isUpdated => ({ payload: isUpdated, }); -export const setInstancesFetching = isFetching => ({ - type: SET_INSTANCES_FETCHING, - payload: isFetching, -}); - -export const fetchEditUrl = (currentInstance, location) => dispatch => { - dispatch(setInstancesFetching(true)); - const url = `/api/enketo/edit/${currentInstance.uuid}?return_url=${location}`; - return getRequest(url) - .then(resp => { - window.location.href = resp.edit_url; - }) - .catch(err => { - openSnackBar(errorSnackBar('fetchEnketoError', null, err)); - }) - .then(() => { - dispatch(setInstancesFetching(false)); - }); -}; - /* Submission Creation workflow * 1. this function call backend create Instance in DB * 2. backend contact enketo to generate a Form page @@ -39,8 +18,7 @@ export const fetchEditUrl = (currentInstance, location) => dispatch => { * 5. After submission Enketo/Backend redirect to the submission detail page * See enketo/README.md for full details. */ -export const createInstance = (currentForm, payload) => dispatch => { - dispatch(setInstancesFetching(true)); +export const createInstance = (currentForm, payload) => () => { // if (!payload.period) delete payload.period; return postRequest('/api/enketo/create/', { org_unit_id: payload.org_unit, @@ -53,13 +31,11 @@ export const createInstance = (currentForm, payload) => dispatch => { }, err => { openSnackBar(errorSnackBar(null, 'Enketo', err)); - dispatch(setInstancesFetching(false)); }, ); }; -export const createExportRequest = (filterParams, selection) => dispatch => { - dispatch(setInstancesFetching(true)); +export const createExportRequest = (filterParams, selection) => () => { const filters = { ...filterParams, }; @@ -83,13 +59,11 @@ export const createExportRequest = (filterParams, selection) => dispatch => { ? `createExportRequestError${err.details.code}` : 'createExportRequestError'; openSnackBar(errorSnackBar(key, null, err)); - }) - .then(() => dispatch(setInstancesFetching(false))); + }); }; export const bulkDelete = - (selection, filters, isUnDeleteAction, successFn) => dispatch => { - dispatch(setInstancesFetching(true)); + (selection, filters, isUnDeleteAction, successFn) => () => { return postRequest('/api/instances/bulkdelete/', { select_all: selection.selectAll, selected_ids: selection.selectedItems.map(i => i.id), @@ -102,13 +76,11 @@ export const bulkDelete = succesfullSnackBar('saveMultiEditOrgUnitsSuccesfull'), ); successFn(); - dispatch(setInstancesFetching(false)); return res; }) .catch(error => { openSnackBar( errorSnackBar('saveMultiEditOrgUnitsError', null, error), ); - dispatch(setInstancesFetching(false)); }); }; diff --git a/hat/assets/js/apps/Iaso/domains/instances/reducer.js b/hat/assets/js/apps/Iaso/domains/instances/reducer.js index 642bd3e243..a90d1e7219 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/reducer.js +++ b/hat/assets/js/apps/Iaso/domains/instances/reducer.js @@ -1,12 +1,6 @@ -import { - SET_INSTANCES_FETCHING, - SET_CURRENT_INSTANCE, - SET_INSTANCES_FILTER_UDPATED, -} from './actions'; +import { SET_INSTANCES_FILTER_UDPATED } from './actions'; export const instancesInitialState = { - fetching: true, - current: null, isInstancesFilterUpdated: false, }; @@ -15,16 +9,6 @@ export const instancesReducer = ( action = {}, ) => { switch (action.type) { - case SET_INSTANCES_FETCHING: { - const fetching = action.payload; - return { ...state, fetching }; - } - - case SET_CURRENT_INSTANCE: { - const current = action.payload; - return { ...state, current }; - } - case SET_INSTANCES_FILTER_UDPATED: { const isInstancesFilterUpdated = action.payload; return { ...state, isInstancesFilterUpdated }; From 90bd3c4bb6d1a770ff053ab35aa7e4afb43ffc72 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Fri, 18 Oct 2024 15:10:13 +0200 Subject: [PATCH 08/60] no more redux --- .../Iaso/domains/completenessStats/index.tsx | 4 +- .../domains/forms/components/FormActions.tsx | 27 +- .../js/apps/Iaso/domains/instances/actions.js | 49 ++-- .../Iaso/domains/instances/actions.spec.js | 37 --- .../components/DeleteInstanceDialog.js | 26 +- .../ExportInstancesDialogComponent.js | 35 +-- .../components/InstancesFiltersComponent.js | 18 +- .../components/InstancesMap/InstancesMap.tsx | 13 +- .../components/InstancesMap/useShowWarning.ts | 4 +- .../instances/hooks/speedDialActions.tsx | 6 +- .../js/apps/Iaso/domains/instances/index.js | 14 +- .../js/apps/Iaso/domains/instances/reducer.js | 20 -- .../js/apps/Iaso/domains/orgUnits/details.js | 13 +- hat/assets/js/apps/Iaso/index.tsx | 41 ++- .../apps/Iaso/redux/actions/formsActions.js | 268 ------------------ hat/assets/js/apps/Iaso/redux/createStore.js | 34 --- hat/assets/js/apps/Iaso/redux/store.js | 28 -- .../js/apps/Iaso/redux/useInjectedStore.ts | 58 ---- hat/assets/js/test/utils/redux.js | 6 +- hat/webpack.dev.js | 1 - .../Calendar/campaignCalendar/types.ts | 4 - 21 files changed, 84 insertions(+), 622 deletions(-) delete mode 100644 hat/assets/js/apps/Iaso/domains/instances/actions.spec.js delete mode 100644 hat/assets/js/apps/Iaso/domains/instances/reducer.js delete mode 100644 hat/assets/js/apps/Iaso/redux/actions/formsActions.js delete mode 100644 hat/assets/js/apps/Iaso/redux/createStore.js delete mode 100644 hat/assets/js/apps/Iaso/redux/store.js delete mode 100644 hat/assets/js/apps/Iaso/redux/useInjectedStore.ts diff --git a/hat/assets/js/apps/Iaso/domains/completenessStats/index.tsx b/hat/assets/js/apps/Iaso/domains/completenessStats/index.tsx index e07a3307e7..4598dd644d 100644 --- a/hat/assets/js/apps/Iaso/domains/completenessStats/index.tsx +++ b/hat/assets/js/apps/Iaso/domains/completenessStats/index.tsx @@ -14,7 +14,6 @@ import React, { useMemo, useState, } from 'react'; -import { useDispatch } from 'react-redux'; import { CsvButton } from '../../components/Buttons/CsvButton'; import TopBar from '../../components/nav/TopBarComponent'; import { openSnackBar } from '../../components/snackBars/EventDispatcher'; @@ -59,7 +58,6 @@ export const CompletenessStats: FunctionComponent = () => { ) as CompletenessRouterParams; const [tab, setTab] = useState<'list' | 'map'>(params.tab ?? 'list'); - const dispatch = useDispatch(); const redirectTo = useRedirectTo(); const { formatMessage } = useSafeIntl(); const { data: completenessStats, isFetching } = @@ -90,7 +88,7 @@ export const CompletenessStats: FunctionComponent = () => { closeSnackbar(snackbarKey); } }; - }, [dispatch, displayWarning]); + }, [displayWarning]); const csvUrl = useMemo( () => `/api/v2/completeness_stats.csv?${buildQueryString(params, true)}`, diff --git a/hat/assets/js/apps/Iaso/domains/forms/components/FormActions.tsx b/hat/assets/js/apps/Iaso/domains/forms/components/FormActions.tsx index 1148dcf728..03af452f48 100644 --- a/hat/assets/js/apps/Iaso/domains/forms/components/FormActions.tsx +++ b/hat/assets/js/apps/Iaso/domains/forms/components/FormActions.tsx @@ -1,18 +1,17 @@ -import React, { FunctionComponent, useState } from 'react'; -import { IconButton } from 'bluesquare-components'; +import { Download } from '@mui/icons-material'; +import FormatListBulleted from '@mui/icons-material/FormatListBulleted'; import { Menu, MenuItem } from '@mui/material'; +import { IconButton } from 'bluesquare-components'; +import React, { FunctionComponent, useState } from 'react'; import { Link } from 'react-router-dom'; -import FormatListBulleted from '@mui/icons-material/FormatListBulleted'; -import { useDispatch } from 'react-redux'; -import { Download } from '@mui/icons-material'; +import DeleteDialog from '../../../components/dialogs/DeleteDialogComponent'; import { DisplayIfUserHasPerm } from '../../../components/DisplayIfUserHasPerm'; -import { CreateSubmissionModal } from './CreateSubmissionModal/CreateSubmissionModal'; import * as Permission from '../../../utils/permissions'; -import DeleteDialog from '../../../components/dialogs/DeleteDialogComponent'; -import MESSAGES from '../messages'; -import { useRestoreForm } from '../hooks/useRestoreForm'; import { createInstance } from '../../instances/actions'; import { useDeleteForm } from '../hooks/useDeleteForm'; +import { useRestoreForm } from '../hooks/useRestoreForm'; +import MESSAGES from '../messages'; +import { CreateSubmissionModal } from './CreateSubmissionModal/CreateSubmissionModal'; type Props = { settings: any; @@ -27,7 +26,6 @@ export const FormActions: FunctionComponent = ({ baseUrls, showDeleted, }) => { - const dispatch = useDispatch(); // XLS and XML download states and functions const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -91,14 +89,7 @@ export const FormActions: FunctionComponent = ({ onCreateOrReAssign={( currentForm, payload, - ) => - dispatch( - createInstance( - currentForm, - payload, - ), - ) - } + ) => createInstance(currentForm, payload)} orgUnitTypes={ settings.row.original.org_unit_type_ids } diff --git a/hat/assets/js/apps/Iaso/domains/instances/actions.js b/hat/assets/js/apps/Iaso/domains/instances/actions.js index 017478ca70..8b0317f772 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/actions.js +++ b/hat/assets/js/apps/Iaso/domains/instances/actions.js @@ -2,14 +2,6 @@ import { postRequest, putRequest } from 'Iaso/libs/Api.ts'; import { openSnackBar } from '../../components/snackBars/EventDispatcher.ts'; import { errorSnackBar, succesfullSnackBar } from '../../constants/snackBars'; -export const SET_INSTANCES_FETCHING = 'SET_INSTANCES_FETCHING'; -export const SET_INSTANCES_FILTER_UDPATED = 'SET_INSTANCES_FILTER_UDPATED'; - -export const setInstancesFilterUpdated = isUpdated => ({ - type: SET_INSTANCES_FILTER_UDPATED, - payload: isUpdated, -}); - /* Submission Creation workflow * 1. this function call backend create Instance in DB * 2. backend contact enketo to generate a Form page @@ -18,7 +10,7 @@ export const setInstancesFilterUpdated = isUpdated => ({ * 5. After submission Enketo/Backend redirect to the submission detail page * See enketo/README.md for full details. */ -export const createInstance = (currentForm, payload) => () => { +export const createInstance = (currentForm, payload) => { // if (!payload.period) delete payload.period; return postRequest('/api/enketo/create/', { org_unit_id: payload.org_unit, @@ -62,25 +54,22 @@ export const createExportRequest = (filterParams, selection) => () => { }); }; -export const bulkDelete = - (selection, filters, isUnDeleteAction, successFn) => () => { - return postRequest('/api/instances/bulkdelete/', { - select_all: selection.selectAll, - selected_ids: selection.selectedItems.map(i => i.id), - unselected_ids: selection.unSelectedItems.map(i => i.id), - is_deletion: !isUnDeleteAction, - ...filters, +export const bulkDelete = (selection, filters, isUnDeleteAction, successFn) => { + return postRequest('/api/instances/bulkdelete/', { + select_all: selection.selectAll, + selected_ids: selection.selectedItems.map(i => i.id), + unselected_ids: selection.unSelectedItems.map(i => i.id), + is_deletion: !isUnDeleteAction, + ...filters, + }) + .then(res => { + openSnackBar(succesfullSnackBar('saveMultiEditOrgUnitsSuccesfull')); + successFn(); + return res; }) - .then(res => { - openSnackBar( - succesfullSnackBar('saveMultiEditOrgUnitsSuccesfull'), - ); - successFn(); - return res; - }) - .catch(error => { - openSnackBar( - errorSnackBar('saveMultiEditOrgUnitsError', null, error), - ); - }); - }; + .catch(error => { + openSnackBar( + errorSnackBar('saveMultiEditOrgUnitsError', null, error), + ); + }); +}; diff --git a/hat/assets/js/apps/Iaso/domains/instances/actions.spec.js b/hat/assets/js/apps/Iaso/domains/instances/actions.spec.js deleted file mode 100644 index 1909b7241c..0000000000 --- a/hat/assets/js/apps/Iaso/domains/instances/actions.spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { - SET_INSTANCES_FILTER_UDPATED, - setInstancesFilterUpdated, -} from './actions'; - -// const Api = require('iaso/libs/Api'); -// const snackBars = require('../../constants/snackBars'); - -// const formsActions = require('../../../redux/actions/formsActions'); - -// let actionStub; -describe('Instances actions', () => { - it('should create an action to set instance filter update', () => { - const payload = false; - const expectedAction = { - type: SET_INSTANCES_FILTER_UDPATED, - payload, - }; - const action = setInstancesFilterUpdated(payload); - expect(action).to.eql(expectedAction); - }); - // it('should call getRequest on fetchEditUrl', () => { - // const resp = { - // edit_url: 'https://www.nintendo.be/', - // }; - // actionStub = sinon - // .stub(Api, 'getRequest') - // .returns(new Promise(resolve => resolve(resp))); - // fetchEditUrl({ - // uuid: 'KOKIRI', - // })(fn => fn); - // expect(actionStub.calledOnce).to.equal(true); - // }); - afterEach(() => { - sinon.restore(); - }); -}); diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/DeleteInstanceDialog.js b/hat/assets/js/apps/Iaso/domains/instances/components/DeleteInstanceDialog.js index 5c1fa3fe18..b6bce64284 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/DeleteInstanceDialog.js +++ b/hat/assets/js/apps/Iaso/domains/instances/components/DeleteInstanceDialog.js @@ -1,11 +1,10 @@ -import React, { useState } from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; -import { useDispatch } from 'react-redux'; -import { DialogContentText } from '@mui/material'; -import { makeStyles } from '@mui/styles'; import DeleteIcon from '@mui/icons-material/Delete'; import RestoreFromTrashIcon from '@mui/icons-material/RestoreFromTrash'; +import { DialogContentText } from '@mui/material'; +import { makeStyles } from '@mui/styles'; +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; import { bulkDelete } from '../actions'; import ConfirmCancelDialogComponent from '../../../components/dialogs/ConfirmCancelDialogComponent'; @@ -27,18 +26,15 @@ const DeleteInstanceDialog = ({ isUnDeleteAction, }) => { const classes = useStyles(); - const dispatch = useDispatch(); const [allowConfirm, setAllowConfirm] = useState(true); const onConfirm = closeDialog => { setAllowConfirm(false); - dispatch( - bulkDelete(selection, filters, isUnDeleteAction, () => { - closeDialog(); - resetSelection(); - setForceRefresh(); - setAllowConfirm(false); - }), - ); + bulkDelete(selection, filters, isUnDeleteAction, () => { + closeDialog(); + resetSelection(); + setForceRefresh(); + setAllowConfirm(false); + }); }; const renderTrigger = ({ openDialog }) => { diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/ExportInstancesDialogComponent.js b/hat/assets/js/apps/Iaso/domains/instances/components/ExportInstancesDialogComponent.js index 2935b413ed..5ff6f271d4 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/ExportInstancesDialogComponent.js +++ b/hat/assets/js/apps/Iaso/domains/instances/components/ExportInstancesDialogComponent.js @@ -1,19 +1,15 @@ -import React from 'react'; -import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; +import React from 'react'; import { injectIntl } from 'bluesquare-components'; -import InputComponent from '../../../components/forms/InputComponent'; import ConfirmCancelDialogComponent from '../../../components/dialogs/ConfirmCancelDialogComponent'; -import { createExportRequest as createExportRequestAction } from '../actions'; +import InputComponent from '../../../components/forms/InputComponent'; +import { createExportRequest } from '../actions'; import MESSAGES from '../messages'; const ExportInstancesDialogComponent = ({ - isInstancesFilterUpdated, getFilters, - createExportRequest, renderTrigger, selection, }) => { @@ -38,9 +34,7 @@ const ExportInstancesDialogComponent = ({ } return ( - renderTrigger(openDialog, isInstancesFilterUpdated) - } + renderTrigger={({ openDialog }) => renderTrigger(openDialog)} titleMessage={title} onConfirm={onConfirm} confirmMessage={MESSAGES.export} @@ -65,28 +59,9 @@ ExportInstancesDialogComponent.defaultProps = { }; ExportInstancesDialogComponent.propTypes = { - isInstancesFilterUpdated: PropTypes.bool.isRequired, getFilters: PropTypes.func.isRequired, - createExportRequest: PropTypes.func.isRequired, renderTrigger: PropTypes.func.isRequired, selection: PropTypes.object, }; -const MapStateToProps = state => ({ - isInstancesFilterUpdated: state.instances.isInstancesFilterUpdated, -}); - -const MapDispatchToProps = dispatch => ({ - dispatch, - ...bindActionCreators( - { - createExportRequest: createExportRequestAction, - }, - dispatch, - ), -}); - -export default connect( - MapStateToProps, - MapDispatchToProps, -)(injectIntl(ExportInstancesDialogComponent)); +export default injectIntl(ExportInstancesDialogComponent); diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js index 62638e462f..af1d0a4d2a 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js +++ b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js @@ -1,6 +1,5 @@ import PropTypes from 'prop-types'; import React, { useCallback, useMemo, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { Box, Button, Grid, Typography } from '@mui/material'; import { makeStyles } from '@mui/styles'; @@ -21,7 +20,6 @@ import { periodTypeOptions } from '../../periods/constants'; import { Period } from '../../periods/models.ts'; import { isValidPeriod } from '../../periods/utils'; -import { setInstancesFilterUpdated } from '../actions'; import { INSTANCE_STATUSES } from '../constants'; import { getInstancesFilterValues, useFormState } from '../../../hooks/form'; @@ -81,8 +79,9 @@ const InstancesFiltersComponent = ({ formDetails, tableColumns, tab, + isInstancesFilterUpdated, + setIsInstancesFilterUpdated, }) => { - const dispatch = useDispatch(); const { formatMessage } = useSafeIntl(); const classes = useStyles(); @@ -121,9 +120,6 @@ const InstancesFiltersComponent = ({ }, [defaultFilters]); const { data: orgUnitTypes, isFetching: isFetchingOuTypes } = useGetOrgUnitTypes(); - const isInstancesFilterUpdated = useSelector( - state => state.instances.isInstancesFilterUpdated, - ); const { data, isFetching: fetchingForms } = useGetForms(); const formsList = useMemo(() => data?.forms ?? [], [data]); const formId = @@ -140,7 +136,7 @@ const InstancesFiltersComponent = ({ ); const handleSearch = useCallback(() => { if (isInstancesFilterUpdated) { - dispatch(setInstancesFilterUpdated(false)); + setIsInstancesFilterUpdated(false); const searchParams = { ...params, ...getInstancesFilterValues(formState), @@ -165,7 +161,7 @@ const InstancesFiltersComponent = ({ } }, [ isInstancesFilterUpdated, - dispatch, + setIsInstancesFilterUpdated, params, formState, onSearch, @@ -190,9 +186,9 @@ const InstancesFiltersComponent = ({ if (key === 'levels') { setInitialOrgUnitId(value); } - dispatch(setInstancesFilterUpdated(true)); + setIsInstancesFilterUpdated(true); }, - [dispatch, setFormState, setFormIds], + [setFormState, setFormIds, setIsInstancesFilterUpdated], ); const startPeriodError = useMemo(() => { @@ -601,6 +597,8 @@ InstancesFiltersComponent.propTypes = { periodType: PropTypes.string, possibleFields: PropTypes.array, formDetails: PropTypes.object, + setIsInstancesFilterUpdated: PropTypes.func.isRequired, + isInstancesFilterUpdated: PropTypes.bool.isRequired, }; export default InstancesFiltersComponent; diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/InstancesMap.tsx b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/InstancesMap.tsx index d214a25b97..99b8570508 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/InstancesMap.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/InstancesMap.tsx @@ -5,8 +5,6 @@ import React, { FunctionComponent, useMemo, useState } from 'react'; import { MapContainer, ScaleControl } from 'react-leaflet'; import MarkerClusterGroup from 'react-leaflet-markercluster'; -import { useSelector } from 'react-redux'; - import { clusterCustomMarker, defaultCenter, @@ -32,11 +30,6 @@ type Props = { fetching: boolean; }; -type PartialReduxState = { - map: { isClusterActive: boolean; currentTile: Tile }; - snackBar: { notifications: any[] }; -}; - const useStyles = makeStyles(theme => ({ root: { ...commonStyles(theme).mapContainer, @@ -51,10 +44,8 @@ export const InstancesMap: FunctionComponent = ({ const [isClusterActive, setIsClusterActive] = useState(true); const [currentTile, setCurrentTile] = useState(tiles.osm); - const notifications = useSelector((state: PartialReduxState) => - state.snackBar ? state.snackBar.notifications : [], - ); - useShowWarning({ instances, notifications, fetching }); + // This should be fixed, notification reducer as been removed a while ago + useShowWarning({ instances, notifications: [], fetching }); const bounds = useMemo(() => { if (instances) { diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/useShowWarning.ts b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/useShowWarning.ts index 19942b8115..a6907f4144 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/useShowWarning.ts +++ b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesMap/useShowWarning.ts @@ -1,6 +1,5 @@ import { closeSnackbar } from 'notistack'; import { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; import { openSnackBar } from '../../../../components/snackBars/EventDispatcher'; import { warningSnackBar } from '../../../../constants/snackBars'; import { getLatLngBounds } from '../../../../utils/map/mapUtils'; @@ -17,7 +16,6 @@ export const useShowWarning = ({ notifications, fetching, }: SetWarningParams): void => { - const dispatch = useDispatch(); const bounds = getLatLngBounds(instances); const isWarningDisplayed = notifications.find(n => n.id === snackbarKey); const shouldShowWarning = @@ -33,5 +31,5 @@ export const useShowWarning = ({ closeSnackbar(snackbarKey); } }; - }, [bounds, dispatch, isWarningDisplayed, shouldShowWarning]); + }, [bounds, isWarningDisplayed, shouldShowWarning]); }; diff --git a/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx b/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx index 27223c7127..5f123798ab 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx +++ b/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx @@ -43,13 +43,9 @@ export const useBaseActions = ( icon: ( ( + renderTrigger={openDialog => ( )} diff --git a/hat/assets/js/apps/Iaso/domains/instances/index.js b/hat/assets/js/apps/Iaso/domains/instances/index.js index 49e8bb613d..bf28c913a4 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/index.js +++ b/hat/assets/js/apps/Iaso/domains/instances/index.js @@ -10,7 +10,6 @@ import { } from 'bluesquare-components'; import React, { useCallback, useMemo, useState } from 'react'; import { useQueryClient } from 'react-query'; -import { useDispatch } from 'react-redux'; import { DisplayIfUserHasPerm } from '../../components/DisplayIfUserHasPerm.tsx'; import DownloadButtonsComponent from '../../components/DownloadButtonsComponent.tsx'; import snackMessages from '../../components/snackBars/messages'; @@ -54,10 +53,10 @@ const Instances = () => { const params = useParamsObject(baseUrl); const classes = useStyles(); const { formatMessage } = useSafeIntl(); - const dispatch = useDispatch(); const queryClient = useQueryClient(); const redirectToReplace = useRedirectToReplace(); - + const [isInstancesFilterUpdated, setIsInstancesFilterUpdated] = + useState(false); const [selection, setSelection] = useState(selectionInitialState); const [tableColumns, setTableColumns] = useState([]); const [tab, setTab] = useState(params.tab ?? 'list'); @@ -171,6 +170,8 @@ const Instances = () => { formDetails={formDetails} tableColumns={tableColumns} tab={tab} + setIsInstancesFilterUpdated={setIsInstancesFilterUpdated} + isInstancesFilterUpdated={isInstancesFilterUpdated} /> {tab === 'list' && isSingleFormSearch && ( @@ -199,12 +200,7 @@ const Instances = () => { currentForm, payload, ) => - dispatch( - createInstance( - currentForm, - payload, - ), - ) + createInstance(currentForm, payload) } /> diff --git a/hat/assets/js/apps/Iaso/domains/instances/reducer.js b/hat/assets/js/apps/Iaso/domains/instances/reducer.js deleted file mode 100644 index a90d1e7219..0000000000 --- a/hat/assets/js/apps/Iaso/domains/instances/reducer.js +++ /dev/null @@ -1,20 +0,0 @@ -import { SET_INSTANCES_FILTER_UDPATED } from './actions'; - -export const instancesInitialState = { - isInstancesFilterUpdated: false, -}; - -export const instancesReducer = ( - state = instancesInitialState, - action = {}, -) => { - switch (action.type) { - case SET_INSTANCES_FILTER_UDPATED: { - const isInstancesFilterUpdated = action.payload; - return { ...state, isInstancesFilterUpdated }; - } - - default: - return state; - } -}; diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/details.js b/hat/assets/js/apps/Iaso/domains/orgUnits/details.js index f82ae61a0e..f9875f62a5 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/details.js +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/details.js @@ -11,7 +11,6 @@ import { import omit from 'lodash/omit'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useQueryClient } from 'react-query'; -import { useDispatch } from 'react-redux'; import TopBar from '../../components/nav/TopBarComponent'; import { FORMS_PREFIX, @@ -104,7 +103,6 @@ const OrgUnitDetail = () => { const classes = useStyles(); const params = useParamsObject(baseUrl); const goBack = useGoBack(baseUrls.orgUnits); - const dispatch = useDispatch(); const { mutateAsync: saveOu, isLoading: savingOu } = useSaveOrgUnit(); const queryClient = useQueryClient(); const { formatMessage } = useSafeIntl(); @@ -323,7 +321,7 @@ const OrgUnitDetail = () => { } } } - }, [originalOrgUnit, dispatch, isNewOrgunit, params, redirectToReplace]); + }, [originalOrgUnit, isNewOrgunit, params, redirectToReplace]); // Set selected sources for current org unit useEffect(() => { @@ -355,14 +353,7 @@ const OrgUnitDetail = () => { fetch(); } } - }, [ - originalOrgUnit, - dispatch, - links, - sources, - isNewOrgunit, - sourcesSelected, - ]); + }, [originalOrgUnit, links, sources, isNewOrgunit, sourcesSelected]); return (
diff --git a/hat/assets/js/apps/Iaso/index.tsx b/hat/assets/js/apps/Iaso/index.tsx index 36adc76738..5f12e5fedd 100644 --- a/hat/assets/js/apps/Iaso/index.tsx +++ b/hat/assets/js/apps/Iaso/index.tsx @@ -4,7 +4,6 @@ import { theme } from 'bluesquare-components'; import React from 'react'; import ReactDOM from 'react-dom'; import { QueryClient, QueryClientProvider } from 'react-query'; -import { Provider } from 'react-redux'; import './libs/polyfills'; @@ -19,7 +18,6 @@ import { } from './domains/app/contexts/ThemeConfigContext'; import App from './domains/app/index'; import { Plugin } from './domains/app/types'; -import { store } from './redux/store.js'; import { getGlobalOverrides, getOverriddenTheme } from './styles'; import { PluginsContext, getPlugins } from './utils'; @@ -56,27 +54,24 @@ const iasoApp = (element, enabledPluginsName, themeConfig, userHomePage) => { - - - - - - - - - + + + + + + + diff --git a/hat/assets/js/apps/Iaso/redux/actions/formsActions.js b/hat/assets/js/apps/Iaso/redux/actions/formsActions.js deleted file mode 100644 index a17b41d0a2..0000000000 --- a/hat/assets/js/apps/Iaso/redux/actions/formsActions.js +++ /dev/null @@ -1,268 +0,0 @@ -import { - deleteRequest, - getRequest, - patchRequest, - postRequest, - putRequest, -} from 'Iaso/libs/Api.ts'; -import { openSnackBar } from '../../components/snackBars/EventDispatcher.ts'; -import { errorSnackBar, succesfullSnackBar } from '../../constants/snackBars'; - -/** - * Fetch action to get a list of items - * @param {Function} dispatch Redux function to trigger an action - * @param {String} apiPath The endpoint path used - * @param {Function} setAction Set action to put the list in redux - * @param {String} errorKeyMessage The key of the error message used by the snackbar - * @param {String} resultKey The key of the list returned by the api ({ groups: [...], ...}) - * @param {Object} params Url params used for the pagination - * @param {Function} setIsLoading The loading action to display the loading state - */ -export const fetchAction = ( - dispatch, - apiPath, - setAction, - errorKeyMessage, - resultKey = null, - params = null, - setIsLoading = null, -) => { - let url = `/api/${apiPath}/`; - if (params) { - url += `?order=${params.order}&limit=${params.pageSize}&page=${params.page}`; - if (params.search) { - url += `&search=${params.search}`; - } - - if (setIsLoading !== null) { - dispatch(setIsLoading(true)); - } - } - return getRequest(url) - .then(res => { - const result = resultKey ? res[resultKey] : res; - return dispatch( - setAction( - result, - params - ? { count: res.count, pages: res.pages } - : { count: result.length, pages: 1 }, - ), - ); - }) - .catch(err => openSnackBar(errorSnackBar(errorKeyMessage, null, err))) - .then(() => { - if (params && setIsLoading !== null) { - dispatch(setIsLoading(false)); - } - }); -}; - -/** - * Fetch action to get a list of items - * @param {Function} dispatch Redux function to trigger an action - * @param {String} apiPath The endpoint path used - * @param {Number|String} itemId The resource id (or a string in very specific cases, such as "me") - * @param {Function} setAction Set action to put the list in redux - * @param {String} errorKeyMessage The key of the error message used by the snackbar - * @param {Function} setIsLoading The loading action to display the loading state - */ -export const retrieveAction = ( - dispatch, - apiPath, - itemId, - setAction, - errorKeyMessage, - setIsLoading = null, -) => { - const url = `/api/${apiPath}/${itemId}/`; - if (setIsLoading !== null) { - dispatch(setIsLoading(true)); - } - - return getRequest(url) - .then(res => dispatch(setAction(res))) - .catch(err => openSnackBar(errorSnackBar(errorKeyMessage, null, err))) - .then(() => { - if (setIsLoading !== null) { - dispatch(setIsLoading(false)); - } - }); -}; - -/** - * Save action to update one item - * @param {Function} dispatch Redux function to trigger an action - * @param {Object} item The item to save - * @param {String} apiPath The endpoint path used - * @param {String} successKeyMessage The key of the success message used by the snackbar - * @param {String} errorKeyMessage The key of the error message used by the snackbar - * @param {Function} setIsLoading The loading action to display the loading state - * @param {Array} ignoredErrorCodes array of status error code to ignore while displaying snackbars - */ - -export const updateAction = ( - dispatch, - item, - apiPath, - successKeyMessage, - errorKeyMessage, - setIsLoading = null, - ignoredErrorCodes, -) => { - if (setIsLoading !== null) { - dispatch(setIsLoading(true)); - } - return putRequest(`/api/${apiPath}/${item.id}/`, item) - .then(res => { - openSnackBar(succesfullSnackBar(successKeyMessage)); - return res; - }) - .catch(err => { - if ( - !ignoredErrorCodes || - (ignoredErrorCodes && !ignoredErrorCodes.includes(err.status)) - ) { - openSnackBar(errorSnackBar(errorKeyMessage, null, err)); - } - throw err; - }) - .finally(() => { - if (setIsLoading !== null) { - dispatch(setIsLoading(false)); - } - }); -}; - -export const saveAction = ( - dispatch, - item, - apiPath, - successKeyMessage, - errorKeyMessage, - setIsLoading = null, - ignoredErrorCodes, - setResultFunction = null, -) => { - if (setIsLoading !== null) { - dispatch(setIsLoading(true)); - } - return patchRequest(`/api/${apiPath}/${item.id}/`, item) - .then(res => { - openSnackBar(succesfullSnackBar(successKeyMessage)); - return res; - }) - .then(res => { - if (setResultFunction) { - dispatch(setResultFunction(res)); - } - return res; - }) - - .catch(err => { - if ( - !ignoredErrorCodes || - (ignoredErrorCodes && !ignoredErrorCodes.includes(err.status)) - ) { - openSnackBar(errorSnackBar(errorKeyMessage, null, err)); - } - throw err; - }) - .finally(() => { - if (setIsLoading !== null) { - dispatch(setIsLoading(false)); - } - }); -}; - -/** - * Save action to create one item - * @param {Function} dispatch Redux function to trigger an action - * @param {Object} item The item to create - * @param {String} apiPath The endpoint path used - * @param {String} successKeyMessage The key of the success message used by the snackbar - * @param {String} errorKeyMessage The key of the error message used by the snackbar - * @param {Function} setIsLoading The loading action to display the loading state - * @param {Array} ignoredErrorCodes array of status error code to ignore while displaying snackbars - */ -export const createAction = ( - dispatch, - item, - apiPath, - successKeyMessage, - errorKeyMessage, - setIsLoading = null, - ignoredErrorCodes, -) => { - if (setIsLoading !== null) { - dispatch(setIsLoading(true)); - } - return postRequest(`/api/${apiPath}/`, item) - .then(res => { - openSnackBar(succesfullSnackBar(successKeyMessage)); - return res; - }) - .catch(err => { - if ( - !ignoredErrorCodes || - (ignoredErrorCodes && !ignoredErrorCodes.includes(err.status)) - ) { - openSnackBar(errorSnackBar(errorKeyMessage, null, err)); - } - throw err; - }) - .finally(() => { - if (setIsLoading !== null) { - dispatch(setIsLoading(false)); - } - }); -}; - -/** - * Delete action to delete one item - * @param {Function} dispatch Redux function to trigger an action - * @param {Object} item The item to delete - * @param {String} apiPath The endpoint path used - * @param {Function} setAction Set action to put the list in redux - * @param {String} successKeyMessage The key of the success message used by the snackbar - * @param {String} errorKeyMessage The key of the error message used by the snackbar - * @param {String} resultKey The key of the list returned by the api ({ groups: [...], ...}) - * @param {Object} params Url params used for the pagination - * @param {Function} setIsLoading The loading action to display the loading state - */ -export const deleteAction = ( - dispatch, - item, - apiPath, - setAction, - successKeyMessage, - errorKeyMessage, - resultKey = null, - params = null, - setIsLoading = null, -) => { - if (setIsLoading !== null) { - dispatch(setIsLoading(true)); - } - return deleteRequest(`/api/${apiPath}/${item.id}/`) - .then(res => { - openSnackBar(succesfullSnackBar(successKeyMessage)); - fetchAction( - dispatch, - apiPath, - setAction, - errorKeyMessage, - resultKey, - params, - setIsLoading, - ); - return res; - }) - .catch(err => { - openSnackBar(errorSnackBar(errorKeyMessage, null, err)); - if (setIsLoading !== null) { - dispatch(setIsLoading(false)); - } - throw err; - }); -}; diff --git a/hat/assets/js/apps/Iaso/redux/createStore.js b/hat/assets/js/apps/Iaso/redux/createStore.js deleted file mode 100644 index d3b54fc48f..0000000000 --- a/hat/assets/js/apps/Iaso/redux/createStore.js +++ /dev/null @@ -1,34 +0,0 @@ -import { - combineReducers, - createStore as _createStore, - applyMiddleware, - compose, -} from 'redux'; -import { routerReducer } from 'react-router-redux'; - -const createReducer = (appReducers, pluginReducers) => { - return combineReducers({ - routing: routerReducer, - ...appReducers, - ...pluginReducers, - }); -}; - -export default (initialState = {}, reducers = {}, middleWare = []) => { - const store = _createStore( - createReducer(reducers), - initialState, - compose( - applyMiddleware(...middleWare), - window.__REDUX_DEVTOOLS_EXTENSION__ - ? window.__REDUX_DEVTOOLS_EXTENSION__() - : f => f, - ), - ); - store.pluginReducers = {}; - store.injectReducer = (key, pluginReducer) => { - store.pluginReducers[key] = pluginReducer; - store.replaceReducer(createReducer(reducers, store.pluginReducers)); - }; - return store; -}; diff --git a/hat/assets/js/apps/Iaso/redux/store.js b/hat/assets/js/apps/Iaso/redux/store.js deleted file mode 100644 index a0530ec113..0000000000 --- a/hat/assets/js/apps/Iaso/redux/store.js +++ /dev/null @@ -1,28 +0,0 @@ -// import { useRouterHistory } from 'react-router'; -// import { syncHistoryWithStore, routerMiddleware } from 'react-router-redux'; -import thunk from 'redux-thunk'; -// import { createHistory } from 'history'; - -import createStore from './createStore'; - -import { - instancesInitialState, - instancesReducer, -} from '../domains/instances/reducer'; - -const store = createStore( - { - instances: instancesInitialState, - }, - { - instances: instancesReducer, - }, - [ - // routerMiddleware(storeHistory), - thunk, - ], -); - -const { dispatch } = store; - -export { dispatch, store }; diff --git a/hat/assets/js/apps/Iaso/redux/useInjectedStore.ts b/hat/assets/js/apps/Iaso/redux/useInjectedStore.ts deleted file mode 100644 index b86d5c7305..0000000000 --- a/hat/assets/js/apps/Iaso/redux/useInjectedStore.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useContext, useEffect, useMemo, useState } from 'react'; -import { ReactReduxContext } from 'react-redux'; -/** - * - * @deprecated Redux - * This is adding a list of reducers to the existing one's. - * As using redux is a practice we are trying to avoid, try not to use this hook - * Also do not use same reducer key as the existing one's (see hat/assets/js/apps/Iaso/redux/store.js) - * - * Example how to use in a component: - * const store = useInjectedStore([ - { - reducerKey: 'reducerKey', - reducer: IMPORTED_REDUCER, - }, - ]); - if (!store) return null; - return ( - - - - ); - * - */ - -type Reducer = { - reducerKey: string; - reducer: (state: any, action: Record) => Record; -}; - -type Reducers = Reducer[]; - -export const useInjectedStore = (reducers: Reducers): any => { - const { store }: { store: any } = useContext(ReactReduxContext); - const existingKeys = useMemo(() => { - return Object.keys(store.getState()); - }, [store]); - const [injectedStore, setInjectedStore] = useState(false); - useEffect(() => { - if (store && !injectedStore) { - for (let i = 0; i < reducers.length; i += 1) { - const { reducerKey, reducer } = reducers[i]; - - if (existingKeys.includes(reducerKey)) { - console.warn( - `A reducer with this key ("${reducerKey}") already exists in the store`, - ); - } else { - store.injectReducer(reducerKey, reducer); - } - } - setInjectedStore(true); - } - }, [store, injectedStore, reducers, existingKeys]); - if (!injectedStore) return undefined; - - return store; -}; diff --git a/hat/assets/js/test/utils/redux.js b/hat/assets/js/test/utils/redux.js index 79a90c3fba..9c96630c1e 100644 --- a/hat/assets/js/test/utils/redux.js +++ b/hat/assets/js/test/utils/redux.js @@ -2,8 +2,6 @@ import React from 'react'; import { Provider } from 'react-redux'; import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; -import { instancesInitialState } from '../../apps/Iaso/domains/instances/reducer'; -import { orgUnitsInitialState } from '../../apps/Iaso/domains/orgUnits/reducer'; import { renderWithIntl } from './intl'; @@ -15,8 +13,8 @@ const mockStore = configureStore(middlewares); const getMockedStore = storeObject => mockStore(storeObject); const initialState = { - orgUnits: orgUnitsInitialState, - instances: instancesInitialState, + orgUnits: [], + instances: [], }; export const renderWithStore = (component, state = null) => ( diff --git a/hat/webpack.dev.js b/hat/webpack.dev.js index cb3756e52a..a1c60da7ee 100644 --- a/hat/webpack.dev.js +++ b/hat/webpack.dev.js @@ -152,7 +152,6 @@ module.exports = { 'moment', 'leaflet', 'leaflet-draw', - 'react-redux', 'prop-types', 'typescript', 'video.js', diff --git a/plugins/polio/js/src/domains/Calendar/campaignCalendar/types.ts b/plugins/polio/js/src/domains/Calendar/campaignCalendar/types.ts index 853875390a..3a74bbbda6 100644 --- a/plugins/polio/js/src/domains/Calendar/campaignCalendar/types.ts +++ b/plugins/polio/js/src/domains/Calendar/campaignCalendar/types.ts @@ -105,8 +105,4 @@ export type Users = { current: User; }; -export type ReduxState = { - users: Users; -}; - export type PeriodType = 'quarter' | 'year' | 'semester'; From d768182060bd7ffc2defb27c27e60e8c2d89ba97 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Fri, 18 Oct 2024 16:16:58 +0200 Subject: [PATCH 09/60] remove totally redux, partially fix js tests --- .../guidelines/front-end/front-end.en.md | 5 +- .../Iaso/components/forms/Checkboxes.spec.js | 16 +- .../forms/EditableTextFields.spec.js | 7 +- .../js/apps/Iaso/domains/app/index.spec.js | 11 +- .../components/FormVersionsDialogComponent.js | 8 +- .../FormVersionsDialogComponent.spec.js | 78 +++++----- .../js/apps/Iaso/domains/instances/actions.js | 2 +- .../users/components/ProtectedRoute.spec.js | 142 ------------------ .../users/components/UsersInfos.spec.js | 23 ++- hat/assets/js/test/utils.js | 14 -- hat/assets/js/test/utils/redux.js | 31 ---- package-lock.json | 57 ------- package.json | 6 +- plugins/test/js/index.js | 2 - 14 files changed, 71 insertions(+), 331 deletions(-) delete mode 100644 hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js delete mode 100644 hat/assets/js/test/utils/redux.js diff --git a/docs/pages/dev/reference/guidelines/front-end/front-end.en.md b/docs/pages/dev/reference/guidelines/front-end/front-end.en.md index 7e3a7f1975..09dbd63218 100644 --- a/docs/pages/dev/reference/guidelines/front-end/front-end.en.md +++ b/docs/pages/dev/reference/guidelines/front-end/front-end.en.md @@ -21,9 +21,8 @@ Don't be afraid to split your code into smaller parts, using understandable nami ## Legacy -Class component, redux, provider are still old way to create features in IASO. +Class component, proptypes are still old way to create features in IASO. Please use `hooks`, `typescript` and `arrow component`. -Redux can still be used with state that needs to be available everywhere in the application (current user, UI constants and states, ...). We already have a lot of typing done in each domain of the application (forms, submissions, org units, ... ) ## Bluesquare-components @@ -37,7 +36,7 @@ To make it available too everybody you have to build new files with `npm run cle ## Architecture Main index file is located here: `hat/assets/js/apps/Iaso/index` -This is the entrypoint of the app, setting up providers, theme, react-query query client, custom plugins, redux,... +This is the entrypoint of the app, setting up providers, theme, react-query query client, custom plugins,... **`components`** Used to store generic components that can be used everywhere, like `inputComponent`, `buttons`, ... diff --git a/hat/assets/js/apps/Iaso/components/forms/Checkboxes.spec.js b/hat/assets/js/apps/Iaso/components/forms/Checkboxes.spec.js index 2f28a02b2b..68ae7611fa 100644 --- a/hat/assets/js/apps/Iaso/components/forms/Checkboxes.spec.js +++ b/hat/assets/js/apps/Iaso/components/forms/Checkboxes.spec.js @@ -1,9 +1,10 @@ -import React from 'react'; +import { Box } from '@mui/material'; import { expect } from 'chai'; import { mount } from 'enzyme'; -import { Box } from '@mui/material'; +import React from 'react'; +import { renderWithIntl } from '../../../../test/utils/intl'; +import { renderWithMuiTheme } from '../../../../test/utils/muiTheme'; import { Checkboxes } from './Checkboxes'; -import { renderWithStore } from '../../../../test/utils/redux'; import InputComponent from './InputComponent'; let component; @@ -40,8 +41,13 @@ const checkboxesProp = () => { }; const renderComponent = props => { component = mount( - renderWithStore( - , + renderWithMuiTheme( + renderWithIntl( + , + ), ), ); }; diff --git a/hat/assets/js/apps/Iaso/components/forms/EditableTextFields.spec.js b/hat/assets/js/apps/Iaso/components/forms/EditableTextFields.spec.js index 398a368521..94f44012e3 100644 --- a/hat/assets/js/apps/Iaso/components/forms/EditableTextFields.spec.js +++ b/hat/assets/js/apps/Iaso/components/forms/EditableTextFields.spec.js @@ -1,9 +1,8 @@ import React from 'react'; +import { renderWithIntl } from '../../../../test/utils/intl'; import { renderWithMuiTheme } from '../../../../test/utils/muiTheme'; import { EditableTextFields } from './EditableTextFields'; -import { renderWithIntl } from '../../../../test/utils/intl'; import InputComponent from './InputComponent.tsx'; -import { renderWithStore } from '../../../../test/utils/redux'; const onChange1 = sinon.spy(); const onChange2 = sinon.spy(); @@ -34,9 +33,7 @@ let inputs; const renderComponent = props => { return mount( renderWithIntl( - renderWithMuiTheme( - renderWithStore(), - ), + renderWithMuiTheme(), ), ); }; diff --git a/hat/assets/js/apps/Iaso/domains/app/index.spec.js b/hat/assets/js/apps/Iaso/domains/app/index.spec.js index bec4c7e486..23c52ec6c2 100644 --- a/hat/assets/js/apps/Iaso/domains/app/index.spec.js +++ b/hat/assets/js/apps/Iaso/domains/app/index.spec.js @@ -1,16 +1,15 @@ import React from 'react'; +import { renderWithIntl } from '../../../../test/utils/intl'; +import { renderWithMuiTheme } from '../../../../test/utils/muiTheme'; import App from './index.tsx'; -import { renderWithStore } from '../../../../test/utils/redux'; describe('App', () => { it('render properly', () => { const wrapper = shallow( - renderWithStore(, { - subscribe: () => null, - dispatch: () => null, - getState: () => null, - }), + renderWithMuiTheme( + renderWithIntl(), + ), ); expect(wrapper.exists()).to.be.true; }); diff --git a/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.js b/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.js index 4be9de5652..f51daf733c 100644 --- a/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.js +++ b/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.js @@ -1,17 +1,17 @@ import { Box, Grid, Typography } from '@mui/material'; +import { LoadingSpinner, useSafeIntl } from 'bluesquare-components'; import PropTypes from 'prop-types'; import React, { useCallback, useMemo, useState } from 'react'; -import { LoadingSpinner, useSafeIntl } from 'bluesquare-components'; import { useQueryClient } from 'react-query'; import ConfirmCancelDialogComponent from '../../../components/dialogs/ConfirmCancelDialogComponent'; import FileInputComponent from '../../../components/forms/FileInputComponent'; -import PeriodPicker from '../../periods/components/PeriodPicker.tsx'; +import { openSnackBar } from '../../../components/snackBars/EventDispatcher.ts'; +import { succesfullSnackBar } from '../../../constants/snackBars'; import { useFormState } from '../../../hooks/form'; import { createFormVersion, updateFormVersion } from '../../../utils/requests'; +import PeriodPicker from '../../periods/components/PeriodPicker.tsx'; import { errorTypes, getPeriodsErrors } from '../../periods/utils'; import MESSAGES from '../messages'; -import { openSnackBar } from '../../../components/snackBars/EventDispatcher.ts'; -import { succesfullSnackBar } from '../../../constants/snackBars'; const emptyVersion = (id = null) => ({ id, diff --git a/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js b/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js index efd3a2ee18..3ce6abf629 100644 --- a/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js +++ b/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js @@ -1,18 +1,19 @@ -import { AdapterMoment } from '@mui/x-date-pickers/AdapterMoment'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import React from 'react'; import { act } from 'react-dom/test-utils'; import { IconButton as IconButtonComponent } from 'bluesquare-components'; import { expect } from 'chai'; -import { withQueryClientProvider } from '../../../../../test/utils'; -import { renderWithStore } from '../../../../../test/utils/redux'; +import { renderWithMuiTheme } from 'hat/assets/js/test/utils/muiTheme'; +import { + renderWithIntl, + withQueryClientProvider, +} from '../../../../../test/utils'; import ConfirmCancelDialogComponent from '../../../components/dialogs/ConfirmCancelDialogComponent'; import PeriodPicker from '../../periods/components/PeriodPicker.tsx'; import { PERIOD_TYPE_DAY } from '../../periods/constants'; import formVersionFixture from '../fixtures/formVersions.json'; import MESSAGES from '../messages'; -import FormVersionsDialog from './FormVersionsDialogComponent'; +import { FormVersionsDialogComponent } from './FormVersionsDialogComponent'; let connectedWrapper; @@ -34,15 +35,12 @@ const awaitUseEffect = async wrapper => { return Promise.resolve(); }; -const getConnectedWrapper = () => +const renderComponent = () => mount( withQueryClientProvider( - renderWithStore( - - )} onConfirmed={() => null} periodType={PERIOD_TYPE_DAY} - /> - , - { - forms: { - current: undefined, - }, - }, + />, + ), ), ), ); -describe('FormVersionsDialog connected component', () => { - describe('with a new form version', () => { +describe.only('FormVersionsDialog connected component', () => { + describe.only('with a new form version', () => { before(() => { connectedWrapper = mount( withQueryClientProvider( - renderWithStore( - null} - periodType={PERIOD_TYPE_DAY} - renderTrigger={({ openDialog }) => ( - - )} - />, - { - forms: { - current: undefined, - }, - }, + renderWithIntl( + renderWithMuiTheme( + null} + periodType={PERIOD_TYPE_DAY} + renderTrigger={({ openDialog }) => ( + + )} + />, + ), ), ), ); @@ -124,7 +116,7 @@ describe('FormVersionsDialog connected component', () => { describe('with a full form version', () => { before(() => { - connectedWrapper = getConnectedWrapper(); + connectedWrapper = renderComponent(); inputComponent = connectedWrapper.find('#open-dialog').at(0); inputComponent.props().onClick(); connectedWrapper.update(); @@ -191,7 +183,7 @@ describe('FormVersionsDialog connected component', () => { describe('onConfirm', () => { before(() => { - connectedWrapper = getConnectedWrapper(); + connectedWrapper = renderComponent(); inputComponent = connectedWrapper.find('#open-dialog').at(0); inputComponent.props().onClick(); connectedWrapper.update(); diff --git a/hat/assets/js/apps/Iaso/domains/instances/actions.js b/hat/assets/js/apps/Iaso/domains/instances/actions.js index 8b0317f772..cde321cd6f 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/actions.js +++ b/hat/assets/js/apps/Iaso/domains/instances/actions.js @@ -27,7 +27,7 @@ export const createInstance = (currentForm, payload) => { ); }; -export const createExportRequest = (filterParams, selection) => () => { +export const createExportRequest = (filterParams, selection) => { const filters = { ...filterParams, }; diff --git a/hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js b/hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js deleted file mode 100644 index ba4e5d2a05..0000000000 --- a/hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js +++ /dev/null @@ -1,142 +0,0 @@ -import { ThemeProvider } from '@mui/material'; -import { ErrorBoundary, theme } from 'bluesquare-components'; -import { expect } from 'chai'; -import { shallow } from 'enzyme'; -import nock from 'nock'; -import React from 'react'; -import { - mockedStore, - renderWithMutableStore, -} from '../../../../../test/utils/redux'; -import { mockRequest } from '../../../../../test/utils/requests'; -import * as Permission from '../../../utils/permissions.ts'; -import SidebarMenu from '../../app/components/SidebarMenuComponent'; -import ProtectedRoute from './ProtectedRoute'; - -const cookieStub = require('../../../utils/cookies'); - -let component; - -const user = { - id: 40, - first_name: '', - user_name: 'son', - last_name: '', - email: '', - permissions: [], - is_superuser: true, - org_units: [], - language: '', -}; - -const updatedUser = { - ...user, - language: 'en', -}; -const unauthorizedUser = { - ...user, - permissions: [Permission.USERS_ADMIN, Permission.USERS_MANAGEMENT], - is_superuser: false, - language: 'en', -}; - -const makeMockedStore = value => - mockedStore({ - app: { locale: { code: 'fr', label: 'Version française' } }, - users: { current: value }, - }); - -const store = makeMockedStore(user); - -const updatedStore = makeMockedStore(updatedUser); - -const storeWithUnauthorizedUser = makeMockedStore(unauthorizedUser); - -const storeWithNoUser = makeMockedStore(null); - -const setCookieSpy = sinon.spy(cookieStub, 'setCookie'); - -const updatedDispatchSpy = sinon.spy(updatedStore, 'dispatch'); - -const unauthorizedDispatchSpy = sinon.spy( - storeWithUnauthorizedUser, - 'dispatch', -); - -const fakeGetItem = key => { - if (key === 'django_language') { - return 'fr'; - } - return ''; -}; -const getCookieStub = sinon - .stub(cookieStub, 'getCookie') - .callsFake(fakeGetItem); - -const stubComponent = () =>
I am a stub
; - -const renderComponent = () => { - component = shallow( - renderWithMutableStore( - - - - - , - store, - ), - ); -}; - -describe('ProtectedRoutes', () => { - beforeEach(() => { - setCookieSpy.resetHistory(); - updatedDispatchSpy.resetHistory(); - unauthorizedDispatchSpy.resetHistory(); - nock.cleanAll(); - nock.abortPendingRequests(); - mockRequest('get', '/api/profiles/me', user); - renderComponent(); - component.update(); - }); - - it('renders', () => { - expect(component.exists()).to.equal(true); - }); - it('uses the languages option in cookies if it exists', async () => { - // updating store to trigger componentDidUpdate - component.setProps({ store: updatedStore }); - await component.update(); - expect(setCookieSpy).to.not.have.been.called; - }); - // Passes when not run as part of the test suite - it.skip('uses the language option from backend if none exist in cookies', async () => { - getCookieStub.returns(null); - component.setProps({ store: updatedStore }); - await component.update(); - expect(updatedDispatchSpy).to.have.been.calledOnce; - // expect 2 calls because cookies is set again by action dispatched - expect(setCookieSpy).to.have.been.calledTwice; - }); - // Passes when not run as part of the test suite - it.skip('redirects unauthorized user to first authorized route', async () => { - // put a value in cookies to prevent saving language option (which would trigger a second dispatch call) - getCookieStub.returns('en'); - component.setProps({ store: storeWithUnauthorizedUser }); - await component.update(); - - // Spying on dispatch as imported functions cannot be stubbed or spied on - expect(unauthorizedDispatchSpy).to.have.been.calledOnce; - }); - it('does not render anything if there is no user', async () => { - component.setProps({ store: storeWithNoUser }); - await component.update(); - const element = component.find(SidebarMenu).at(0); - expect(element.exists()).to.equal(false); - }); -}); diff --git a/hat/assets/js/apps/Iaso/domains/users/components/UsersInfos.spec.js b/hat/assets/js/apps/Iaso/domains/users/components/UsersInfos.spec.js index 71abff225a..291ff82a79 100644 --- a/hat/assets/js/apps/Iaso/domains/users/components/UsersInfos.spec.js +++ b/hat/assets/js/apps/Iaso/domains/users/components/UsersInfos.spec.js @@ -1,12 +1,11 @@ -import React from 'react'; -import { Select, PasswordInput } from 'bluesquare-components'; +import { PasswordInput, Select } from 'bluesquare-components'; import { expect } from 'chai'; -import { renderWithMuiTheme } from '../../../../../test/utils/muiTheme'; +import React from 'react'; +import { withQueryClientProvider } from '../../../../../test/utils'; import { renderWithIntl } from '../../../../../test/utils/intl'; -import UsersInfos from './UsersInfos'; +import { renderWithMuiTheme } from '../../../../../test/utils/muiTheme'; import MESSAGES from '../messages'; -import { renderWithStore } from '../../../../../test/utils/redux'; -import { withQueryClientProvider } from '../../../../../test/utils'; +import UsersInfos from './UsersInfos'; let component; let inputs; @@ -33,13 +32,11 @@ const renderComponent = initialData => { renderWithIntl( renderWithMuiTheme( withQueryClientProvider( - renderWithStore( - , - ), + , ), ), ), diff --git a/hat/assets/js/test/utils.js b/hat/assets/js/test/utils.js index 3c71f1ca3f..10d759ce2e 100644 --- a/hat/assets/js/test/utils.js +++ b/hat/assets/js/test/utils.js @@ -1,8 +1,6 @@ import React from 'react'; -import ReactDOM from 'react-dom'; import TestUtils, { act } from 'react-dom/test-utils'; import { IntlProvider } from 'react-intl'; -import { Provider } from 'react-redux'; import { QueryClient, QueryClientProvider } from 'react-query'; import { BrowserRouter, Route } from 'react-router-dom'; import InputComponent from '../apps/Iaso/components/forms/InputComponent.tsx'; @@ -31,18 +29,6 @@ export function withRouter(component) { return ; } -export function renderWithStore(store, component, node = null) { - const wrappedComp = ( - - {component} - - ); - if (!node) { - node = document.createElement('div'); // eslint-disable-line - } - return ReactDOM.render(wrappedComp, node); // eslint-disable-line -} - export const awaitUseEffect = async wrapper => { await act(async () => { await Promise.resolve(wrapper); diff --git a/hat/assets/js/test/utils/redux.js b/hat/assets/js/test/utils/redux.js deleted file mode 100644 index 9c96630c1e..0000000000 --- a/hat/assets/js/test/utils/redux.js +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import { Provider } from 'react-redux'; -import configureStore from 'redux-mock-store'; -import thunk from 'redux-thunk'; - -import { renderWithIntl } from './intl'; - -import { renderWithMuiTheme } from './muiTheme'; - -const middlewares = [thunk]; -const mockStore = configureStore(middlewares); - -const getMockedStore = storeObject => mockStore(storeObject); - -const initialState = { - orgUnits: [], - instances: [], -}; - -export const renderWithStore = (component, state = null) => ( - - {renderWithMuiTheme(renderWithIntl(component))} - -); - -export const mockedStore = state => - getMockedStore({ ...initialState, ...state }); - -export const renderWithMutableStore = (component, store) => ( - {renderWithIntl(component)} -); diff --git a/package-lock.json b/package-lock.json index 462217030e..11468bccaf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,13 +55,9 @@ "react-leaflet-markercluster": "^3.0.0-rc1", "react-query": "^3.18.1", "react-quilljs": "^2.0.0", - "react-redux": "^7.2.0", "react-router-dom": "^6.22.3", - "react-router-redux": "^4.0.0", "react-table": "^7.7.0", "recharts": "^2.2", - "redux": "^4.0.5", - "redux-thunk": "^2.3.0", "typescript": "^4.5.2", "url-search-params-polyfill": "^8.2.5", "use-debounce": "^7.0.0", @@ -4427,17 +4423,6 @@ "@types/react": "^17" } }, - "node_modules/@types/react-redux": { - "version": "7.1.33", - "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz", - "integrity": "sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==", - "dependencies": { - "@types/hoist-non-react-statics": "^3.3.0", - "@types/react": "*", - "hoist-non-react-statics": "^3.3.0", - "redux": "^4.0.0" - } - }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -19134,35 +19119,6 @@ "react-dom": "^17.0.2 || ^18.0.0-0" } }, - "node_modules/react-redux": { - "version": "7.2.9", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", - "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", - "dependencies": { - "@babel/runtime": "^7.15.4", - "@types/react-redux": "^7.1.20", - "hoist-non-react-statics": "^3.3.2", - "loose-envify": "^1.4.0", - "prop-types": "^15.7.2", - "react-is": "^17.0.2" - }, - "peerDependencies": { - "react": "^16.8.3 || ^17 || ^18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, - "node_modules/react-redux/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" - }, "node_modules/react-router": { "version": "6.23.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.23.1.tgz", @@ -19193,11 +19149,6 @@ "react-dom": ">=16.8" } }, - "node_modules/react-router-redux": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/react-router-redux/-/react-router-redux-4.0.8.tgz", - "integrity": "sha512-lzlK+S6jZnn17BZbzBe6F8ok3YAhGAUlyWgRu3cz5mT199gKxfem5lNu3qcgzRiVhNEOFVG0/pdT+1t4aWhoQw==" - }, "node_modules/react-shallow-renderer": { "version": "16.15.0", "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", @@ -19388,14 +19339,6 @@ "lodash.isplainobject": "^4.0.6" } }, - "node_modules/redux-thunk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", - "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", - "peerDependencies": { - "redux": "^4" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", diff --git a/package.json b/package.json index f6c2721512..cbc9bb27ed 100644 --- a/package.json +++ b/package.json @@ -94,13 +94,9 @@ "react-leaflet-markercluster": "^3.0.0-rc1", "react-query": "^3.18.1", "react-quilljs": "^2.0.0", - "react-redux": "^7.2.0", "react-router-dom": "^6.22.3", - "react-router-redux": "^4.0.0", "react-table": "^7.7.0", "recharts": "^2.2", - "redux": "^4.0.5", - "redux-thunk": "^2.3.0", "typescript": "^4.5.2", "url-search-params-polyfill": "^8.2.5", "use-debounce": "^7.0.0", @@ -166,4 +162,4 @@ "webpack-cli": "^4.10.0", "webpack-dev-server": "^4.15.1" } -} \ No newline at end of file +} diff --git a/plugins/test/js/index.js b/plugins/test/js/index.js index e629543f83..e0335bb900 100644 --- a/plugins/test/js/index.js +++ b/plugins/test/js/index.js @@ -8,7 +8,6 @@ import { } from 'bluesquare-components'; import TopBar from 'Iaso/components/nav/TopBarComponent'; import React, { useEffect, useState } from 'react'; -import { useDispatch } from 'react-redux'; import { getRequest } from 'Iaso/libs/Api'; import tableColumns from './columns'; @@ -21,7 +20,6 @@ const useStyles = makeStyles(theme => ({ })); const TestApp = () => { - const dispatch = useDispatch(); const classes = useStyles(); const intl = useSafeIntl(); const [fetching, setFetching] = useState(true); From b125f04197a3bb300577e7b5c136677428475f1b Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Fri, 18 Oct 2024 16:23:17 +0200 Subject: [PATCH 10/60] Fix import --- .../forms/components/FormVersionsDialogComponent.spec.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js b/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js index 3ce6abf629..1198d26480 100644 --- a/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js +++ b/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js @@ -3,17 +3,17 @@ import { act } from 'react-dom/test-utils'; import { IconButton as IconButtonComponent } from 'bluesquare-components'; import { expect } from 'chai'; -import { renderWithMuiTheme } from 'hat/assets/js/test/utils/muiTheme'; import { renderWithIntl, withQueryClientProvider, } from '../../../../../test/utils'; +import { renderWithMuiTheme } from '../../../../../test/utils/muiTheme'; import ConfirmCancelDialogComponent from '../../../components/dialogs/ConfirmCancelDialogComponent'; import PeriodPicker from '../../periods/components/PeriodPicker.tsx'; import { PERIOD_TYPE_DAY } from '../../periods/constants'; import formVersionFixture from '../fixtures/formVersions.json'; import MESSAGES from '../messages'; -import { FormVersionsDialogComponent } from './FormVersionsDialogComponent'; +import FormVersionsDialogComponent from './FormVersionsDialogComponent'; let connectedWrapper; @@ -60,8 +60,8 @@ const renderComponent = () => ), ); -describe.only('FormVersionsDialog connected component', () => { - describe.only('with a new form version', () => { +describe('FormVersionsDialog connected component', () => { + describe('with a new form version', () => { before(() => { connectedWrapper = mount( withQueryClientProvider( From e54b92485edd298a4eb44594c101103a5f61dc3c Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Fri, 18 Oct 2024 16:35:11 +0200 Subject: [PATCH 11/60] fix last js test --- .../FormVersionsDialogComponent.spec.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js b/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js index 1198d26480..6902e79cfd 100644 --- a/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js +++ b/hat/assets/js/apps/Iaso/domains/forms/components/FormVersionsDialogComponent.spec.js @@ -3,10 +3,8 @@ import { act } from 'react-dom/test-utils'; import { IconButton as IconButtonComponent } from 'bluesquare-components'; import { expect } from 'chai'; -import { - renderWithIntl, - withQueryClientProvider, -} from '../../../../../test/utils'; +import { withQueryClientProvider } from '../../../../../test/utils'; +import { renderWithIntl } from '../../../../../test/utils/intl'; import { renderWithMuiTheme } from '../../../../../test/utils/muiTheme'; import ConfirmCancelDialogComponent from '../../../components/dialogs/ConfirmCancelDialogComponent'; import PeriodPicker from '../../periods/components/PeriodPicker.tsx'; @@ -37,9 +35,9 @@ const awaitUseEffect = async wrapper => { const renderComponent = () => mount( - withQueryClientProvider( - renderWithIntl( - renderWithMuiTheme( + renderWithIntl( + renderWithMuiTheme( + withQueryClientProvider( { describe('with a new form version', () => { before(() => { connectedWrapper = mount( - withQueryClientProvider( - renderWithIntl( - renderWithMuiTheme( + renderWithIntl( + renderWithMuiTheme( + withQueryClientProvider( Date: Fri, 18 Oct 2024 16:36:57 +0200 Subject: [PATCH 12/60] remove last redux package --- package-lock.json | 16 ---------------- package.json | 1 - 2 files changed, 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11468bccaf..849fb3acfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -112,7 +112,6 @@ "prettier": "^3.2.5", "prettier-eslint": "^12.0.0", "prettier-eslint-cli": "^8.0.1", - "redux-mock-store": "^1.5.4", "sinon": "^18.0.0", "sinon-chai": "^3.7.0", "source-map-loader": "^5.0.0", @@ -12997,12 +12996,6 @@ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true - }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -19330,15 +19323,6 @@ "@babel/runtime": "^7.9.2" } }, - "node_modules/redux-mock-store": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.4.tgz", - "integrity": "sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA==", - "dev": true, - "dependencies": { - "lodash.isplainobject": "^4.0.6" - } - }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", diff --git a/package.json b/package.json index cbc9bb27ed..88268fd208 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,6 @@ "prettier": "^3.2.5", "prettier-eslint": "^12.0.0", "prettier-eslint-cli": "^8.0.1", - "redux-mock-store": "^1.5.4", "sinon": "^18.0.0", "sinon-chai": "^3.7.0", "source-map-loader": "^5.0.0", From 12a1b75f34c357d008dc21ba26e589642433493f Mon Sep 17 00:00:00 2001 From: kemar Date: Wed, 9 Oct 2024 15:30:04 +0200 Subject: [PATCH 13/60] Reduce the number of queries in `validate_editable_org_unit_types` --- iaso/api/profiles/profiles.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index 4588277d53..8521536628 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -500,13 +500,11 @@ def validate_projects(self, request, profile): return result def validate_editable_org_unit_types(self, request): - result = [] editable_org_unit_type_ids = request.data.get("editable_org_unit_type_ids", None) - if editable_org_unit_type_ids: - for editable_org_unit_type_id in editable_org_unit_type_ids: - item = get_object_or_404(OrgUnitType, pk=editable_org_unit_type_id) - result.append(item) - return result + editable_org_unit_types = OrgUnitType.objects.filter(pk__in=editable_org_unit_type_ids) + if editable_org_unit_types.count() != len(editable_org_unit_type_ids): + raise ValidationError("Invalid editable org unit type submitted.") + return editable_org_unit_types @staticmethod def update_user_own_profile(request): From f2137e8050096edafa5fa5d4a13afe43e8447a76 Mon Sep 17 00:00:00 2001 From: kemar Date: Wed, 9 Oct 2024 15:41:40 +0200 Subject: [PATCH 14/60] Use an iterable as default value --- iaso/api/profiles/profiles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index 8521536628..750f76f32e 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -500,7 +500,7 @@ def validate_projects(self, request, profile): return result def validate_editable_org_unit_types(self, request): - editable_org_unit_type_ids = request.data.get("editable_org_unit_type_ids", None) + editable_org_unit_type_ids = request.data.get("editable_org_unit_type_ids", []) editable_org_unit_types = OrgUnitType.objects.filter(pk__in=editable_org_unit_type_ids) if editable_org_unit_types.count() != len(editable_org_unit_type_ids): raise ValidationError("Invalid editable org unit type submitted.") From dc3268357512289bca58c1c57943cbae795dcb19 Mon Sep 17 00:00:00 2001 From: kemar Date: Thu, 10 Oct 2024 15:05:04 +0200 Subject: [PATCH 15/60] Reduce the number of queries --- iaso/api/profiles/profiles.py | 3 ++- iaso/models/base.py | 6 +++--- iaso/tests/api/test_profiles.py | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index 750f76f32e..a940f2d1ef 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -220,7 +220,7 @@ class ProfilesViewSet(viewsets.ViewSet): def get_queryset(self): account = self.request.user.iaso_profile.account - return Profile.objects.filter(account=account) + return Profile.objects.filter(account=account).prefetch_related("editable_org_unit_types") def list(self, request): limit = request.GET.get("limit", None) @@ -275,6 +275,7 @@ def list(self, request): "org_units__parent__org_unit_type", "org_units__parent__parent__org_unit_type", "projects", + "editable_org_unit_types", ) if request.GET.get("csv"): return self.list_export(queryset=queryset, file_format=FileFormatEnum.CSV) diff --git a/iaso/models/base.py b/iaso/models/base.py index 668cfd90a3..0fabf3e0c9 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -1467,7 +1467,7 @@ def as_dict(self, small=False): "phone_number": self.phone_number.as_e164 if self.phone_number else None, "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, "projects": [p.as_dict() for p in self.projects.all().order_by("name")], - "editable_org_unit_type_ids": list(self.editable_org_unit_types.values_list("id", flat=True)), + "editable_org_unit_type_ids": [out.pk for out in self.editable_org_unit_types.all()], } else: return { @@ -1490,7 +1490,7 @@ def as_dict(self, small=False): "phone_number": self.phone_number.as_e164 if self.phone_number else None, "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, "projects": [p.as_dict() for p in self.projects.all()], - "editable_org_unit_type_ids": list(self.editable_org_unit_types.values_list("id", flat=True)), + "editable_org_unit_type_ids": [out.pk for out in self.editable_org_unit_types.all()], } def as_short_dict(self): @@ -1504,7 +1504,7 @@ def as_short_dict(self): "user_id": self.user.id, "phone_number": self.phone_number.as_e164 if self.phone_number else None, "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, - "editable_org_unit_type_ids": list(self.editable_org_unit_types.values_list("id", flat=True)), + "editable_org_unit_type_ids": [out.pk for out in self.editable_org_unit_types.all()], } def has_a_team(self): diff --git a/iaso/tests/api/test_profiles.py b/iaso/tests/api/test_profiles.py index 2c6f86333f..768068429f 100644 --- a/iaso/tests/api/test_profiles.py +++ b/iaso/tests/api/test_profiles.py @@ -296,7 +296,7 @@ def test_profile_list_read_only_permissions(self): """GET /profiles/ with auth (user has read only permissions)""" self.client.force_authenticate(self.jane) - with self.assertNumQueries(17): + with self.assertNumQueries(11): response = self.client.get("/api/profiles/") self.assertJSONResponse(response, 200) profile_url = "/api/profiles/%s/" % self.jane.iaso_profile.id From df58b449101052b371b7b842f0383a0c67bce507 Mon Sep 17 00:00:00 2001 From: kemar Date: Thu, 10 Oct 2024 17:57:18 +0200 Subject: [PATCH 16/60] Add `Profile.has_org_unit_write_permission()` --- iaso/models/base.py | 5 +++++ iaso/tests/models/test_profile.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/iaso/models/base.py b/iaso/models/base.py index 0fabf3e0c9..97a5b43e33 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -1513,6 +1513,11 @@ def has_a_team(self): return True return False + def has_org_unit_write_permission(self, org_unit_type_id: int) -> bool: + if not self.editable_org_unit_types.exists(): + return True + return self.editable_org_unit_types.filter(id=org_unit_type_id).exists() + class ExportRequest(models.Model): id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID") diff --git a/iaso/tests/models/test_profile.py b/iaso/tests/models/test_profile.py index 626c6b2bc1..5a08dafdfd 100644 --- a/iaso/tests/models/test_profile.py +++ b/iaso/tests/models/test_profile.py @@ -18,3 +18,20 @@ def setUpTestData(cls): def test_user_has_team(self): self.assertTrue(self.profile1.has_a_team()) self.assertFalse(self.profile2.has_a_team()) + + def test_has_org_unit_write_permission(self): + org_unit_type_country = m.OrgUnitType.objects.create(name="Country") + org_unit_type_region = m.OrgUnitType.objects.create(name="Region") + + with self.assertNumQueries(1): + self.assertTrue(self.profile1.has_org_unit_write_permission(org_unit_type_country.pk)) + + self.profile1.editable_org_unit_types.set([org_unit_type_country]) + with self.assertNumQueries(2): + self.assertFalse(self.profile1.has_org_unit_write_permission(org_unit_type_region.pk)) + self.profile1.editable_org_unit_types.clear() + + self.profile1.editable_org_unit_types.set([org_unit_type_region]) + with self.assertNumQueries(2): + self.assertTrue(self.profile1.has_org_unit_write_permission(org_unit_type_region.pk)) + self.profile1.editable_org_unit_types.clear() From 513c989a48a9ebcb065b0c8818fdb7a7b04bf6ff Mon Sep 17 00:00:00 2001 From: kemar Date: Thu, 10 Oct 2024 17:59:38 +0200 Subject: [PATCH 17/60] Reduce the number of queries --- iaso/models/base.py | 5 +++-- iaso/tests/models/test_profile.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/iaso/models/base.py b/iaso/models/base.py index 97a5b43e33..74115ae562 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -1514,9 +1514,10 @@ def has_a_team(self): return False def has_org_unit_write_permission(self, org_unit_type_id: int) -> bool: - if not self.editable_org_unit_types.exists(): + editable_org_unit_type_ids = self.editable_org_unit_types.values_list("id", flat=True) + if not editable_org_unit_type_ids: return True - return self.editable_org_unit_types.filter(id=org_unit_type_id).exists() + return org_unit_type_id in editable_org_unit_type_ids class ExportRequest(models.Model): diff --git a/iaso/tests/models/test_profile.py b/iaso/tests/models/test_profile.py index 5a08dafdfd..f4cf14de82 100644 --- a/iaso/tests/models/test_profile.py +++ b/iaso/tests/models/test_profile.py @@ -27,11 +27,11 @@ def test_has_org_unit_write_permission(self): self.assertTrue(self.profile1.has_org_unit_write_permission(org_unit_type_country.pk)) self.profile1.editable_org_unit_types.set([org_unit_type_country]) - with self.assertNumQueries(2): + with self.assertNumQueries(1): self.assertFalse(self.profile1.has_org_unit_write_permission(org_unit_type_region.pk)) self.profile1.editable_org_unit_types.clear() self.profile1.editable_org_unit_types.set([org_unit_type_region]) - with self.assertNumQueries(2): + with self.assertNumQueries(1): self.assertTrue(self.profile1.has_org_unit_write_permission(org_unit_type_region.pk)) self.profile1.editable_org_unit_types.clear() From 6365ee1101134c383b393c07c965ffd1ace8796f Mon Sep 17 00:00:00 2001 From: kemar Date: Fri, 11 Oct 2024 11:13:23 +0200 Subject: [PATCH 18/60] Prevent update and creation of org units for users without write permission --- iaso/api/org_units.py | 22 +++++++++++++++--- iaso/models/base.py | 5 ---- iaso/tests/api/test_orgunits.py | 41 +++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/iaso/api/org_units.py b/iaso/api/org_units.py index 762ebc105b..e77825e47c 100644 --- a/iaso/api/org_units.py +++ b/iaso/api/org_units.py @@ -423,9 +423,18 @@ def treesearch(self, request, **kwargs): def partial_update(self, request, pk=None): errors = [] org_unit = get_object_or_404(self.get_queryset(), id=pk) + profile = request.user.iaso_profile self.check_object_permissions(request, org_unit) + if org_unit.org_unit_type and not profile.has_org_unit_write_permission(org_unit.org_unit_type.pk): + errors.append( + { + "errorKey": "org_unit_type_id", + "errorMessage": _("You cannot create or edit an Org unit of this type"), + } + ) + original_copy = deepcopy(org_unit) if "name" in request.data: @@ -528,7 +537,6 @@ def partial_update(self, request, pk=None): org_unit.parent = parent_org_unit else: # User that are restricted to parts of the hierarchy cannot create root orgunit - profile = request.user.iaso_profile if profile.org_units.all(): errors.append( { @@ -674,8 +682,6 @@ def create_org_unit(self, request): else: org_unit.validation_status = validation_status - org_unit_type_id = request.data.get("org_unit_type_id", None) - reference_instance_id = request.data.get("reference_instance_id", None) parent_id = request.data.get("parent_id", None) @@ -697,9 +703,19 @@ def create_org_unit(self, request): if latitude and longitude: org_unit.location = Point(x=longitude, y=latitude, z=altitude, srid=4326) + org_unit_type_id = request.data.get("org_unit_type_id", None) + if not org_unit_type_id: errors.append({"errorKey": "org_unit_type_id", "errorMessage": _("Org unit type is required")}) + if not profile.has_org_unit_write_permission(org_unit_type_id): + errors.append( + { + "errorKey": "org_unit_type_id", + "errorMessage": _("You cannot create or edit an Org unit of this type"), + } + ) + if parent_id: parent_org_unit = get_object_or_404(self.get_queryset(), id=parent_id) if org_unit.version_id != parent_org_unit.version_id: diff --git a/iaso/models/base.py b/iaso/models/base.py index 74115ae562..96e7c433dd 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -1,5 +1,3 @@ -import datetime -import mimetypes import operator import random import re @@ -15,7 +13,6 @@ from bs4 import BeautifulSoup as Soup # type: ignore from django import forms as dj_forms from django.contrib import auth -from django.contrib.auth import models as authModels from django.contrib.auth.models import AnonymousUser, User from django.contrib.gis.db.models.fields import PointField from django.contrib.gis.geos import Point @@ -33,7 +30,6 @@ from phonenumber_field.modelfields import PhoneNumberField from phonenumbers.phonenumberutil import region_code_for_number -from hat.audit.models import INSTANCE_API, log_modification from hat.menupermissions.constants import MODULES from iaso.models.data_source import DataSource, SourceVersion from iaso.models.org_unit import OrgUnit, OrgUnitReferenceInstance @@ -43,7 +39,6 @@ from .. import periods from ..utils.emoji import fix_emoji from ..utils.jsonlogic import jsonlogic_to_q -from ..utils.models.common import get_creator_name from .device import Device, DeviceOwnership from .forms import Form, FormVersion from .project import Project diff --git a/iaso/tests/api/test_orgunits.py b/iaso/tests/api/test_orgunits.py index 966b6cd26d..1da808a993 100644 --- a/iaso/tests/api/test_orgunits.py +++ b/iaso/tests/api/test_orgunits.py @@ -607,6 +607,22 @@ def test_create_org_unit_with_read_permission(self): response = self.set_up_org_unit_creation() self.assertJSONResponse(response, 403) + def test_create_org_unit_without_write_permission(self): + """ + Check that we cannot create an org unit if writing rights are limited + by a set of org unit types that we are allowed to modify. + """ + self.yoda.iaso_profile.editable_org_unit_types.set( + # Only org units of this type are now writable. + [self.jedi_squad] + ) + self.client.force_authenticate(self.yoda) + response = self.set_up_org_unit_creation() + json_response = self.assertJSONResponse(response, 400) + self.assertEqual(json_response[0]["errorKey"], "org_unit_type_id") + self.assertEqual(json_response[0]["errorMessage"], "You cannot create or edit an Org unit of this type") + self.yoda.iaso_profile.editable_org_unit_types.clear() + def test_create_org_unit(self): """Check that we can create org unit with only org units management permission""" self.client.force_authenticate(self.yoda) @@ -999,6 +1015,31 @@ def test_edit_org_unit_partial_update_read_permission(self): ) self.assertJSONResponse(response, 403) + def test_edit_org_unit_without_write_permission(self): + """ + Check that we cannot edit an org unit if writing rights are limited + by a set of org unit types that we are allowed to modify. + """ + org_unit = m.OrgUnit.objects.create( + name="Foo", + org_unit_type=self.jedi_council, + version=self.star_wars.default_version, + ) + self.yoda.iaso_profile.editable_org_unit_types.set( + # Only org units of this type are now writable. + [self.jedi_squad] + ) + self.client.force_authenticate(self.yoda) + response = self.client.patch( + f"/api/orgunits/{org_unit.id}/", + format="json", + data={"name": "New name"}, + ) + json_response = self.assertJSONResponse(response, 400) + self.assertEqual(json_response[0]["errorKey"], "org_unit_type_id") + self.assertEqual(json_response[0]["errorMessage"], "You cannot create or edit an Org unit of this type") + self.yoda.iaso_profile.editable_org_unit_types.clear() + def test_edit_org_unit_edit_bad_group_fail(self): """Check for a previous bug if an org unit is already member of a bad group it couldn't be edited anymore from the interface From f78e4800ff455cc561e933301583c688251ec968 Mon Sep 17 00:00:00 2001 From: kemar Date: Fri, 11 Oct 2024 16:08:36 +0200 Subject: [PATCH 19/60] Prevent buk update of org units for users without write permission --- iaso/models/base.py | 8 +++- iaso/tasks/org_units_bulk_update.py | 23 ++++++++++- iaso/tests/api/test_org_units_bulk_update.py | 41 +++++++++++++++++++- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/iaso/models/base.py b/iaso/models/base.py index 96e7c433dd..d649da7aec 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -1508,8 +1508,12 @@ def has_a_team(self): return True return False - def has_org_unit_write_permission(self, org_unit_type_id: int) -> bool: - editable_org_unit_type_ids = self.editable_org_unit_types.values_list("id", flat=True) + def has_org_unit_write_permission( + self, org_unit_type_id: int, prefetched_editable_org_unit_type_ids: list = None + ) -> bool: + editable_org_unit_type_ids = prefetched_editable_org_unit_type_ids or list( + self.editable_org_unit_types.values_list("id", flat=True) + ) if not editable_org_unit_type_ids: return True return org_unit_type_id in editable_org_unit_type_ids diff --git a/iaso/tasks/org_units_bulk_update.py b/iaso/tasks/org_units_bulk_update.py index 781fd42c1a..0ce753dda0 100644 --- a/iaso/tasks/org_units_bulk_update.py +++ b/iaso/tasks/org_units_bulk_update.py @@ -81,10 +81,25 @@ def org_units_bulk_update( raise Exception("Modification on read only source are not allowed") total = queryset.count() + editable_org_unit_type_ids = list(user.iaso_profile.editable_org_unit_types.values_list("id", flat=True)) + skipped_messages = [] # FIXME Task don't handle rollback properly if task is killed by user or other error with transaction.atomic(): for index, org_unit in enumerate(queryset.iterator()): + if org_unit.org_unit_type and not user.iaso_profile.has_org_unit_write_permission( + org_unit_type_id=org_unit.org_unit_type.pk, + prefetched_editable_org_unit_type_ids=editable_org_unit_type_ids, + ): + skipped_messages.append( + ( + f"Org unit `{org_unit.name}` (#{org_unit.pk}) silently skipped " + f"because user `{user.username}` (#{user.pk}) cannot edit " + f"an org unit of type `{org_unit.org_unit_type.name}` (#{org_unit.org_unit_type.pk})." + ) + ) + continue + res_string = "%.2f sec, processed %i org units" % (time() - start, index) task.report_progress_and_stop_if_killed(progress_message=res_string, end_value=total, progress_value=index) update_single_unit_from_bulk( @@ -96,4 +111,10 @@ def org_units_bulk_update( groups_ids_removed=groups_ids_removed, ) - task.report_success(message="%d modified" % total) + message = f"{total} modified" + if skipped_messages: + message = f"{total - len(skipped_messages)} modified\n" + message += f"{len(skipped_messages)} skipped\n" + message += "\n".join(skipped_messages) + + task.report_success(message=message) diff --git a/iaso/tests/api/test_org_units_bulk_update.py b/iaso/tests/api/test_org_units_bulk_update.py index ee24bedafc..05c5412706 100644 --- a/iaso/tests/api/test_org_units_bulk_update.py +++ b/iaso/tests/api/test_org_units_bulk_update.py @@ -60,7 +60,7 @@ def setUpTestData(cls): location=cls.mock_point, validation_status=m.OrgUnit.VALIDATION_VALID, ) - cls.jedi_squad_endor = m.OrgUnit.objects.create( + cls.jedi_squad_endor_1 = m.OrgUnit.objects.create( parent=cls.jedi_council_endor, org_unit_type=cls.jedi_squad, version=sw_version_1, @@ -72,7 +72,7 @@ def setUpTestData(cls): validation_status=m.OrgUnit.VALIDATION_VALID, source_ref="F9w3VW1cQmb", ) - cls.jedi_squad_endor = m.OrgUnit.objects.create( + cls.jedi_squad_endor_2 = m.OrgUnit.objects.create( parent=cls.jedi_council_endor, org_unit_type=cls.jedi_squad, version=sw_version_1, @@ -246,6 +246,43 @@ def test_org_unit_bulkupdate_select_all(self): self.assertEqual(5, am.Modification.objects.count()) + @tag("iaso_only") + def test_org_unit_bulkupdate_select_all_without_write_permission(self): + """ + Check that we cannot bulk edit all org units if writing rights are limited + by a set of org unit types that we are allowed to modify. + """ + self.yoda.iaso_profile.editable_org_unit_types.set( + # Only org units of this type are now writable. + [self.jedi_squad] + ) + self.client.force_authenticate(self.yoda) + + response = self.client.post( + f"/api/tasks/create/orgunitsbulkupdate/", + data={"select_all": True, "validation_status": m.OrgUnit.VALIDATION_VALID}, + format="json", + ) + self.assertJSONResponse(response, 201) + data = response.json() + task = self.assertValidTaskAndInDB(data["task"], status="QUEUED", name="org_unit_bulk_update") + self.assertEqual(task.launcher, self.yoda) + + # Run the task. + self.runAndValidateTask(task, "SUCCESS") + + for org_unit in [self.jedi_squad_endor_1, self.jedi_squad_endor_2]: + org_unit.refresh_from_db() + self.assertEqual(org_unit.validation_status, m.OrgUnit.VALIDATION_VALID) + + self.assertEqual(2, am.Modification.objects.count()) + + task.refresh_from_db() + self.assertIn("2 modified", task.progress_message) + self.assertIn("3 skipped", task.progress_message) + + self.yoda.iaso_profile.editable_org_unit_types.clear() + @tag("iaso_only") def test_org_unit_bulkupdate_select_all_with_search(self): """POST /orgunits/bulkupdate happy path (select all, but with search)""" From 5c33fedef2e9927fc54265819f30d07bc6d7e6d7 Mon Sep 17 00:00:00 2001 From: kemar Date: Mon, 14 Oct 2024 16:17:40 +0200 Subject: [PATCH 20/60] Handle `Profile.editable_org_unit_types` in the mobile org unit change request configuration API --- .../views_mobile.py | 50 +++++++- ..._org_unit_change_request_configs_mobile.py | 121 ++++++++++++++++-- 2 files changed, 160 insertions(+), 11 deletions(-) diff --git a/iaso/api/org_unit_change_request_configurations/views_mobile.py b/iaso/api/org_unit_change_request_configurations/views_mobile.py index a20e4feb28..a6b33b479e 100644 --- a/iaso/api/org_unit_change_request_configurations/views_mobile.py +++ b/iaso/api/org_unit_change_request_configurations/views_mobile.py @@ -1,3 +1,5 @@ +from itertools import chain + import django_filters from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema @@ -17,7 +19,7 @@ ) from iaso.api.query_params import APP_ID from iaso.api.serializers import AppIdSerializer -from iaso.models import OrgUnitChangeRequestConfiguration +from iaso.models import OrgUnitChangeRequestConfiguration, Project class MobileOrgUnitChangeRequestConfigurationViewSet(ListModelMixin, viewsets.GenericViewSet): @@ -35,14 +37,56 @@ class MobileOrgUnitChangeRequestConfigurationViewSet(ListModelMixin, viewsets.Ge ) def get_queryset(self): + """ + Because some Org Unit Type restrictions are also configurable at the `Profile` level, + we implement the following logic here: + + 1. If `Profile.editable_org_unit_types` empty: + + - return `OrgUnitChangeRequestConfiguration` + + 2. If `Profile.editable_org_unit_types` not empty: + + 2a. for org_unit_type not in `Profile.editable_org_unit_types`: + - return a dynamic configuration that says `org_units_editable: False` + - regardless of any existing `OrgUnitChangeRequestConfiguration` + + 2b. for org_unit_type in `Profile.editable_org_unit_types`: + - return either the existing `OrgUnitChangeRequestConfiguration` or nothing + + """ app_id = AppIdSerializer(data=self.request.query_params).get_app_id(raise_exception=True) - return ( + + org_unit_change_request_configurations = ( OrgUnitChangeRequestConfiguration.objects.filter(project__app_id=app_id) .select_related("org_unit_type") .prefetch_related( "possible_types", "possible_parent_types", "group_sets", "editable_reference_forms", "other_groups" ) - .order_by("id") + .order_by("org_unit_type_id") + ) + + user_editable_org_unit_type_ids = set( + self.request.user.iaso_profile.editable_org_unit_types.values_list("id", flat=True) + ) + + if not user_editable_org_unit_type_ids: + return org_unit_change_request_configurations + + project_org_unit_types = set(Project.objects.get(app_id=app_id).unit_types.values_list("id", flat=True)) + + non_editable_org_unit_type_ids = project_org_unit_types - user_editable_org_unit_type_ids + + dynamic_configurations = [ + OrgUnitChangeRequestConfiguration(org_unit_type_id=org_unit_type_id, org_units_editable=False) + for org_unit_type_id in non_editable_org_unit_type_ids + ] + + return list( + chain( + org_unit_change_request_configurations.exclude(org_unit_type__in=non_editable_org_unit_type_ids), + dynamic_configurations, + ) ) @swagger_auto_schema(manual_parameters=[app_id_param]) diff --git a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py index dba5815b7b..881ed5280a 100644 --- a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py +++ b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py @@ -1,5 +1,8 @@ +import json + from rest_framework import status +from iaso import models as m from iaso.tests.api.org_unit_change_request_configurations.common_base_with_setup import OUCRCAPIBase @@ -12,19 +15,121 @@ class MobileOrgUnitChangeRequestConfigurationAPITestCase(OUCRCAPIBase): def test_list_ok(self): self.client.force_authenticate(self.user_ash_ketchum) - with self.assertNumQueries(7): + with self.assertNumQueries(8): # get_queryset - # 1. COUNT(*) OrgUnitChangeRequestConfiguration - # 2. SELECT OrgUnitChangeRequestConfiguration - # 3. PREFETCH OrgUnitChangeRequestConfiguration.possible_types - # 4. PREFETCH OrgUnitChangeRequestConfiguration.possible_parent_types - # 5. PREFETCH OrgUnitChangeRequestConfiguration.group_sets - # 6. PREFETCH OrgUnitChangeRequestConfiguration.editable_reference_forms - # 7. PREFETCH OrgUnitChangeRequestConfiguration.other_groups + # 1. SELECT user_editable_org_unit_type_ids + # 2. COUNT(*) OrgUnitChangeRequestConfiguration + # 3. SELECT OrgUnitChangeRequestConfiguration + # 4. PREFETCH OrgUnitChangeRequestConfiguration.possible_types + # 5. PREFETCH OrgUnitChangeRequestConfiguration.possible_parent_types + # 6. PREFETCH OrgUnitChangeRequestConfiguration.group_sets + # 7. PREFETCH OrgUnitChangeRequestConfiguration.editable_reference_forms + # 8. PREFETCH OrgUnitChangeRequestConfiguration.other_groups response = self.client.get(f"{self.MOBILE_OUCRC_API_URL}?app_id={self.app_id}") self.assertJSONResponse(response, status.HTTP_200_OK) self.assertEqual(3, len(response.data["results"])) # the 3 OUCRCs from setup + def test_list_ok_with_restricted_write_permission_for_user(self): + # Add new Org Unit Types. + new_org_unit_type_1 = m.OrgUnitType.objects.create(name="Hospital") + new_org_unit_type_1.projects.add(self.project_johto) + new_org_unit_type_2 = m.OrgUnitType.objects.create(name="Health facility") + new_org_unit_type_2.projects.add(self.project_johto) + new_org_unit_type_3 = m.OrgUnitType.objects.create(name="District") + new_org_unit_type_3.projects.add(self.project_johto) + self.assertEqual(self.project_johto.unit_types.count(), 6) + + self.user_ash_ketchum.iaso_profile.editable_org_unit_types.set( + # Only org units of this type are now writable for this user. + [self.ou_type_fire_pokemons, new_org_unit_type_3] + ) + + self.client.force_authenticate(self.user_ash_ketchum) + + response = self.client.get(f"{self.MOBILE_OUCRC_API_URL}?app_id={self.app_id}") + self.assertJSONResponse(response, status.HTTP_200_OK) + results = response.data["results"] + + # The user has write access on `new_org_unit_type_3` at his Profile level + # and there is no existing configuration for `new_org_unit_type_3`, so this + # Org Unit Type should not be in the response meaning the user has full + # write perms on this type. + new_org_unit_type_3_config = next( + (config for config in results if config["org_unit_type_id"] == new_org_unit_type_3.pk), None + ) + self.assertEqual(new_org_unit_type_3_config, None) + + self.assertEqual(5, len(results)) # 3 OUCRCs from setup + 2 dynamic OUCRCs. + self.assertEqual( + json.loads(json.dumps(response.data["results"])), + [ + { + "created_at": self.oucrc_type_fire.created_at.timestamp(), + "editable_fields": ["name", "aliases", "location", "opening_date", "closing_date"], + "editable_reference_form_ids": list( + self.oucrc_type_fire.editable_reference_forms.values_list("id", flat=True) + ), + "group_set_ids": list(self.oucrc_type_fire.group_sets.values_list("id", flat=True)), + "org_unit_type_id": self.ou_type_fire_pokemons.pk, + "org_units_editable": True, + "other_group_ids": list(self.oucrc_type_fire.other_groups.values_list("id", flat=True)), + "possible_parent_type_ids": list( + self.oucrc_type_fire.possible_parent_types.values_list("id", flat=True) + ), + "possible_type_ids": list(self.oucrc_type_fire.possible_types.values_list("id", flat=True)), + "updated_at": self.oucrc_type_fire.updated_at.timestamp(), + }, + { + "created_at": None, + "editable_fields": [], + "editable_reference_form_ids": [], + "group_set_ids": [], + "org_unit_type_id": self.ou_type_rock_pokemons.pk, + "org_units_editable": False, + "other_group_ids": [], + "possible_parent_type_ids": [], + "possible_type_ids": [], + "updated_at": None, + }, + { + "created_at": None, + "editable_fields": [], + "editable_reference_form_ids": [], + "group_set_ids": [], + "org_unit_type_id": self.ou_type_water_pokemons.pk, + "org_units_editable": False, + "other_group_ids": [], + "possible_parent_type_ids": [], + "possible_type_ids": [], + "updated_at": None, + }, + { + "created_at": None, + "editable_fields": [], + "editable_reference_form_ids": [], + "group_set_ids": [], + "org_unit_type_id": new_org_unit_type_1.id, + "org_units_editable": False, + "other_group_ids": [], + "possible_parent_type_ids": [], + "possible_type_ids": [], + "updated_at": None, + }, + { + "created_at": None, + "editable_fields": [], + "editable_reference_form_ids": [], + "group_set_ids": [], + "org_unit_type_id": new_org_unit_type_2.id, + "org_units_editable": False, + "other_group_ids": [], + "possible_parent_type_ids": [], + "possible_type_ids": [], + "updated_at": None, + }, + ], + ) + def test_list_without_auth(self): response = self.client.get(f"{self.MOBILE_OUCRC_API_URL}?app_id={self.app_id}") self.assertJSONResponse(response, status.HTTP_401_UNAUTHORIZED) From e4ff5b495d302fc1ffd4a3bb8d23ad43a7fe31f0 Mon Sep 17 00:00:00 2001 From: kemar Date: Mon, 14 Oct 2024 16:20:12 +0200 Subject: [PATCH 21/60] Change test names --- iaso/tests/api/test_org_units_bulk_update.py | 2 +- iaso/tests/api/test_orgunits.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/iaso/tests/api/test_org_units_bulk_update.py b/iaso/tests/api/test_org_units_bulk_update.py index 05c5412706..ddb21b5af7 100644 --- a/iaso/tests/api/test_org_units_bulk_update.py +++ b/iaso/tests/api/test_org_units_bulk_update.py @@ -247,7 +247,7 @@ def test_org_unit_bulkupdate_select_all(self): self.assertEqual(5, am.Modification.objects.count()) @tag("iaso_only") - def test_org_unit_bulkupdate_select_all_without_write_permission(self): + def test_org_unit_bulkupdate_select_all_with_restricted_write_permission_for_user(self): """ Check that we cannot bulk edit all org units if writing rights are limited by a set of org unit types that we are allowed to modify. diff --git a/iaso/tests/api/test_orgunits.py b/iaso/tests/api/test_orgunits.py index 1da808a993..c8458f6a34 100644 --- a/iaso/tests/api/test_orgunits.py +++ b/iaso/tests/api/test_orgunits.py @@ -607,7 +607,7 @@ def test_create_org_unit_with_read_permission(self): response = self.set_up_org_unit_creation() self.assertJSONResponse(response, 403) - def test_create_org_unit_without_write_permission(self): + def test_create_org_unit_with_restricted_write_permission_for_user(self): """ Check that we cannot create an org unit if writing rights are limited by a set of org unit types that we are allowed to modify. @@ -1015,7 +1015,7 @@ def test_edit_org_unit_partial_update_read_permission(self): ) self.assertJSONResponse(response, 403) - def test_edit_org_unit_without_write_permission(self): + def test_edit_org_unit_with_restricted_write_permission_for_user(self): """ Check that we cannot edit an org unit if writing rights are limited by a set of org unit types that we are allowed to modify. From 1a5aaa8277ea7c364e24f8878431911d64978a53 Mon Sep 17 00:00:00 2001 From: kemar Date: Mon, 14 Oct 2024 17:15:13 +0200 Subject: [PATCH 22/60] Fix tests --- .../views_mobile.py | 4 +- ..._org_unit_change_request_configs_mobile.py | 164 +++++++++++------- 2 files changed, 100 insertions(+), 68 deletions(-) diff --git a/iaso/api/org_unit_change_request_configurations/views_mobile.py b/iaso/api/org_unit_change_request_configurations/views_mobile.py index a6b33b479e..fbf9e417df 100644 --- a/iaso/api/org_unit_change_request_configurations/views_mobile.py +++ b/iaso/api/org_unit_change_request_configurations/views_mobile.py @@ -63,7 +63,7 @@ def get_queryset(self): .prefetch_related( "possible_types", "possible_parent_types", "group_sets", "editable_reference_forms", "other_groups" ) - .order_by("org_unit_type_id") + .order_by("id") ) user_editable_org_unit_type_ids = set( @@ -82,6 +82,8 @@ def get_queryset(self): for org_unit_type_id in non_editable_org_unit_type_ids ] + # A queryset is a representation of a database query, so it's difficult to add unsaved objects manually. + # This trick will return a list but some features like `order_by` will not work for unsaved objects. return list( chain( org_unit_change_request_configurations.exclude(org_unit_type__in=non_editable_org_unit_type_ids), diff --git a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py index 881ed5280a..bc1b1ac127 100644 --- a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py +++ b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py @@ -60,74 +60,104 @@ def test_list_ok_with_restricted_write_permission_for_user(self): self.assertEqual(new_org_unit_type_3_config, None) self.assertEqual(5, len(results)) # 3 OUCRCs from setup + 2 dynamic OUCRCs. + + ou_type_fire_pokemons_config = next( + (config for config in results if config["org_unit_type_id"] == self.ou_type_fire_pokemons.pk) + ) + self.assertEqual( + ou_type_fire_pokemons_config, + { + "org_unit_type_id": self.ou_type_fire_pokemons.pk, + "org_units_editable": True, + "editable_fields": ["name", "aliases", "location", "opening_date", "closing_date"], + "possible_type_ids": list(self.oucrc_type_fire.possible_types.values_list("id", flat=True)), + "possible_parent_type_ids": list( + self.oucrc_type_fire.possible_parent_types.values_list("id", flat=True) + ), + "group_set_ids": list(self.oucrc_type_fire.group_sets.values_list("id", flat=True)), + "editable_reference_form_ids": list( + self.oucrc_type_fire.editable_reference_forms.values_list("id", flat=True) + ), + "other_group_ids": list(self.oucrc_type_fire.other_groups.values_list("id", flat=True)), + "created_at": self.oucrc_type_fire.created_at.timestamp(), + "updated_at": self.oucrc_type_fire.updated_at.timestamp(), + }, + ) + + ou_type_rock_pokemons_config = next( + (config for config in results if config["org_unit_type_id"] == self.ou_type_rock_pokemons.pk) + ) + self.assertEqual( + ou_type_rock_pokemons_config, + { + "org_unit_type_id": self.ou_type_rock_pokemons.pk, + "org_units_editable": False, + "editable_fields": [], + "possible_type_ids": [], + "possible_parent_type_ids": [], + "group_set_ids": [], + "editable_reference_form_ids": [], + "other_group_ids": [], + "created_at": None, + "updated_at": None, + }, + ) + + ou_type_water_pokemons_config = next( + (config for config in results if config["org_unit_type_id"] == self.ou_type_water_pokemons.pk) + ) + self.assertEqual( + ou_type_water_pokemons_config, + { + "org_unit_type_id": self.ou_type_water_pokemons.pk, + "org_units_editable": False, + "editable_fields": [], + "possible_type_ids": [], + "possible_parent_type_ids": [], + "group_set_ids": [], + "editable_reference_form_ids": [], + "other_group_ids": [], + "created_at": None, + "updated_at": None, + }, + ) + + new_org_unit_type_1_config = next( + (config for config in results if config["org_unit_type_id"] == new_org_unit_type_1.pk) + ) + self.assertEqual( + new_org_unit_type_1_config, + { + "org_unit_type_id": new_org_unit_type_1.pk, + "org_units_editable": False, + "editable_fields": [], + "possible_type_ids": [], + "possible_parent_type_ids": [], + "group_set_ids": [], + "editable_reference_form_ids": [], + "other_group_ids": [], + "created_at": None, + "updated_at": None, + }, + ) + + new_org_unit_type_2_config = next( + (config for config in results if config["org_unit_type_id"] == new_org_unit_type_2.pk) + ) self.assertEqual( - json.loads(json.dumps(response.data["results"])), - [ - { - "created_at": self.oucrc_type_fire.created_at.timestamp(), - "editable_fields": ["name", "aliases", "location", "opening_date", "closing_date"], - "editable_reference_form_ids": list( - self.oucrc_type_fire.editable_reference_forms.values_list("id", flat=True) - ), - "group_set_ids": list(self.oucrc_type_fire.group_sets.values_list("id", flat=True)), - "org_unit_type_id": self.ou_type_fire_pokemons.pk, - "org_units_editable": True, - "other_group_ids": list(self.oucrc_type_fire.other_groups.values_list("id", flat=True)), - "possible_parent_type_ids": list( - self.oucrc_type_fire.possible_parent_types.values_list("id", flat=True) - ), - "possible_type_ids": list(self.oucrc_type_fire.possible_types.values_list("id", flat=True)), - "updated_at": self.oucrc_type_fire.updated_at.timestamp(), - }, - { - "created_at": None, - "editable_fields": [], - "editable_reference_form_ids": [], - "group_set_ids": [], - "org_unit_type_id": self.ou_type_rock_pokemons.pk, - "org_units_editable": False, - "other_group_ids": [], - "possible_parent_type_ids": [], - "possible_type_ids": [], - "updated_at": None, - }, - { - "created_at": None, - "editable_fields": [], - "editable_reference_form_ids": [], - "group_set_ids": [], - "org_unit_type_id": self.ou_type_water_pokemons.pk, - "org_units_editable": False, - "other_group_ids": [], - "possible_parent_type_ids": [], - "possible_type_ids": [], - "updated_at": None, - }, - { - "created_at": None, - "editable_fields": [], - "editable_reference_form_ids": [], - "group_set_ids": [], - "org_unit_type_id": new_org_unit_type_1.id, - "org_units_editable": False, - "other_group_ids": [], - "possible_parent_type_ids": [], - "possible_type_ids": [], - "updated_at": None, - }, - { - "created_at": None, - "editable_fields": [], - "editable_reference_form_ids": [], - "group_set_ids": [], - "org_unit_type_id": new_org_unit_type_2.id, - "org_units_editable": False, - "other_group_ids": [], - "possible_parent_type_ids": [], - "possible_type_ids": [], - "updated_at": None, - }, - ], + new_org_unit_type_2_config, + { + "org_unit_type_id": new_org_unit_type_2.pk, + "org_units_editable": False, + "editable_fields": [], + "possible_type_ids": [], + "possible_parent_type_ids": [], + "group_set_ids": [], + "editable_reference_form_ids": [], + "other_group_ids": [], + "created_at": None, + "updated_at": None, + }, ) def test_list_without_auth(self): From 526c4c4e548ce278adb6b5c93d0a5b431359bba0 Mon Sep 17 00:00:00 2001 From: kemar Date: Tue, 15 Oct 2024 10:57:09 +0200 Subject: [PATCH 23/60] Keep the pagination working properly --- .../views_mobile.py | 84 ++++---- ..._org_unit_change_request_configs_mobile.py | 184 ++++++++---------- 2 files changed, 124 insertions(+), 144 deletions(-) diff --git a/iaso/api/org_unit_change_request_configurations/views_mobile.py b/iaso/api/org_unit_change_request_configurations/views_mobile.py index fbf9e417df..afac603900 100644 --- a/iaso/api/org_unit_change_request_configurations/views_mobile.py +++ b/iaso/api/org_unit_change_request_configurations/views_mobile.py @@ -4,7 +4,6 @@ from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema -from rest_framework import filters from rest_framework import viewsets from rest_framework.mixins import ListModelMixin from rest_framework.request import Request @@ -24,7 +23,7 @@ class MobileOrgUnitChangeRequestConfigurationViewSet(ListModelMixin, viewsets.GenericViewSet): permission_classes = [HasOrgUnitsChangeRequestConfigurationReadPermission] - filter_backends = [filters.OrderingFilter, django_filters.rest_framework.DjangoFilterBackend] + filter_backends = [django_filters.rest_framework.DjangoFilterBackend] serializer_class = MobileOrgUnitChangeRequestConfigurationListSerializer pagination_class = OrgUnitChangeRequestConfigurationPagination @@ -37,60 +36,71 @@ class MobileOrgUnitChangeRequestConfigurationViewSet(ListModelMixin, viewsets.Ge ) def get_queryset(self): + app_id = AppIdSerializer(data=self.request.query_params).get_app_id(raise_exception=True) + return ( + OrgUnitChangeRequestConfiguration.objects.filter(project__app_id=app_id) + .select_related("org_unit_type") + .prefetch_related( + "possible_types", "possible_parent_types", "group_sets", "editable_reference_forms", "other_groups" + ) + .order_by("org_unit_type_id") + ) + + @swagger_auto_schema(manual_parameters=[app_id_param]) + def list(self, request: Request, *args, **kwargs) -> Response: """ Because some Org Unit Type restrictions are also configurable at the `Profile` level, - we implement the following logic here: + we implement the following logic in the list view: 1. If `Profile.editable_org_unit_types` empty: - - return `OrgUnitChangeRequestConfiguration` + - return `OrgUnitChangeRequestConfiguration` content 2. If `Profile.editable_org_unit_types` not empty: - 2a. for org_unit_type not in `Profile.editable_org_unit_types`: + a. for org_unit_type not in `Profile.editable_org_unit_types`: + - return a dynamic configuration that says `org_units_editable: False` - regardless of any existing `OrgUnitChangeRequestConfiguration` - 2b. for org_unit_type in `Profile.editable_org_unit_types`: - - return either the existing `OrgUnitChangeRequestConfiguration` or nothing + b. for org_unit_type in `Profile.editable_org_unit_types`: + + - return either the existing `OrgUnitChangeRequestConfiguration` content or nothing """ app_id = AppIdSerializer(data=self.request.query_params).get_app_id(raise_exception=True) - - org_unit_change_request_configurations = ( - OrgUnitChangeRequestConfiguration.objects.filter(project__app_id=app_id) - .select_related("org_unit_type") - .prefetch_related( - "possible_types", "possible_parent_types", "group_sets", "editable_reference_forms", "other_groups" - ) - .order_by("id") - ) + queryset = self.get_queryset() user_editable_org_unit_type_ids = set( self.request.user.iaso_profile.editable_org_unit_types.values_list("id", flat=True) ) - if not user_editable_org_unit_type_ids: - return org_unit_change_request_configurations - - project_org_unit_types = set(Project.objects.get(app_id=app_id).unit_types.values_list("id", flat=True)) - - non_editable_org_unit_type_ids = project_org_unit_types - user_editable_org_unit_type_ids + if user_editable_org_unit_type_ids: + project_org_unit_types = set(Project.objects.get(app_id=app_id).unit_types.values_list("id", flat=True)) + non_editable_org_unit_type_ids = project_org_unit_types - user_editable_org_unit_type_ids + + dynamic_configurations = [ + OrgUnitChangeRequestConfiguration(org_unit_type_id=org_unit_type_id, org_units_editable=False) + for org_unit_type_id in non_editable_org_unit_type_ids + ] + + # Because we're merging unsaved instances with a queryset (which is a representation of a database query), + # we have to sort the resulting list manually we have to keep the pagination working properly. + queryset = list( + chain( + queryset.exclude(org_unit_type__in=non_editable_org_unit_type_ids), + dynamic_configurations, + ) + ) + # Unsaved instances do not have an `id`, so we're sorting on `org_unit_type_id` in all cases. + queryset = sorted(queryset, key=lambda item: item.org_unit_type_id) - dynamic_configurations = [ - OrgUnitChangeRequestConfiguration(org_unit_type_id=org_unit_type_id, org_units_editable=False) - for org_unit_type_id in non_editable_org_unit_type_ids - ] + queryset = self.filter_queryset(queryset) - # A queryset is a representation of a database query, so it's difficult to add unsaved objects manually. - # This trick will return a list but some features like `order_by` will not work for unsaved objects. - return list( - chain( - org_unit_change_request_configurations.exclude(org_unit_type__in=non_editable_org_unit_type_ids), - dynamic_configurations, - ) - ) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) - @swagger_auto_schema(manual_parameters=[app_id_param]) - def list(self, request: Request, *args, **kwargs) -> Response: - return super().list(request, *args, **kwargs) + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) diff --git a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py index bc1b1ac127..a114b14fc7 100644 --- a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py +++ b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py @@ -46,118 +46,88 @@ def test_list_ok_with_restricted_write_permission_for_user(self): self.client.force_authenticate(self.user_ash_ketchum) - response = self.client.get(f"{self.MOBILE_OUCRC_API_URL}?app_id={self.app_id}") - self.assertJSONResponse(response, status.HTTP_200_OK) - results = response.data["results"] - - # The user has write access on `new_org_unit_type_3` at his Profile level - # and there is no existing configuration for `new_org_unit_type_3`, so this - # Org Unit Type should not be in the response meaning the user has full - # write perms on this type. - new_org_unit_type_3_config = next( - (config for config in results if config["org_unit_type_id"] == new_org_unit_type_3.pk), None - ) - self.assertEqual(new_org_unit_type_3_config, None) + with self.assertNumQueries(9): + response = self.client.get(f"{self.MOBILE_OUCRC_API_URL}?app_id={self.app_id}") + self.assertJSONResponse(response, status.HTTP_200_OK) + results = response.data["results"] self.assertEqual(5, len(results)) # 3 OUCRCs from setup + 2 dynamic OUCRCs. - ou_type_fire_pokemons_config = next( - (config for config in results if config["org_unit_type_id"] == self.ou_type_fire_pokemons.pk) - ) - self.assertEqual( - ou_type_fire_pokemons_config, - { - "org_unit_type_id": self.ou_type_fire_pokemons.pk, - "org_units_editable": True, - "editable_fields": ["name", "aliases", "location", "opening_date", "closing_date"], - "possible_type_ids": list(self.oucrc_type_fire.possible_types.values_list("id", flat=True)), - "possible_parent_type_ids": list( - self.oucrc_type_fire.possible_parent_types.values_list("id", flat=True) - ), - "group_set_ids": list(self.oucrc_type_fire.group_sets.values_list("id", flat=True)), - "editable_reference_form_ids": list( - self.oucrc_type_fire.editable_reference_forms.values_list("id", flat=True) - ), - "other_group_ids": list(self.oucrc_type_fire.other_groups.values_list("id", flat=True)), - "created_at": self.oucrc_type_fire.created_at.timestamp(), - "updated_at": self.oucrc_type_fire.updated_at.timestamp(), - }, - ) + # `new_org_unit_type_3` should not be in the response because the user + # have full write permission on it: + # - the user has write access on `new_org_unit_type_3` at his Profile level + # - there is no existing configuration for `new_org_unit_type_3` + configs_org_unit_type_ids = [config["org_unit_type_id"] for config in results] + self.assertNotIn(new_org_unit_type_3.pk, configs_org_unit_type_ids) - ou_type_rock_pokemons_config = next( - (config for config in results if config["org_unit_type_id"] == self.ou_type_rock_pokemons.pk) - ) - self.assertEqual( - ou_type_rock_pokemons_config, - { - "org_unit_type_id": self.ou_type_rock_pokemons.pk, - "org_units_editable": False, - "editable_fields": [], - "possible_type_ids": [], - "possible_parent_type_ids": [], - "group_set_ids": [], - "editable_reference_form_ids": [], - "other_group_ids": [], - "created_at": None, - "updated_at": None, - }, - ) - - ou_type_water_pokemons_config = next( - (config for config in results if config["org_unit_type_id"] == self.ou_type_water_pokemons.pk) - ) - self.assertEqual( - ou_type_water_pokemons_config, - { - "org_unit_type_id": self.ou_type_water_pokemons.pk, - "org_units_editable": False, - "editable_fields": [], - "possible_type_ids": [], - "possible_parent_type_ids": [], - "group_set_ids": [], - "editable_reference_form_ids": [], - "other_group_ids": [], - "created_at": None, - "updated_at": None, - }, - ) - - new_org_unit_type_1_config = next( - (config for config in results if config["org_unit_type_id"] == new_org_unit_type_1.pk) - ) - self.assertEqual( - new_org_unit_type_1_config, - { - "org_unit_type_id": new_org_unit_type_1.pk, - "org_units_editable": False, - "editable_fields": [], - "possible_type_ids": [], - "possible_parent_type_ids": [], - "group_set_ids": [], - "editable_reference_form_ids": [], - "other_group_ids": [], - "created_at": None, - "updated_at": None, - }, - ) - - new_org_unit_type_2_config = next( - (config for config in results if config["org_unit_type_id"] == new_org_unit_type_2.pk) - ) self.assertEqual( - new_org_unit_type_2_config, - { - "org_unit_type_id": new_org_unit_type_2.pk, - "org_units_editable": False, - "editable_fields": [], - "possible_type_ids": [], - "possible_parent_type_ids": [], - "group_set_ids": [], - "editable_reference_form_ids": [], - "other_group_ids": [], - "created_at": None, - "updated_at": None, - }, + results, + [ + { + "org_unit_type_id": self.ou_type_fire_pokemons.pk, + "org_units_editable": True, + "editable_fields": ["name", "aliases", "location", "opening_date", "closing_date"], + "possible_type_ids": list(self.oucrc_type_fire.possible_types.values_list("id", flat=True)), + "possible_parent_type_ids": list( + self.oucrc_type_fire.possible_parent_types.values_list("id", flat=True) + ), + "group_set_ids": list(self.oucrc_type_fire.group_sets.values_list("id", flat=True)), + "editable_reference_form_ids": list( + self.oucrc_type_fire.editable_reference_forms.values_list("id", flat=True) + ), + "other_group_ids": list(self.oucrc_type_fire.other_groups.values_list("id", flat=True)), + "created_at": self.oucrc_type_fire.created_at.timestamp(), + "updated_at": self.oucrc_type_fire.updated_at.timestamp(), + }, + { + "org_unit_type_id": self.ou_type_rock_pokemons.pk, + "org_units_editable": False, + "editable_fields": [], + "possible_type_ids": [], + "possible_parent_type_ids": [], + "group_set_ids": [], + "editable_reference_form_ids": [], + "other_group_ids": [], + "created_at": None, + "updated_at": None, + }, + { + "org_unit_type_id": self.ou_type_water_pokemons.pk, + "org_units_editable": False, + "editable_fields": [], + "possible_type_ids": [], + "possible_parent_type_ids": [], + "group_set_ids": [], + "editable_reference_form_ids": [], + "other_group_ids": [], + "created_at": None, + "updated_at": None, + }, + { + "org_unit_type_id": new_org_unit_type_1.pk, + "org_units_editable": False, + "editable_fields": [], + "possible_type_ids": [], + "possible_parent_type_ids": [], + "group_set_ids": [], + "editable_reference_form_ids": [], + "other_group_ids": [], + "created_at": None, + "updated_at": None, + }, + { + "org_unit_type_id": new_org_unit_type_2.pk, + "org_units_editable": False, + "editable_fields": [], + "possible_type_ids": [], + "possible_parent_type_ids": [], + "group_set_ids": [], + "editable_reference_form_ids": [], + "other_group_ids": [], + "created_at": None, + "updated_at": None, + }, + ], ) def test_list_without_auth(self): From fb2e376fff298faad7910cb353adeaf73e8d6a8f Mon Sep 17 00:00:00 2001 From: kemar Date: Tue, 15 Oct 2024 11:27:22 +0200 Subject: [PATCH 24/60] Add comments --- .../test_org_unit_change_request_configs_mobile.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py index a114b14fc7..de310ea32e 100644 --- a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py +++ b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py @@ -51,7 +51,7 @@ def test_list_ok_with_restricted_write_permission_for_user(self): self.assertJSONResponse(response, status.HTTP_200_OK) results = response.data["results"] - self.assertEqual(5, len(results)) # 3 OUCRCs from setup + 2 dynamic OUCRCs. + self.assertEqual(5, len(results)) # `new_org_unit_type_3` should not be in the response because the user # have full write permission on it: @@ -63,6 +63,9 @@ def test_list_ok_with_restricted_write_permission_for_user(self): self.assertEqual( results, [ + # The user has write access on `ou_type_fire_pokemons` at his Profile level, + # and there is an existing configuration for `ou_type_fire_pokemons`, so we + # return the configuration. { "org_unit_type_id": self.ou_type_fire_pokemons.pk, "org_units_editable": True, @@ -79,6 +82,8 @@ def test_list_ok_with_restricted_write_permission_for_user(self): "created_at": self.oucrc_type_fire.created_at.timestamp(), "updated_at": self.oucrc_type_fire.updated_at.timestamp(), }, + # Because of the configuration of his Profile, the user can't write on `ou_type_rock_pokemons`, + # so we override the existing configuration. { "org_unit_type_id": self.ou_type_rock_pokemons.pk, "org_units_editable": False, @@ -91,6 +96,8 @@ def test_list_ok_with_restricted_write_permission_for_user(self): "created_at": None, "updated_at": None, }, + # Because of the configuration of his Profile, the user can't write on `ou_type_water_pokemons`, + # so we override the existing configuration. { "org_unit_type_id": self.ou_type_water_pokemons.pk, "org_units_editable": False, @@ -103,6 +110,8 @@ def test_list_ok_with_restricted_write_permission_for_user(self): "created_at": None, "updated_at": None, }, + # Because of the configuration of his Profile, the user can't write on `new_org_unit_type_1`, + # and since there is no existing configuration, we add a dynamic one. { "org_unit_type_id": new_org_unit_type_1.pk, "org_units_editable": False, @@ -115,6 +124,8 @@ def test_list_ok_with_restricted_write_permission_for_user(self): "created_at": None, "updated_at": None, }, + # Because of the configuration of his Profile, the user can't write on `new_org_unit_type_2`, + # and since there is no existing configuration, we add a dynamic one. { "org_unit_type_id": new_org_unit_type_2.pk, "org_units_editable": False, From 2f1bc9aaebb2805f3dd7e6eef4a681e83b1af0f2 Mon Sep 17 00:00:00 2001 From: kemar Date: Tue, 15 Oct 2024 11:28:10 +0200 Subject: [PATCH 25/60] Fix typo --- iaso/api/org_unit_change_request_configurations/views_mobile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iaso/api/org_unit_change_request_configurations/views_mobile.py b/iaso/api/org_unit_change_request_configurations/views_mobile.py index afac603900..65997038a8 100644 --- a/iaso/api/org_unit_change_request_configurations/views_mobile.py +++ b/iaso/api/org_unit_change_request_configurations/views_mobile.py @@ -85,7 +85,7 @@ def list(self, request: Request, *args, **kwargs) -> Response: ] # Because we're merging unsaved instances with a queryset (which is a representation of a database query), - # we have to sort the resulting list manually we have to keep the pagination working properly. + # we have to sort the resulting list manually to keep the pagination working properly. queryset = list( chain( queryset.exclude(org_unit_type__in=non_editable_org_unit_type_ids), From a9e046c26375364b3f6fd15fb1975ed2e61d683d Mon Sep 17 00:00:00 2001 From: kemar Date: Thu, 17 Oct 2024 15:09:12 +0200 Subject: [PATCH 26/60] Improve bulkupdate test --- iaso/tests/api/test_org_units_bulk_update.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/iaso/tests/api/test_org_units_bulk_update.py b/iaso/tests/api/test_org_units_bulk_update.py index ddb21b5af7..e5f193f5ed 100644 --- a/iaso/tests/api/test_org_units_bulk_update.py +++ b/iaso/tests/api/test_org_units_bulk_update.py @@ -260,7 +260,7 @@ def test_org_unit_bulkupdate_select_all_with_restricted_write_permission_for_use response = self.client.post( f"/api/tasks/create/orgunitsbulkupdate/", - data={"select_all": True, "validation_status": m.OrgUnit.VALIDATION_VALID}, + data={"select_all": True, "validation_status": m.OrgUnit.VALIDATION_REJECTED}, format="json", ) self.assertJSONResponse(response, 201) @@ -271,10 +271,6 @@ def test_org_unit_bulkupdate_select_all_with_restricted_write_permission_for_use # Run the task. self.runAndValidateTask(task, "SUCCESS") - for org_unit in [self.jedi_squad_endor_1, self.jedi_squad_endor_2]: - org_unit.refresh_from_db() - self.assertEqual(org_unit.validation_status, m.OrgUnit.VALIDATION_VALID) - self.assertEqual(2, am.Modification.objects.count()) task.refresh_from_db() @@ -283,6 +279,14 @@ def test_org_unit_bulkupdate_select_all_with_restricted_write_permission_for_use self.yoda.iaso_profile.editable_org_unit_types.clear() + for org_unit in [self.jedi_squad_endor_1, self.jedi_squad_endor_2]: + org_unit.refresh_from_db() + self.assertEqual(org_unit.validation_status, m.OrgUnit.VALIDATION_REJECTED) + + for org_unit in [self.jedi_council_corruscant, self.jedi_council_endor, self.jedi_council_brussels]: + org_unit.refresh_from_db() + self.assertEqual(org_unit.validation_status, m.OrgUnit.VALIDATION_VALID) + @tag("iaso_only") def test_org_unit_bulkupdate_select_all_with_search(self): """POST /orgunits/bulkupdate happy path (select all, but with search)""" From 667c082fa4cb80920d7ade0ecd79566a26982743 Mon Sep 17 00:00:00 2001 From: kemar Date: Wed, 16 Oct 2024 15:50:43 +0200 Subject: [PATCH 27/60] Implement org unit type restrictions in `UserRole` --- .../views_mobile.py | 4 +- .../0305_userrole_editable_org_unit_types.py | 17 ++++++++ iaso/models/base.py | 16 ++++++-- iaso/tasks/org_units_bulk_update.py | 2 +- ..._org_unit_change_request_configs_mobile.py | 18 ++++++-- iaso/tests/models/test_profile.py | 41 +++++++++++++++++-- 6 files changed, 83 insertions(+), 15 deletions(-) create mode 100644 iaso/migrations/0305_userrole_editable_org_unit_types.py diff --git a/iaso/api/org_unit_change_request_configurations/views_mobile.py b/iaso/api/org_unit_change_request_configurations/views_mobile.py index 65997038a8..8e2efd9459 100644 --- a/iaso/api/org_unit_change_request_configurations/views_mobile.py +++ b/iaso/api/org_unit_change_request_configurations/views_mobile.py @@ -71,9 +71,7 @@ def list(self, request: Request, *args, **kwargs) -> Response: app_id = AppIdSerializer(data=self.request.query_params).get_app_id(raise_exception=True) queryset = self.get_queryset() - user_editable_org_unit_type_ids = set( - self.request.user.iaso_profile.editable_org_unit_types.values_list("id", flat=True) - ) + user_editable_org_unit_type_ids = self.request.user.iaso_profile.get_editable_org_unit_type_ids() if user_editable_org_unit_type_ids: project_org_unit_types = set(Project.objects.get(app_id=app_id).unit_types.values_list("id", flat=True)) diff --git a/iaso/migrations/0305_userrole_editable_org_unit_types.py b/iaso/migrations/0305_userrole_editable_org_unit_types.py new file mode 100644 index 0000000000..b17617a8c7 --- /dev/null +++ b/iaso/migrations/0305_userrole_editable_org_unit_types.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2024-10-16 13:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0304_profile_org_unit_types"), + ] + + operations = [ + migrations.AddField( + model_name="userrole", + name="editable_org_unit_types", + field=models.ManyToManyField(blank=True, related_name="editable_by_user_role_set", to="iaso.orgunittype"), + ), + ] diff --git a/iaso/models/base.py b/iaso/models/base.py index d649da7aec..cc7c6c506a 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -1508,12 +1508,15 @@ def has_a_team(self): return True return False + def get_editable_org_unit_type_ids(self) -> set[int]: + ids_in_user_roles = set(self.user_roles.values_list("editable_org_unit_types", flat=True)) + ids_in_user_profile = set(self.editable_org_unit_types.values_list("id", flat=True)) + return ids_in_user_profile.union(ids_in_user_roles) + def has_org_unit_write_permission( - self, org_unit_type_id: int, prefetched_editable_org_unit_type_ids: list = None + self, org_unit_type_id: int, prefetched_editable_org_unit_type_ids: set[int] = None ) -> bool: - editable_org_unit_type_ids = prefetched_editable_org_unit_type_ids or list( - self.editable_org_unit_types.values_list("id", flat=True) - ) + editable_org_unit_type_ids = prefetched_editable_org_unit_type_ids or self.get_editable_org_unit_type_ids() if not editable_org_unit_type_ids: return True return org_unit_type_id in editable_org_unit_type_ids @@ -1674,6 +1677,11 @@ class UserRole(models.Model): group = models.OneToOneField(auth.models.Group, on_delete=models.CASCADE, related_name="iaso_user_role") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # Each user can have restricted write access to OrgUnits, based on their type. + # By default, empty `editable_org_unit_types` means access to everything. + editable_org_unit_types = models.ManyToManyField( + "OrgUnitType", related_name="editable_by_user_role_set", blank=True + ) def __str__(self) -> str: return self.group.name diff --git a/iaso/tasks/org_units_bulk_update.py b/iaso/tasks/org_units_bulk_update.py index 0ce753dda0..6598e21b0d 100644 --- a/iaso/tasks/org_units_bulk_update.py +++ b/iaso/tasks/org_units_bulk_update.py @@ -81,7 +81,7 @@ def org_units_bulk_update( raise Exception("Modification on read only source are not allowed") total = queryset.count() - editable_org_unit_type_ids = list(user.iaso_profile.editable_org_unit_types.values_list("id", flat=True)) + editable_org_unit_type_ids = user.iaso_profile.get_editable_org_unit_type_ids() skipped_messages = [] # FIXME Task don't handle rollback properly if task is killed by user or other error diff --git a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py index de310ea32e..64e6fab19d 100644 --- a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py +++ b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py @@ -1,7 +1,7 @@ -import json - from rest_framework import status +from django.contrib.auth.models import Group + from iaso import models as m from iaso.tests.api.org_unit_change_request_configurations.common_base_with_setup import OUCRCAPIBase @@ -39,14 +39,24 @@ def test_list_ok_with_restricted_write_permission_for_user(self): new_org_unit_type_3.projects.add(self.project_johto) self.assertEqual(self.project_johto.unit_types.count(), 6) + # Restrict write permissions on Org Units at the "Profile" level. self.user_ash_ketchum.iaso_profile.editable_org_unit_types.set( # Only org units of this type are now writable for this user. - [self.ou_type_fire_pokemons, new_org_unit_type_3] + [self.ou_type_fire_pokemons] + ) + + # Restrict write permissions on Org Units at the "Role" level. + group = Group.objects.create(name="Group") + user_role = m.UserRole.objects.create(group=group, account=self.account_pokemon) + user_role.editable_org_unit_types.set( + # Only org units of this type are now writable for this user. + [new_org_unit_type_3] ) + self.user_ash_ketchum.iaso_profile.user_roles.set([user_role]) self.client.force_authenticate(self.user_ash_ketchum) - with self.assertNumQueries(9): + with self.assertNumQueries(10): response = self.client.get(f"{self.MOBILE_OUCRC_API_URL}?app_id={self.app_id}") self.assertJSONResponse(response, status.HTTP_200_OK) diff --git a/iaso/tests/models/test_profile.py b/iaso/tests/models/test_profile.py index f4cf14de82..207a8012c7 100644 --- a/iaso/tests/models/test_profile.py +++ b/iaso/tests/models/test_profile.py @@ -1,3 +1,5 @@ +from django.contrib.auth.models import Group + from iaso import models as m from iaso.models.microplanning import Team from iaso.test import TestCase @@ -22,16 +24,49 @@ def test_user_has_team(self): def test_has_org_unit_write_permission(self): org_unit_type_country = m.OrgUnitType.objects.create(name="Country") org_unit_type_region = m.OrgUnitType.objects.create(name="Region") + org_unit_type_district = m.OrgUnitType.objects.create(name="District") + org_unit_type_town = m.OrgUnitType.objects.create(name="Town") + + group_1 = Group.objects.create(name="Group 1") + user_role_1 = m.UserRole.objects.create(group=group_1, account=self.account) + user_role_1.editable_org_unit_types.set([org_unit_type_district]) + + group_2 = Group.objects.create(name="Group 2") + user_role_2 = m.UserRole.objects.create(group=group_2, account=self.account) + user_role_2.editable_org_unit_types.set([org_unit_type_town, org_unit_type_region]) - with self.assertNumQueries(1): + with self.assertNumQueries(2): self.assertTrue(self.profile1.has_org_unit_write_permission(org_unit_type_country.pk)) self.profile1.editable_org_unit_types.set([org_unit_type_country]) - with self.assertNumQueries(1): + with self.assertNumQueries(2): self.assertFalse(self.profile1.has_org_unit_write_permission(org_unit_type_region.pk)) + with self.assertNumQueries(2): + self.assertTrue(self.profile1.has_org_unit_write_permission(org_unit_type_country.pk)) self.profile1.editable_org_unit_types.clear() self.profile1.editable_org_unit_types.set([org_unit_type_region]) - with self.assertNumQueries(1): + with self.assertNumQueries(2): self.assertTrue(self.profile1.has_org_unit_write_permission(org_unit_type_region.pk)) self.profile1.editable_org_unit_types.clear() + + self.profile1.user_roles.set([user_role_1, user_role_2]) + with self.assertNumQueries(2): + editable_org_unit_type_ids = self.profile1.get_editable_org_unit_type_ids() + with self.assertNumQueries(0): + self.assertTrue( + self.profile1.has_org_unit_write_permission( + org_unit_type_district.pk, prefetched_editable_org_unit_type_ids=editable_org_unit_type_ids + ) + ) + self.assertTrue( + self.profile1.has_org_unit_write_permission( + org_unit_type_town.pk, prefetched_editable_org_unit_type_ids=editable_org_unit_type_ids + ) + ) + self.assertTrue( + self.profile1.has_org_unit_write_permission( + org_unit_type_region.pk, prefetched_editable_org_unit_type_ids=editable_org_unit_type_ids + ) + ) + self.profile1.user_roles.clear() From 2bff9e309d452250c553b9c78b427d9f1c54905e Mon Sep 17 00:00:00 2001 From: kemar Date: Wed, 16 Oct 2024 16:00:34 +0200 Subject: [PATCH 28/60] Fix tests --- ...st_org_unit_change_request_configs_mobile.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py index 64e6fab19d..9fe549620a 100644 --- a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py +++ b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py @@ -15,16 +15,17 @@ class MobileOrgUnitChangeRequestConfigurationAPITestCase(OUCRCAPIBase): def test_list_ok(self): self.client.force_authenticate(self.user_ash_ketchum) - with self.assertNumQueries(8): + with self.assertNumQueries(9): # get_queryset # 1. SELECT user_editable_org_unit_type_ids - # 2. COUNT(*) OrgUnitChangeRequestConfiguration - # 3. SELECT OrgUnitChangeRequestConfiguration - # 4. PREFETCH OrgUnitChangeRequestConfiguration.possible_types - # 5. PREFETCH OrgUnitChangeRequestConfiguration.possible_parent_types - # 6. PREFETCH OrgUnitChangeRequestConfiguration.group_sets - # 7. PREFETCH OrgUnitChangeRequestConfiguration.editable_reference_forms - # 8. PREFETCH OrgUnitChangeRequestConfiguration.other_groups + # 2. SELECT user_roles_editable_org_unit_type_ids + # 3. COUNT(*) OrgUnitChangeRequestConfiguration + # 4. SELECT OrgUnitChangeRequestConfiguration + # 5. PREFETCH OrgUnitChangeRequestConfiguration.possible_types + # 6. PREFETCH OrgUnitChangeRequestConfiguration.possible_parent_types + # 7. PREFETCH OrgUnitChangeRequestConfiguration.group_sets + # 8. PREFETCH OrgUnitChangeRequestConfiguration.editable_reference_forms + # 9. PREFETCH OrgUnitChangeRequestConfiguration.other_groups response = self.client.get(f"{self.MOBILE_OUCRC_API_URL}?app_id={self.app_id}") self.assertJSONResponse(response, status.HTTP_200_OK) self.assertEqual(3, len(response.data["results"])) # the 3 OUCRCs from setup From 9833a00d67ea41f25c39fca479f8ca100dc68f8a Mon Sep 17 00:00:00 2001 From: kemar Date: Thu, 17 Oct 2024 11:44:34 +0200 Subject: [PATCH 29/60] Implement `editable_org_unit_types` in the `UserRole` API --- iaso/api/user_roles.py | 62 +++++++-------- iaso/tests/api/test_user_roles.py | 126 ++++++++++++++++++------------ 2 files changed, 106 insertions(+), 82 deletions(-) diff --git a/iaso/api/user_roles.py b/iaso/api/user_roles.py index 039605fdb5..83c9037d8f 100644 --- a/iaso/api/user_roles.py +++ b/iaso/api/user_roles.py @@ -5,7 +5,7 @@ from rest_framework import permissions, serializers, status from django.contrib.auth.models import Permission, Group from django.db.models import Q, QuerySet -from iaso.models import UserRole +from iaso.models import OrgUnitType, UserRole from .common import TimestampField, ModelViewSet from hat.menupermissions import models as permission @@ -23,45 +23,43 @@ class Meta: fields = ("id", "name", "codename") +class OrgUnitTypeNestedReadSerializer(serializers.ModelSerializer): + class Meta: + model = OrgUnitType + fields = ["id", "name", "short_name"] + + class UserRoleSerializer(serializers.ModelSerializer): permissions = serializers.SerializerMethodField("get_permissions") name = serializers.CharField(source="group.name") + created_at = TimestampField(read_only=True) + updated_at = TimestampField(read_only=True) class Meta: model = UserRole - fields = ["id", "name", "permissions", "created_at", "updated_at"] + fields = ["id", "name", "permissions", "editable_org_unit_types", "created_at", "updated_at"] def to_representation(self, instance): user_role = super().to_representation(instance) account_id = user_role["name"].split("_")[0] - user_role["name"] = self.remove_prefix_from_str(user_role["name"], account_id + "_") + user_role["name"] = user_role["name"].removeprefix(f"{account_id}_") + user_role["editable_org_unit_types"] = OrgUnitTypeNestedReadSerializer( + instance.editable_org_unit_types.only("id", "name", "short_name").order_by("id"), many=True + ).data return user_role - created_at = TimestampField(read_only=True) - updated_at = TimestampField(read_only=True) - - # This method will remove a given prefix from a string - def remove_prefix_from_str(self, str, prefix): - if str.startswith(prefix): - return str[len(prefix) :] - return str - def get_permissions(self, obj): return [permission["codename"] for permission in PermissionSerializer(obj.group.permissions, many=True).data] def create(self, validated_data): - account = self.context["request"].user.iaso_profile.account request = self.context["request"] + account = request.user.iaso_profile.account group_name = str(account.id) + "_" + request.data.get("name") permissions = request.data.get("permissions", []) - - # check if the user role name has been given - if not group_name: - raise serializers.ValidationError({"name": "User role name is required"}) + editable_org_unit_types = validated_data.get("editable_org_unit_types", []) # check if a user role with the same name already exists - group_exists = Group.objects.filter(name__iexact=group_name) - if group_exists: + if Group.objects.filter(name__iexact=group_name).exists(): raise serializers.ValidationError({"name": "User role already exists"}) group = Group(name=group_name) @@ -73,21 +71,20 @@ def create(self, validated_data): group.permissions.add(permission) group.save() - userRole = UserRole.objects.create(group=group, account=account) - userRole.save() - return userRole + user_role = UserRole.objects.create(group=group, account=account) + user_role.save() + user_role.editable_org_unit_types.set(editable_org_unit_types) + return user_role def update(self, user_role, validated_data): - account = self.context["request"].user.iaso_profile.account - group_name = str(account.id) + "_" + self.context["request"].data.get("name", None) - permissions = self.context["request"].data.get("permissions", None) + request = self.context["request"] + account = request.user.iaso_profile.account group = user_role.group + group_name = str(account.id) + "_" + validated_data.get("group", {}).get("name") + permissions = request.data.get("permissions", None) + editable_org_unit_types = validated_data.get("editable_org_unit_types", []) - if group_name is not None: - group.name = group_name - # check if a user role with the same name already exists other than the current user role - group_exists = Group.objects.filter(~Q(pk=group.id), name__iexact=group_name) - if group_exists: + if Group.objects.filter(~Q(pk=group.id), name__iexact=group_name).exists(): raise serializers.ValidationError({"name": "User role already exists"}) if permissions is not None: @@ -98,6 +95,7 @@ def update(self, user_role, validated_data): group.save() user_role.save() + user_role.editable_org_unit_types.set(editable_org_unit_types) return user_role @@ -120,7 +118,9 @@ class UserRolesViewSet(ModelViewSet): def get_queryset(self) -> QuerySet[UserRole]: user = self.request.user - queryset = UserRole.objects.filter(account=user.iaso_profile.account) # type: ignore + queryset = UserRole.objects.filter(account=user.iaso_profile.account).prefetch_related( + "group__permissions", "editable_org_unit_types" + ) search = self.request.GET.get("search", None) orders = self.request.GET.get("order", "group__name").split(",") if search: diff --git a/iaso/tests/api/test_user_roles.py b/iaso/tests/api/test_user_roles.py index 9c2be70b30..63351950cc 100644 --- a/iaso/tests/api/test_user_roles.py +++ b/iaso/tests/api/test_user_roles.py @@ -6,16 +6,18 @@ class UserRoleAPITestCase(APITestCase): @classmethod def setUpTestData(cls): - star_wars = m.Account.objects.create(name="Star Wars") - cls.star_wars = star_wars + account = m.Account.objects.create(name="Account") + cls.account = account sw_source = m.DataSource.objects.create(name="Galactic Empire") cls.sw_source = sw_source sw_version = m.SourceVersion.objects.create(data_source=sw_source, number=1) - star_wars.default_version = sw_version - star_wars.save() + account.default_version = sw_version + account.save() - cls.yoda = cls.create_user_with_profile(username="yoda", account=star_wars, permissions=["iaso_user_roles"]) - cls.user_with_no_permissions = cls.create_user_with_profile(username="userNoPermission", account=star_wars) + cls.org_unit_type = m.OrgUnitType.objects.create(name="Org unit type", short_name="OUT") + + cls.user = cls.create_user_with_profile(username="yoda", account=account, permissions=["iaso_user_roles"]) + cls.user_with_no_permissions = cls.create_user_with_profile(username="userNoPermission", account=account) cls.permission = Permission.objects.create( name="iaso permission", content_type_id=1, codename="iaso_permission" @@ -32,31 +34,29 @@ def setUpTestData(cls): cls.permission_not_allowable = Permission.objects.create( name="admin permission", content_type_id=1, codename="admin_permission1" ) - cls.group = Group.objects.create(name=str(star_wars.id) + "user role") + cls.group = Group.objects.create(name=str(account.id) + "user role") cls.group.permissions.add(cls.permission) cls.group.refresh_from_db() - cls.userRole = m.UserRole.objects.create(group=cls.group, account=star_wars) - - # This method will remove a given prefix from a string - def remove_prefix_from_str(self, str, prefix): - if str.startswith(prefix): - return str[len(prefix) :] - return str + cls.user_role = m.UserRole.objects.create(group=cls.group, account=account) + cls.user_role.editable_org_unit_types.set([cls.org_unit_type]) def test_create_user_role(self): - self.client.force_authenticate(self.yoda) - - payload = {"name": "New user role name"} + self.client.force_authenticate(self.user) + payload = {"name": "New user role name", "editable_org_unit_types": [self.org_unit_type.id]} response = self.client.post("/api/userroles/", data=payload, format="json") r = self.assertJSONResponse(response, 201) + self.assertEqual(r["name"], payload["name"]) self.assertIsNotNone(r["id"]) + self.assertEqual( + r["editable_org_unit_types"], [{"id": self.org_unit_type.id, "name": "Org unit type", "short_name": "OUT"}] + ) def test_create_user_role_without_name(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) payload = {"permissions": ["iaso_mappings"]} response = self.client.post("/api/userroles/", data=payload, format="json") @@ -64,25 +64,32 @@ def test_create_user_role_without_name(self): self.assertEqual(response.status_code, 400) def test_retrieve_user_role(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) - response = self.client.get(f"/api/userroles/{self.userRole.pk}/") + response = self.client.get(f"/api/userroles/{self.user_role.pk}/") r = self.assertJSONResponse(response, 200) - self.assertEqual(r["id"], self.userRole.pk) - self.userRole.refresh_from_db() - self.assertEqual(r["name"], self.remove_prefix_from_str(self.userRole.group.name, str(self.star_wars.id) + "_")) + self.assertEqual(r["id"], self.user_role.pk) + self.user_role.refresh_from_db() + expected_name = self.user_role.group.name.removeprefix(f"{self.account.id}_") + self.assertEqual(r["name"], expected_name) + self.assertEqual( + r["editable_org_unit_types"], [{"id": self.org_unit_type.id, "name": "Org unit type", "short_name": "OUT"}] + ) def test_retrieve_user_role_read_only(self): self.client.force_authenticate(self.user_with_no_permissions) - response = self.client.get(f"/api/userroles/{self.userRole.pk}/") + response = self.client.get(f"/api/userroles/{self.user_role.pk}/") r = self.assertJSONResponse(response, 200) - self.assertEqual(r["id"], self.userRole.pk) + self.assertEqual(r["id"], self.user_role.pk) + self.assertEqual( + r["editable_org_unit_types"], [{"id": self.org_unit_type.id, "name": "Org unit type", "short_name": "OUT"}] + ) def test_list_without_search(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) response = self.client.get("/api/userroles/") @@ -90,7 +97,7 @@ def test_list_without_search(self): self.assertEqual(len(r["results"]), 1) def test_list_with_search_on_user_role_name(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) payload = {"search": "user role"} response = self.client.get("/api/userroles/", data=payload, format="json") @@ -98,45 +105,62 @@ def test_list_with_search_on_user_role_name(self): r = self.assertJSONResponse(response, 200) self.assertEqual(len(r["results"]), 1) + + expected_name = self.user_role.group.name.removeprefix(f"{self.account.id}_") + self.assertEqual(r["results"][0]["name"], expected_name) self.assertEqual( - r["results"][0]["name"], self.remove_prefix_from_str(self.userRole.group.name, str(self.star_wars.id) + "_") + r["results"][0]["editable_org_unit_types"], + [{"id": self.org_unit_type.id, "name": "Org unit type", "short_name": "OUT"}], ) def test_partial_update_no_modification(self): - self.client.force_authenticate(self.yoda) + new_org_unit_type = m.OrgUnitType.objects.create(name="New org unit type", short_name="NOUT") + + self.client.force_authenticate(self.user) + payload = { + "name": self.user_role.group.name, + "editable_org_unit_types": [self.org_unit_type.id, new_org_unit_type.id], + } - payload = {"name": self.userRole.group.name} - response = self.client.put(f"/api/userroles/{self.userRole.id}/", data=payload, format="json") + response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") r = self.assertJSONResponse(response, 200) self.assertEqual(r["name"], payload["name"]) + self.assertEqual( + r["editable_org_unit_types"], + [ + {"id": self.org_unit_type.id, "name": "Org unit type", "short_name": "OUT"}, + {"id": new_org_unit_type.id, "name": "New org unit type", "short_name": "NOUT"}, + ], + ) def test_partial_update_no_permission(self): self.client.force_authenticate(self.user_with_no_permissions) - payload = {"name": self.userRole.group.name} - response = self.client.put(f"/api/userroles/{self.userRole.id}/", data=payload, format="json") + payload = {"name": self.user_role.group.name} + response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") r = self.assertJSONResponse(response, 403) self.assertEqual(r["detail"], "You do not have permission to perform this action.") def test_update_name_modification(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) payload = {"name": "user role modified"} - response = self.client.put(f"/api/userroles/{self.userRole.id}/", data=payload, format="json") + response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") self.group.refresh_from_db() r = self.assertJSONResponse(response, 200) - self.assertEqual(r["name"], self.remove_prefix_from_str(self.group.name, str(self.star_wars.id) + "_")) + expected_name = self.user_role.group.name.removeprefix(f"{self.account.id}_") + self.assertEqual(r["name"], expected_name) def test_partial_update_permissions_modification(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) payload = { - "name": self.userRole.group.name, + "name": self.user_role.group.name, "permissions": [self.permission1.codename, self.permission2.codename], } - response = self.client.put(f"/api/userroles/{self.userRole.id}/", data=payload, format="json") + response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") r = self.assertJSONResponse(response, 200) self.assertEqual( @@ -145,13 +169,13 @@ def test_partial_update_permissions_modification(self): ) def test_partial_update_not_allowable_permissions_modification(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) payload = { - "name": self.userRole.group.name, + "name": self.user_role.group.name, "permissions": [self.permission_not_allowable.codename], } - response = self.client.put(f"/api/userroles/{self.userRole.id}/", data=payload, format="json") + response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") r = self.assertJSONResponse(response, 404) self.assertEqual( @@ -160,33 +184,33 @@ def test_partial_update_not_allowable_permissions_modification(self): ) def test_delete_user_role(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) - response = self.client.delete(f"/api/userroles/{self.userRole.id}/") + response = self.client.delete(f"/api/userroles/{self.user_role.id}/") r = self.assertJSONResponse(response, 204) def test_delete_user_role_and_remove_users_in_it(self): - self.client.force_authenticate(self.yoda) + self.client.force_authenticate(self.user) group_1 = Group.objects.create(name="Group 1") group_2 = Group.objects.create(name="Group 2") group_1.permissions.add(self.permission) group_2.permissions.add(self.permission) - userRole_1 = m.UserRole.objects.create(group=group_1, account=self.star_wars) - userRole_2 = m.UserRole.objects.create(group=group_2, account=self.star_wars) + userRole_1 = m.UserRole.objects.create(group=group_1, account=self.account) + userRole_2 = m.UserRole.objects.create(group=group_2, account=self.account) - self.yoda.iaso_profile.user_roles.add(userRole_1) - self.yoda.iaso_profile.user_roles.add(userRole_2) + self.user.iaso_profile.user_roles.add(userRole_1) + self.user.iaso_profile.user_roles.add(userRole_2) self.assertEqual( - list(self.yoda.iaso_profile.user_roles.all()), + list(self.user.iaso_profile.user_roles.all()), list(m.UserRole.objects.filter(id__in=[userRole_2.id, userRole_1.id])), ) response = self.client.delete(f"/api/userroles/{userRole_1.id}/") r = self.assertJSONResponse(response, 204) self.assertEqual( - list(self.yoda.iaso_profile.user_roles.all()), + list(self.user.iaso_profile.user_roles.all()), list(m.UserRole.objects.filter(id__in=[userRole_2.id])), ) From bdc273aedf2605cf05af23735717111f14624825 Mon Sep 17 00:00:00 2001 From: kemar Date: Thu, 17 Oct 2024 14:58:20 +0200 Subject: [PATCH 30/60] Add `validate_editable_org_unit_types` --- iaso/api/user_roles.py | 20 ++++++++++++----- iaso/tests/api/test_user_roles.py | 37 ++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/iaso/api/user_roles.py b/iaso/api/user_roles.py index 83c9037d8f..696ef2cfb9 100644 --- a/iaso/api/user_roles.py +++ b/iaso/api/user_roles.py @@ -1,13 +1,13 @@ -from typing import Any -from django.conf import settings -from django.shortcuts import get_object_or_404 -from rest_framework.response import Response from rest_framework import permissions, serializers, status +from rest_framework.response import Response + from django.contrib.auth.models import Permission, Group from django.db.models import Q, QuerySet -from iaso.models import OrgUnitType, UserRole +from django.shortcuts import get_object_or_404 + from .common import TimestampField, ModelViewSet from hat.menupermissions import models as permission +from iaso.models import Project, OrgUnitType, UserRole class HasUserRolePermission(permissions.BasePermission): @@ -98,6 +98,16 @@ def update(self, user_role, validated_data): user_role.editable_org_unit_types.set(editable_org_unit_types) return user_role + def validate_editable_org_unit_types(self, editable_org_unit_types): + account = self.context.get("request").user.iaso_profile.account + project_org_unit_types = set(Project.objects.get(account=account).unit_types.values_list("id", flat=True)) + for org_unit_type in editable_org_unit_types: + if org_unit_type.pk not in project_org_unit_types: + raise serializers.ValidationError( + f"`{org_unit_type.name} ({org_unit_type.pk})` is not a valid Org Unit Type fot this account." + ) + return editable_org_unit_types + class UserRolesViewSet(ModelViewSet): f"""Roles API diff --git a/iaso/tests/api/test_user_roles.py b/iaso/tests/api/test_user_roles.py index 63351950cc..b3fa377071 100644 --- a/iaso/tests/api/test_user_roles.py +++ b/iaso/tests/api/test_user_roles.py @@ -6,16 +6,19 @@ class UserRoleAPITestCase(APITestCase): @classmethod def setUpTestData(cls): - account = m.Account.objects.create(name="Account") - cls.account = account - sw_source = m.DataSource.objects.create(name="Galactic Empire") - cls.sw_source = sw_source + cls.org_unit_type = m.OrgUnitType.objects.create(name="Org unit type", short_name="OUT") + + cls.account = account = m.Account.objects.create(name="Account") + cls.project = project = m.Project.objects.create(name="Project", account=account, app_id="foo.bar.baz") + project.unit_types.set([cls.org_unit_type]) + + cls.sw_source = sw_source = m.DataSource.objects.create(name="Galactic Empire") + sw_source.projects.set([project]) + sw_version = m.SourceVersion.objects.create(data_source=sw_source, number=1) account.default_version = sw_version account.save() - cls.org_unit_type = m.OrgUnitType.objects.create(name="Org unit type", short_name="OUT") - cls.user = cls.create_user_with_profile(username="yoda", account=account, permissions=["iaso_user_roles"]) cls.user_with_no_permissions = cls.create_user_with_profile(username="userNoPermission", account=account) @@ -113,8 +116,30 @@ def test_list_with_search_on_user_role_name(self): [{"id": self.org_unit_type.id, "name": "Org unit type", "short_name": "OUT"}], ) + def test_partial_update_invalid_org_unit_type(self): + invalid_org_unit_type = m.OrgUnitType.objects.create( + name="This org unit type is not linked to the account", short_name="Invalid" + ) + + self.client.force_authenticate(self.user) + payload = { + "name": self.user_role.group.name, + "editable_org_unit_types": [invalid_org_unit_type.pk], + } + + response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") + + r = self.assertJSONResponse(response, 400) + self.assertEqual( + r["editable_org_unit_types"], + [ + f"`{invalid_org_unit_type.name} ({invalid_org_unit_type.pk})` is not a valid Org Unit Type fot this account." + ], + ) + def test_partial_update_no_modification(self): new_org_unit_type = m.OrgUnitType.objects.create(name="New org unit type", short_name="NOUT") + self.project.unit_types.add(new_org_unit_type.pk) self.client.force_authenticate(self.user) payload = { From 1aca895b346fab74dae903130289fb9d516be8af Mon Sep 17 00:00:00 2001 From: kemar Date: Mon, 21 Oct 2024 14:13:57 +0200 Subject: [PATCH 31/60] Fix `MultipleObjectsReturned` --- .../org_unit_change_request_configurations/views_mobile.py | 2 +- iaso/api/user_roles.py | 4 ++-- iaso/tests/api/test_user_roles.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/iaso/api/org_unit_change_request_configurations/views_mobile.py b/iaso/api/org_unit_change_request_configurations/views_mobile.py index 8e2efd9459..59a36f1e2a 100644 --- a/iaso/api/org_unit_change_request_configurations/views_mobile.py +++ b/iaso/api/org_unit_change_request_configurations/views_mobile.py @@ -74,7 +74,7 @@ def list(self, request: Request, *args, **kwargs) -> Response: user_editable_org_unit_type_ids = self.request.user.iaso_profile.get_editable_org_unit_type_ids() if user_editable_org_unit_type_ids: - project_org_unit_types = set(Project.objects.get(app_id=app_id).unit_types.values_list("id", flat=True)) + project_org_unit_types = set(Project.objects.filter(app_id=app_id).values_list("unit_types__id", flat=True)) non_editable_org_unit_type_ids = project_org_unit_types - user_editable_org_unit_type_ids dynamic_configurations = [ diff --git a/iaso/api/user_roles.py b/iaso/api/user_roles.py index 696ef2cfb9..ba799bf51d 100644 --- a/iaso/api/user_roles.py +++ b/iaso/api/user_roles.py @@ -100,11 +100,11 @@ def update(self, user_role, validated_data): def validate_editable_org_unit_types(self, editable_org_unit_types): account = self.context.get("request").user.iaso_profile.account - project_org_unit_types = set(Project.objects.get(account=account).unit_types.values_list("id", flat=True)) + project_org_unit_types = set(Project.objects.filter(account=account).values_list("unit_types__id", flat=True)) for org_unit_type in editable_org_unit_types: if org_unit_type.pk not in project_org_unit_types: raise serializers.ValidationError( - f"`{org_unit_type.name} ({org_unit_type.pk})` is not a valid Org Unit Type fot this account." + f"`{org_unit_type.name} ({org_unit_type.pk})` is not a valid Org Unit Type for this account." ) return editable_org_unit_types diff --git a/iaso/tests/api/test_user_roles.py b/iaso/tests/api/test_user_roles.py index b3fa377071..dc46fe0096 100644 --- a/iaso/tests/api/test_user_roles.py +++ b/iaso/tests/api/test_user_roles.py @@ -133,7 +133,7 @@ def test_partial_update_invalid_org_unit_type(self): self.assertEqual( r["editable_org_unit_types"], [ - f"`{invalid_org_unit_type.name} ({invalid_org_unit_type.pk})` is not a valid Org Unit Type fot this account." + f"`{invalid_org_unit_type.name} ({invalid_org_unit_type.pk})` is not a valid Org Unit Type for this account." ], ) From 736624c14c40e5e473035f841dc331bb35b98189 Mon Sep 17 00:00:00 2001 From: kemar Date: Mon, 21 Oct 2024 14:21:55 +0200 Subject: [PATCH 32/60] Fix tests --- .../test_org_unit_change_request_configs_mobile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py index 9fe549620a..06ba38e56e 100644 --- a/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py +++ b/iaso/tests/api/org_unit_change_request_configurations/test_org_unit_change_request_configs_mobile.py @@ -57,7 +57,7 @@ def test_list_ok_with_restricted_write_permission_for_user(self): self.client.force_authenticate(self.user_ash_ketchum) - with self.assertNumQueries(10): + with self.assertNumQueries(9): response = self.client.get(f"{self.MOBILE_OUCRC_API_URL}?app_id={self.app_id}") self.assertJSONResponse(response, status.HTTP_200_OK) From 8a9f9346480186dfd5bead051503356ba73de136 Mon Sep 17 00:00:00 2001 From: kemar Date: Mon, 21 Oct 2024 15:25:43 +0200 Subject: [PATCH 33/60] Add a `user_roles_editable_org_unit_type_ids` field to the Profile API --- iaso/api/profiles/profiles.py | 4 +-- iaso/models/base.py | 46 +++++++++++++++++++++++++++++-- iaso/tests/models/test_profile.py | 23 ++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index a940f2d1ef..df7e49b72d 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -220,7 +220,7 @@ class ProfilesViewSet(viewsets.ViewSet): def get_queryset(self): account = self.request.user.iaso_profile.account - return Profile.objects.filter(account=account).prefetch_related("editable_org_unit_types") + return Profile.objects.filter(account=account).with_editable_org_unit_types() def list(self, request): limit = request.GET.get("limit", None) @@ -260,7 +260,7 @@ def list(self, request): teams=teams, managed_users_only=managed_users_only, ids=ids, - ) + ).order_by("id") queryset = queryset.prefetch_related( "user", diff --git a/iaso/models/base.py b/iaso/models/base.py index cc7c6c506a..e4a6d7b6d5 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -1405,6 +1405,21 @@ def as_dict(self): } +class ProfileQuerySet(models.QuerySet): + def with_editable_org_unit_types(self): + qs = self + return qs.annotate( + annotated_editable_org_unit_types_ids=ArrayAgg( + "editable_org_unit_types__id", distinct=True, filter=Q(editable_org_unit_types__isnull=False) + ), + annotated_user_roles_editable_org_unit_type_ids=ArrayAgg( + "user_roles__editable_org_unit_types__id", + distinct=True, + filter=Q(user_roles__editable_org_unit_types__isnull=False), + ), + ) + + class Profile(models.Model): account = models.ForeignKey(Account, on_delete=models.CASCADE) user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="iaso_profile") @@ -1424,6 +1439,8 @@ class Profile(models.Model): "OrgUnitType", related_name="editable_by_iaso_profile_set", blank=True ) + objects = models.Manager.from_queryset(ProfileQuerySet)() + class Meta: constraints = [models.UniqueConstraint(fields=["dhis2_id", "account"], name="dhis2_id_constraint")] @@ -1440,6 +1457,16 @@ def as_dict(self, small=False): ) all_permissions = user_group_permissions + user_permissions permissions = list(set(all_permissions)) + try: + editable_org_unit_type_ids = self.annotated_editable_org_unit_types_ids + except AttributeError: + editable_org_unit_type_ids = [out.pk for out in self.editable_org_unit_types.all()] + try: + user_roles_editable_org_unit_type_ids = self.annotated_user_roles_editable_org_unit_type_ids + except AttributeError: + user_roles_editable_org_unit_type_ids = ( + list(self.user_roles.values_list("editable_org_unit_types", flat=True)), + ) if not small: return { "id": self.id, @@ -1462,7 +1489,8 @@ def as_dict(self, small=False): "phone_number": self.phone_number.as_e164 if self.phone_number else None, "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, "projects": [p.as_dict() for p in self.projects.all().order_by("name")], - "editable_org_unit_type_ids": [out.pk for out in self.editable_org_unit_types.all()], + "editable_org_unit_type_ids": editable_org_unit_type_ids, + "user_roles_editable_org_unit_type_ids": user_roles_editable_org_unit_type_ids, } else: return { @@ -1485,10 +1513,21 @@ def as_dict(self, small=False): "phone_number": self.phone_number.as_e164 if self.phone_number else None, "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, "projects": [p.as_dict() for p in self.projects.all()], - "editable_org_unit_type_ids": [out.pk for out in self.editable_org_unit_types.all()], + "editable_org_unit_type_ids": editable_org_unit_type_ids, + "user_roles_editable_org_unit_type_ids": user_roles_editable_org_unit_type_ids, } def as_short_dict(self): + try: + editable_org_unit_type_ids = self.annotated_editable_org_unit_types_ids + except AttributeError: + editable_org_unit_type_ids = [out.pk for out in self.editable_org_unit_types.all()] + try: + user_roles_editable_org_unit_type_ids = self.annotated_user_roles_editable_org_unit_type_ids + except AttributeError: + user_roles_editable_org_unit_type_ids = ( + list(self.user_roles.values_list("editable_org_unit_types", flat=True)), + ) return { "id": self.id, "first_name": self.user.first_name, @@ -1499,7 +1538,8 @@ def as_short_dict(self): "user_id": self.user.id, "phone_number": self.phone_number.as_e164 if self.phone_number else None, "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, - "editable_org_unit_type_ids": [out.pk for out in self.editable_org_unit_types.all()], + "editable_org_unit_type_ids": editable_org_unit_type_ids, + "user_roles_editable_org_unit_type_ids": user_roles_editable_org_unit_type_ids, } def has_a_team(self): diff --git a/iaso/tests/models/test_profile.py b/iaso/tests/models/test_profile.py index 207a8012c7..c0aee1dff7 100644 --- a/iaso/tests/models/test_profile.py +++ b/iaso/tests/models/test_profile.py @@ -70,3 +70,26 @@ def test_has_org_unit_write_permission(self): ) ) self.profile1.user_roles.clear() + + def test_with_editable_org_unit_types(self): + org_unit_type_country = m.OrgUnitType.objects.create(name="Country") + org_unit_type_region = m.OrgUnitType.objects.create(name="Region") + org_unit_type_district = m.OrgUnitType.objects.create(name="District") + + group_1 = Group.objects.create(name="Group 1") + user_role_1 = m.UserRole.objects.create(group=group_1, account=self.account) + user_role_1.editable_org_unit_types.set([org_unit_type_country]) + + group_2 = Group.objects.create(name="Group 2") + user_role_2 = m.UserRole.objects.create(group=group_2, account=self.account) + user_role_2.editable_org_unit_types.set([org_unit_type_region]) + + self.profile1.user_roles.set([user_role_1, user_role_2]) + self.profile1.editable_org_unit_types.set([org_unit_type_district]) + + profile = m.Profile.objects.filter(id=self.profile1.pk).with_editable_org_unit_types().first() + + self.assertEqual(profile.annotated_editable_org_unit_types_ids, [org_unit_type_district.pk]) + self.assertCountEqual( + profile.annotated_user_roles_editable_org_unit_type_ids, [org_unit_type_country.pk, org_unit_type_region.pk] + ) From a9b3ac5050f5696a49a2aee876eb8f247dee6beb Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Mon, 21 Oct 2024 12:21:05 +0200 Subject: [PATCH 34/60] =?UTF-8?q?=F0=9F=9A=A7=20adapt=20frontend=20to=20ou?= =?UTF-8?q?=20type=20restrictions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../site/python3.9/greenlet/greenlet.h | 164 ++++++++++++++++++ .venv/share/man/man1/ipython.1 | 60 +++++++ .../Iaso/domains/app/translations/en.json | 1 + .../Iaso/domains/app/translations/fr.json | 3 +- .../components/OrgUnitWriteTypes.tsx | 45 +++++ .../hooks/requests/useGetUserRoles.ts | 18 +- .../apps/Iaso/domains/userRoles/messages.ts | 12 ++ .../Iaso/domains/userRoles/types/userRoles.ts | 2 + hat/assets/js/apps/Iaso/utils/usersUtils.ts | 23 ++- 9 files changed, 312 insertions(+), 16 deletions(-) create mode 100644 .venv/include/site/python3.9/greenlet/greenlet.h create mode 100644 .venv/share/man/man1/ipython.1 create mode 100644 hat/assets/js/apps/Iaso/domains/userRoles/components/OrgUnitWriteTypes.tsx diff --git a/.venv/include/site/python3.9/greenlet/greenlet.h b/.venv/include/site/python3.9/greenlet/greenlet.h new file mode 100644 index 0000000000..d02a16e434 --- /dev/null +++ b/.venv/include/site/python3.9/greenlet/greenlet.h @@ -0,0 +1,164 @@ +/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */ + +/* Greenlet object interface */ + +#ifndef Py_GREENLETOBJECT_H +#define Py_GREENLETOBJECT_H + + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* This is deprecated and undocumented. It does not change. */ +#define GREENLET_VERSION "1.0.0" + +#ifndef GREENLET_MODULE +#define implementation_ptr_t void* +#endif + +typedef struct _greenlet { + PyObject_HEAD + PyObject* weakreflist; + PyObject* dict; + implementation_ptr_t pimpl; +} PyGreenlet; + +#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type)) + + +/* C API functions */ + +/* Total number of symbols that are exported */ +#define PyGreenlet_API_pointers 12 + +#define PyGreenlet_Type_NUM 0 +#define PyExc_GreenletError_NUM 1 +#define PyExc_GreenletExit_NUM 2 + +#define PyGreenlet_New_NUM 3 +#define PyGreenlet_GetCurrent_NUM 4 +#define PyGreenlet_Throw_NUM 5 +#define PyGreenlet_Switch_NUM 6 +#define PyGreenlet_SetParent_NUM 7 + +#define PyGreenlet_MAIN_NUM 8 +#define PyGreenlet_STARTED_NUM 9 +#define PyGreenlet_ACTIVE_NUM 10 +#define PyGreenlet_GET_PARENT_NUM 11 + +#ifndef GREENLET_MODULE +/* This section is used by modules that uses the greenlet C API */ +static void** _PyGreenlet_API = NULL; + +# define PyGreenlet_Type \ + (*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM]) + +# define PyExc_GreenletError \ + ((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM]) + +# define PyExc_GreenletExit \ + ((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM]) + +/* + * PyGreenlet_New(PyObject *args) + * + * greenlet.greenlet(run, parent=None) + */ +# define PyGreenlet_New \ + (*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \ + _PyGreenlet_API[PyGreenlet_New_NUM]) + +/* + * PyGreenlet_GetCurrent(void) + * + * greenlet.getcurrent() + */ +# define PyGreenlet_GetCurrent \ + (*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM]) + +/* + * PyGreenlet_Throw( + * PyGreenlet *greenlet, + * PyObject *typ, + * PyObject *val, + * PyObject *tb) + * + * g.throw(...) + */ +# define PyGreenlet_Throw \ + (*(PyObject * (*)(PyGreenlet * self, \ + PyObject * typ, \ + PyObject * val, \ + PyObject * tb)) \ + _PyGreenlet_API[PyGreenlet_Throw_NUM]) + +/* + * PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args) + * + * g.switch(*args, **kwargs) + */ +# define PyGreenlet_Switch \ + (*(PyObject * \ + (*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \ + _PyGreenlet_API[PyGreenlet_Switch_NUM]) + +/* + * PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent) + * + * g.parent = new_parent + */ +# define PyGreenlet_SetParent \ + (*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \ + _PyGreenlet_API[PyGreenlet_SetParent_NUM]) + +/* + * PyGreenlet_GetParent(PyObject* greenlet) + * + * return greenlet.parent; + * + * This could return NULL even if there is no exception active. + * If it does not return NULL, you are responsible for decrementing the + * reference count. + */ +# define PyGreenlet_GetParent \ + (*(PyGreenlet* (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_GET_PARENT_NUM]) + +/* + * deprecated, undocumented alias. + */ +# define PyGreenlet_GET_PARENT PyGreenlet_GetParent + +# define PyGreenlet_MAIN \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_MAIN_NUM]) + +# define PyGreenlet_STARTED \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_STARTED_NUM]) + +# define PyGreenlet_ACTIVE \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_ACTIVE_NUM]) + + + + +/* Macro that imports greenlet and initializes C API */ +/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we + keep the older definition to be sure older code that might have a copy of + the header still works. */ +# define PyGreenlet_Import() \ + { \ + _PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \ + } + +#endif /* GREENLET_MODULE */ + +#ifdef __cplusplus +} +#endif +#endif /* !Py_GREENLETOBJECT_H */ diff --git a/.venv/share/man/man1/ipython.1 b/.venv/share/man/man1/ipython.1 new file mode 100644 index 0000000000..0f4a191f3f --- /dev/null +++ b/.venv/share/man/man1/ipython.1 @@ -0,0 +1,60 @@ +.\" Hey, EMACS: -*- nroff -*- +.\" First parameter, NAME, should be all caps +.\" Second parameter, SECTION, should be 1-8, maybe w/ subsection +.\" other parameters are allowed: see man(7), man(1) +.TH IPYTHON 1 "July 15, 2011" +.\" Please adjust this date whenever revising the manpage. +.\" +.\" Some roff macros, for reference: +.\" .nh disable hyphenation +.\" .hy enable hyphenation +.\" .ad l left justify +.\" .ad b justify to both left and right margins +.\" .nf disable filling +.\" .fi enable filling +.\" .br insert line break +.\" .sp insert n+1 empty lines +.\" for manpage-specific macros, see man(7) and groff_man(7) +.\" .SH section heading +.\" .SS secondary section heading +.\" +.\" +.\" To preview this page as plain text: nroff -man ipython.1 +.\" +.SH NAME +ipython \- Tools for Interactive Computing in Python. +.SH SYNOPSIS +.B ipython +.RI [ options ] " files" ... + +.B ipython subcommand +.RI [ options ] ... + +.SH DESCRIPTION +An interactive Python shell with automatic history (input and output), dynamic +object introspection, easier configuration, command completion, access to the +system shell, integration with numerical and scientific computing tools, +web notebook, Qt console, and more. + +For more information on how to use IPython, see 'ipython \-\-help', +or 'ipython \-\-help\-all' for all available command\(hyline options. + +.SH "ENVIRONMENT VARIABLES" +.sp +.PP +\fIIPYTHONDIR\fR +.RS 4 +This is the location where IPython stores all its configuration files. The default +is $HOME/.ipython if IPYTHONDIR is not defined. + +You can see the computed value of IPYTHONDIR with `ipython locate`. + +.SH FILES + +IPython uses various configuration files stored in profiles within IPYTHONDIR. +To generate the default configuration files and start configuring IPython, +do 'ipython profile create', and edit '*_config.py' files located in +IPYTHONDIR/profile_default. + +.SH AUTHORS +IPython is written by the IPython Development Team . 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 c9b189f545..cb564ef273 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/en.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/en.json @@ -1380,6 +1380,7 @@ "iaso.userRoles.dialogInfoTitle": "Warning on the new created user role", "iaso.userRoles.edit": "Edit user role", "iaso.userRoles.infoButton": "Got it!", + "iaso.userRoles.orgUnitWriteTypesInfos": "Select the org unit types the user role can edit", "iaso.userRoles.title": "User roles", "iaso.userRoles.userRolePermissions": "User role permissions", "iaso.users.addLocations": "Add location(s)", 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 f5d38f2ecd..772438f381 100644 --- a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json +++ b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json @@ -15,7 +15,6 @@ "blsq.treeview.label.selectSingle": "Sélectionner une unités d'organisation", "blsq.treeview.loading": "Chargement", "blsq.treeview.search.cancel": "Annuler", - "iaso.users.selectAllHelperText": "Laisser vide pour tout sélectionner", "blsq.treeview.search.confirm": "Confirmer", "blsq.treeview.search.inputLabelObject": "Rechercher", "blsq.treeview.search.options.label.clear": "Effacer", @@ -1381,6 +1380,7 @@ "iaso.userRoles.dialogInfoTitle": "Avertissement sur le nouveau rôle utilisateur créé", "iaso.userRoles.edit": "Editer un rôle d'utilisateur", "iaso.userRoles.infoButton": "Compris!", + "iaso.userRoles.orgUnitWriteTypesInfos": "Sélectionner les types d'unités d'org. que ce rôle d'utilisateur peut modifier", "iaso.userRoles.title": "Rôles des utilisateurs", "iaso.userRoles.userRolePermissions": "Permissions du rôle d'utilisateurs", "iaso.users.addLocations": "Ajouter la(les) localisation(s)", @@ -1440,6 +1440,7 @@ "iaso.users.removeProjects": "Retirer du(des) projet(s)", "iaso.users.removeRoles": "Retirer le(s) rôle(s)", "iaso.users.removeTeams": "Enlever de l'/des équipe(s)", + "iaso.users.selectAllHelperText": "Laisser vide pour tout sélectionner", "iaso.users.selectedOrgUnits": "Unité d'organisation sélectionnées", "iaso.users.update": "Mettre l'utilisateur à jour", "iaso.users.userPermissions": "Permissions d'utilisateur", diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/components/OrgUnitWriteTypes.tsx b/hat/assets/js/apps/Iaso/domains/userRoles/components/OrgUnitWriteTypes.tsx new file mode 100644 index 0000000000..2eaad393f1 --- /dev/null +++ b/hat/assets/js/apps/Iaso/domains/userRoles/components/OrgUnitWriteTypes.tsx @@ -0,0 +1,45 @@ +import { Box } from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import React, { FunctionComponent } from 'react'; +import InputComponent from '../../../components/forms/InputComponent'; +import { InputWithInfos } from '../../../components/InputWithInfos'; +import { commaSeparatedIdsToArray } from '../../../utils/forms'; +import { useGetOrgUnitTypesDropdownOptions } from '../../orgUnits/orgUnitTypes/hooks/useGetOrgUnitTypesDropdownOptions'; +import MESSAGES from '../messages'; +import { UserRole } from '../types/userRoles'; + +type Props = { + userRole: UserRole; + handleChange: (ouTypesIds: number[]) => void; +}; + +export const OrgUnitWriteTypes: FunctionComponent = ({ + userRole, + handleChange, +}) => { + const { formatMessage } = useSafeIntl(); + const { data: orgUnitTypes, isLoading: isLoadingOrgUitTypes } = + useGetOrgUnitTypesDropdownOptions(undefined, true); + return ( + + + + handleChange(commaSeparatedIdsToArray(value)) + } + loading={isLoadingOrgUitTypes} + value={userRole.editable_org_unit_type_ids ?? []} + type="select" + options={orgUnitTypes} + label={MESSAGES.orgUnitWriteTypes} + helperText={formatMessage(MESSAGES.selectAllHelperText)} + /> + + + ); +}; diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/hooks/requests/useGetUserRoles.ts b/hat/assets/js/apps/Iaso/domains/userRoles/hooks/requests/useGetUserRoles.ts index f4a03ad001..9a9b6c7ccc 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/hooks/requests/useGetUserRoles.ts +++ b/hat/assets/js/apps/Iaso/domains/userRoles/hooks/requests/useGetUserRoles.ts @@ -1,15 +1,15 @@ -import { UseQueryResult } from 'react-query'; import { Pagination } from 'bluesquare-components'; +import { UseQueryResult } from 'react-query'; import { getRequest } from '../../../../libs/Api'; import { useSnackQuery } from '../../../../libs/apiHooks'; import { makeUrlWithParams } from '../../../../libs/utils'; +import { DropdownOptions } from '../../../../types/utils'; +import MESSAGES from '../../messages'; import { + UserRole, UserRoleParams, UserRolesFilterParams, - UserRole, } from '../../types/userRoles'; -import { DropdownOptions } from '../../../../types/utils'; -import MESSAGES from '../../messages'; type UserRolesList = Pagination & { results: UserRole[]; @@ -26,7 +26,7 @@ const getUserRoles = async ( if (params.select) { delete params.select; } - const url = makeUrlWithParams('/api/userroles', params); + const url = makeUrlWithParams('/api/userroles/', params); return getRequest(url) as Promise; }; @@ -34,9 +34,15 @@ export const useGetUserRoles = ( options: UserRoleParams | UserRolesFilterParams, ): UseQueryResult => { const { select } = options as Record; + const safeParams = { + ...options, + }; + if (safeParams?.accountId) { + delete safeParams.accountId; + } return useSnackQuery({ queryKey: ['userRoles', options], - queryFn: () => getUserRoles(options), + queryFn: () => getUserRoles(safeParams), snackErrorMsg: undefined, options: { staleTime: 1000 * 60 * 15, // in MS diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/messages.ts b/hat/assets/js/apps/Iaso/domains/userRoles/messages.ts index b2515ee8b3..f40a2243e5 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/messages.ts +++ b/hat/assets/js/apps/Iaso/domains/userRoles/messages.ts @@ -86,6 +86,18 @@ const MESSAGES = defineMessages({ defaultMessage: 'An error occurred while fetching user roles', id: 'iaso.snackBar.fetchUserRoles', }, + orgUnitWriteTypesInfos: { + id: 'iaso.userRoles.orgUnitWriteTypesInfos', + defaultMessage: 'Select the org unit types the user role can edit', + }, + selectAllHelperText: { + id: 'iaso.users.selectAllHelperText', + defaultMessage: 'Leave empty to select all', + }, + orgUnitWriteTypes: { + id: 'iaso.users.orgUnitWriteTypes', + defaultMessage: 'Org unit write types', + }, }); export default MESSAGES; 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 1f6bda28e9..fdb45bd1ed 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/types/userRoles.ts +++ b/hat/assets/js/apps/Iaso/domains/userRoles/types/userRoles.ts @@ -5,9 +5,11 @@ export type UserRole = { name: string; created_at: string; updated_at?: string; + editable_org_unit_type_ids?: number[]; }; export type UserRolesFilterParams = { name?: string; + accountId?: number; }; export type UserRoleParams = UrlParams & diff --git a/hat/assets/js/apps/Iaso/utils/usersUtils.ts b/hat/assets/js/apps/Iaso/utils/usersUtils.ts index 2801437744..3495a72457 100644 --- a/hat/assets/js/apps/Iaso/utils/usersUtils.ts +++ b/hat/assets/js/apps/Iaso/utils/usersUtils.ts @@ -79,6 +79,8 @@ export type User = { user_id: number; dhis2_id?: string; editable_org_unit_type_ids?: number[]; + user_roles: number[]; + user_roles_editable_org_unit_type_ids?: number[]; }; export const getDisplayName = ( @@ -117,19 +119,22 @@ export const useCheckUserHasWriteTypePermission = (): (( ) => boolean) => { const currentUser = useCurrentUser(); return (orgUnitTypeId?: number) => { - return Boolean( - currentUser && - (!currentUser.editable_org_unit_type_ids || - currentUser.editable_org_unit_type_ids?.length === 0 || - (orgUnitTypeId && - currentUser.editable_org_unit_type_ids?.includes( - orgUnitTypeId, - ))), + if (!currentUser) return false; + + const editableTypeIds = [ + ...(currentUser.editable_org_unit_type_ids ?? []), + ...(currentUser.user_roles_editable_org_unit_type_ids ?? []), + ]; + + return ( + editableTypeIds.length === 0 || + (orgUnitTypeId !== undefined && + editableTypeIds.includes(orgUnitTypeId)) ); }; }; -export const useCheckUserHasWritePermissionOnOrgunit = ( +export const useGetUserHasWritePermissionOnOrgunit = ( orgUnitTypeId?: number, ): boolean => { const getHasWriteByTypePermission = useCheckUserHasWriteTypePermission(); From a3243c060d7a900f2515c4ad1a30d6903e5424ba Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Mon, 21 Oct 2024 13:11:55 +0200 Subject: [PATCH 35/60] create or update user roles with editable or unit tables --- .../components/CreateEditUserRole.tsx | 42 +++++++++++++------ .../components/OrgUnitWriteTypes.tsx | 6 +-- .../hooks/requests/useSaveUserRole.ts | 16 +++---- .../components/UserOrgUnitWriteTypes.tsx | 2 +- 4 files changed, 43 insertions(+), 23 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/components/CreateEditUserRole.tsx b/hat/assets/js/apps/Iaso/domains/userRoles/components/CreateEditUserRole.tsx index 981be34f8c..8946fd0813 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/components/CreateEditUserRole.tsx +++ b/hat/assets/js/apps/Iaso/domains/userRoles/components/CreateEditUserRole.tsx @@ -1,28 +1,31 @@ -import React, { FunctionComponent, useState } from 'react'; -import { useFormik, FormikProvider } from 'formik'; import { AddButton, - useSafeIntl, ConfirmCancelModal, makeFullModal, + useSafeIntl, } from 'bluesquare-components'; +import { FormikProvider, useFormik } from 'formik'; import { isEqual } from 'lodash'; +import React, { FunctionComponent, useState } from 'react'; import { useQueryClient } from 'react-query'; +import { EditIconButton } from '../../../components/Buttons/EditIconButton'; +import InputComponent from '../../../components/forms/InputComponent'; +import { + useApiErrorValidation, + useTranslatedErrors, +} from '../../../libs/validation'; +import { hasFeatureFlag, SHOW_DEV_FEATURES } from '../../../utils/featureFlags'; +import { useCurrentUser } from '../../../utils/usersUtils'; import { SaveUserRoleQuery, useSaveUserRole, } from '../hooks/requests/useSaveUserRole'; import MESSAGES from '../messages'; +import { Permission } from '../types/userRoles'; import { useUserRoleValidation } from '../validation'; -import { - useApiErrorValidation, - useTranslatedErrors, -} from '../../../libs/validation'; -import InputComponent from '../../../components/forms/InputComponent'; +import { OrgUnitWriteTypes } from './OrgUnitWriteTypes'; import { PermissionsAttribution } from './PermissionsAttribution'; -import { EditIconButton } from '../../../components/Buttons/EditIconButton'; import UserRoleDialogInfoComponent from './UserRoleDialogInfoComponent'; -import { Permission } from '../types/userRoles'; type ModalMode = 'create' | 'edit'; type Props = Partial & { @@ -38,6 +41,7 @@ export const CreateEditUserRole: FunctionComponent = ({ isOpen, id, name, + editable_org_unit_type_ids, permissions = [], }) => { const [userRolePermissions, setUserRolePermissoins] = @@ -68,6 +72,7 @@ export const CreateEditUserRole: FunctionComponent = ({ initialValues: { id, name, + editable_org_unit_type_ids, permissions, }, enableReinitialize: true, @@ -109,7 +114,8 @@ export const CreateEditUserRole: FunctionComponent = ({ setUserRolePermissoins(newPermissions); setFieldValue('permissions', newPermissions); }; - + const currentUser = useCurrentUser(); + const hasDevFeatures = hasFeatureFlag(currentUser, SHOW_DEV_FEATURES); return ( <> {dialogType === 'create' && ( @@ -149,6 +155,17 @@ export const CreateEditUserRole: FunctionComponent = ({ label={MESSAGES.name} required /> + {hasDevFeatures && ( + { + onChange( + 'editable_org_unit_type_ids', + newEditableOrgUnitTypeIds, + ); + }} + /> + )} { @@ -172,5 +189,6 @@ const editUserRoleModalWithIcon = makeFullModal( export { createUserRoleModalWithButton as CreateUserRoleDialog, - editUserRoleModalWithIcon as EditUserRoleDialog, + editUserRoleModalWithIcon as EditUserRoleDialog }; + diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/components/OrgUnitWriteTypes.tsx b/hat/assets/js/apps/Iaso/domains/userRoles/components/OrgUnitWriteTypes.tsx index 2eaad393f1..2795d7f6b9 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/components/OrgUnitWriteTypes.tsx +++ b/hat/assets/js/apps/Iaso/domains/userRoles/components/OrgUnitWriteTypes.tsx @@ -5,11 +5,11 @@ import InputComponent from '../../../components/forms/InputComponent'; import { InputWithInfos } from '../../../components/InputWithInfos'; import { commaSeparatedIdsToArray } from '../../../utils/forms'; import { useGetOrgUnitTypesDropdownOptions } from '../../orgUnits/orgUnitTypes/hooks/useGetOrgUnitTypesDropdownOptions'; +import { SaveUserRoleQuery } from '../hooks/requests/useSaveUserRole'; import MESSAGES from '../messages'; -import { UserRole } from '../types/userRoles'; type Props = { - userRole: UserRole; + userRole: Partial; handleChange: (ouTypesIds: number[]) => void; }; @@ -28,7 +28,7 @@ export const OrgUnitWriteTypes: FunctionComponent = ({ handleChange(commaSeparatedIdsToArray(value)) } diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/hooks/requests/useSaveUserRole.ts b/hat/assets/js/apps/Iaso/domains/userRoles/hooks/requests/useSaveUserRole.ts index 06e8eb63b0..edd9cb0f14 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/hooks/requests/useSaveUserRole.ts +++ b/hat/assets/js/apps/Iaso/domains/userRoles/hooks/requests/useSaveUserRole.ts @@ -1,6 +1,6 @@ -import { UseMutationResult } from 'react-query'; import { isEmpty } from 'lodash'; -import { putRequest, postRequest } from '../../../../libs/Api'; +import { UseMutationResult } from 'react-query'; +import { postRequest, putRequest } from '../../../../libs/Api'; import { useSnackMutation } from '../../../../libs/apiHooks'; import { Permission } from '../../types/userRoles'; @@ -8,14 +8,16 @@ export type SaveUserRoleQuery = { id?: number; name: string; permissions?: Array; + editable_org_unit_type_ids?: number[]; }; -const convertToApi = data => { +const convertToApi = (data: Partial) => { const { permissions, ...converted } = data; - if (!isEmpty(permissions)) { - converted.permissions = permissions; - } - return converted; + return { + ...converted, + permissions: !isEmpty(permissions) ? permissions : undefined, + editable_org_unit_types: converted.editable_org_unit_type_ids, + }; }; const endpoint = '/api/userroles/'; diff --git a/hat/assets/js/apps/Iaso/domains/users/components/UserOrgUnitWriteTypes.tsx b/hat/assets/js/apps/Iaso/domains/users/components/UserOrgUnitWriteTypes.tsx index 978d29e963..51349a3ef9 100644 --- a/hat/assets/js/apps/Iaso/domains/users/components/UserOrgUnitWriteTypes.tsx +++ b/hat/assets/js/apps/Iaso/domains/users/components/UserOrgUnitWriteTypes.tsx @@ -28,7 +28,7 @@ export const UserOrgUnitWriteTypes: FunctionComponent = ({ handleChange(commaSeparatedIdsToArray(value)) } From 08253181482e27892fb5049e16ce63a0a88f949f Mon Sep 17 00:00:00 2001 From: kemar Date: Tue, 22 Oct 2024 10:52:29 +0200 Subject: [PATCH 36/60] Fix typo in rebase --- hat/assets/js/apps/Iaso/utils/usersUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hat/assets/js/apps/Iaso/utils/usersUtils.ts b/hat/assets/js/apps/Iaso/utils/usersUtils.ts index 3495a72457..20fbb82988 100644 --- a/hat/assets/js/apps/Iaso/utils/usersUtils.ts +++ b/hat/assets/js/apps/Iaso/utils/usersUtils.ts @@ -134,7 +134,7 @@ export const useCheckUserHasWriteTypePermission = (): (( }; }; -export const useGetUserHasWritePermissionOnOrgunit = ( +export const useCheckUserHasWritePermissionOnOrgunit = ( orgUnitTypeId?: number, ): boolean => { const getHasWriteByTypePermission = useCheckUserHasWriteTypePermission(); From 63371d235ec670935dab47e841200a110d39e39c Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Wed, 23 Oct 2024 12:14:30 +0200 Subject: [PATCH 37/60] remove .venv --- .../site/python3.9/greenlet/greenlet.h | 164 ------------------ .venv/share/man/man1/ipython.1 | 60 ------- 2 files changed, 224 deletions(-) delete mode 100644 .venv/include/site/python3.9/greenlet/greenlet.h delete mode 100644 .venv/share/man/man1/ipython.1 diff --git a/.venv/include/site/python3.9/greenlet/greenlet.h b/.venv/include/site/python3.9/greenlet/greenlet.h deleted file mode 100644 index d02a16e434..0000000000 --- a/.venv/include/site/python3.9/greenlet/greenlet.h +++ /dev/null @@ -1,164 +0,0 @@ -/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */ - -/* Greenlet object interface */ - -#ifndef Py_GREENLETOBJECT_H -#define Py_GREENLETOBJECT_H - - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -/* This is deprecated and undocumented. It does not change. */ -#define GREENLET_VERSION "1.0.0" - -#ifndef GREENLET_MODULE -#define implementation_ptr_t void* -#endif - -typedef struct _greenlet { - PyObject_HEAD - PyObject* weakreflist; - PyObject* dict; - implementation_ptr_t pimpl; -} PyGreenlet; - -#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type)) - - -/* C API functions */ - -/* Total number of symbols that are exported */ -#define PyGreenlet_API_pointers 12 - -#define PyGreenlet_Type_NUM 0 -#define PyExc_GreenletError_NUM 1 -#define PyExc_GreenletExit_NUM 2 - -#define PyGreenlet_New_NUM 3 -#define PyGreenlet_GetCurrent_NUM 4 -#define PyGreenlet_Throw_NUM 5 -#define PyGreenlet_Switch_NUM 6 -#define PyGreenlet_SetParent_NUM 7 - -#define PyGreenlet_MAIN_NUM 8 -#define PyGreenlet_STARTED_NUM 9 -#define PyGreenlet_ACTIVE_NUM 10 -#define PyGreenlet_GET_PARENT_NUM 11 - -#ifndef GREENLET_MODULE -/* This section is used by modules that uses the greenlet C API */ -static void** _PyGreenlet_API = NULL; - -# define PyGreenlet_Type \ - (*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM]) - -# define PyExc_GreenletError \ - ((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM]) - -# define PyExc_GreenletExit \ - ((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM]) - -/* - * PyGreenlet_New(PyObject *args) - * - * greenlet.greenlet(run, parent=None) - */ -# define PyGreenlet_New \ - (*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \ - _PyGreenlet_API[PyGreenlet_New_NUM]) - -/* - * PyGreenlet_GetCurrent(void) - * - * greenlet.getcurrent() - */ -# define PyGreenlet_GetCurrent \ - (*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM]) - -/* - * PyGreenlet_Throw( - * PyGreenlet *greenlet, - * PyObject *typ, - * PyObject *val, - * PyObject *tb) - * - * g.throw(...) - */ -# define PyGreenlet_Throw \ - (*(PyObject * (*)(PyGreenlet * self, \ - PyObject * typ, \ - PyObject * val, \ - PyObject * tb)) \ - _PyGreenlet_API[PyGreenlet_Throw_NUM]) - -/* - * PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args) - * - * g.switch(*args, **kwargs) - */ -# define PyGreenlet_Switch \ - (*(PyObject * \ - (*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \ - _PyGreenlet_API[PyGreenlet_Switch_NUM]) - -/* - * PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent) - * - * g.parent = new_parent - */ -# define PyGreenlet_SetParent \ - (*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \ - _PyGreenlet_API[PyGreenlet_SetParent_NUM]) - -/* - * PyGreenlet_GetParent(PyObject* greenlet) - * - * return greenlet.parent; - * - * This could return NULL even if there is no exception active. - * If it does not return NULL, you are responsible for decrementing the - * reference count. - */ -# define PyGreenlet_GetParent \ - (*(PyGreenlet* (*)(PyGreenlet*)) \ - _PyGreenlet_API[PyGreenlet_GET_PARENT_NUM]) - -/* - * deprecated, undocumented alias. - */ -# define PyGreenlet_GET_PARENT PyGreenlet_GetParent - -# define PyGreenlet_MAIN \ - (*(int (*)(PyGreenlet*)) \ - _PyGreenlet_API[PyGreenlet_MAIN_NUM]) - -# define PyGreenlet_STARTED \ - (*(int (*)(PyGreenlet*)) \ - _PyGreenlet_API[PyGreenlet_STARTED_NUM]) - -# define PyGreenlet_ACTIVE \ - (*(int (*)(PyGreenlet*)) \ - _PyGreenlet_API[PyGreenlet_ACTIVE_NUM]) - - - - -/* Macro that imports greenlet and initializes C API */ -/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we - keep the older definition to be sure older code that might have a copy of - the header still works. */ -# define PyGreenlet_Import() \ - { \ - _PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \ - } - -#endif /* GREENLET_MODULE */ - -#ifdef __cplusplus -} -#endif -#endif /* !Py_GREENLETOBJECT_H */ diff --git a/.venv/share/man/man1/ipython.1 b/.venv/share/man/man1/ipython.1 deleted file mode 100644 index 0f4a191f3f..0000000000 --- a/.venv/share/man/man1/ipython.1 +++ /dev/null @@ -1,60 +0,0 @@ -.\" Hey, EMACS: -*- nroff -*- -.\" First parameter, NAME, should be all caps -.\" Second parameter, SECTION, should be 1-8, maybe w/ subsection -.\" other parameters are allowed: see man(7), man(1) -.TH IPYTHON 1 "July 15, 2011" -.\" Please adjust this date whenever revising the manpage. -.\" -.\" Some roff macros, for reference: -.\" .nh disable hyphenation -.\" .hy enable hyphenation -.\" .ad l left justify -.\" .ad b justify to both left and right margins -.\" .nf disable filling -.\" .fi enable filling -.\" .br insert line break -.\" .sp insert n+1 empty lines -.\" for manpage-specific macros, see man(7) and groff_man(7) -.\" .SH section heading -.\" .SS secondary section heading -.\" -.\" -.\" To preview this page as plain text: nroff -man ipython.1 -.\" -.SH NAME -ipython \- Tools for Interactive Computing in Python. -.SH SYNOPSIS -.B ipython -.RI [ options ] " files" ... - -.B ipython subcommand -.RI [ options ] ... - -.SH DESCRIPTION -An interactive Python shell with automatic history (input and output), dynamic -object introspection, easier configuration, command completion, access to the -system shell, integration with numerical and scientific computing tools, -web notebook, Qt console, and more. - -For more information on how to use IPython, see 'ipython \-\-help', -or 'ipython \-\-help\-all' for all available command\(hyline options. - -.SH "ENVIRONMENT VARIABLES" -.sp -.PP -\fIIPYTHONDIR\fR -.RS 4 -This is the location where IPython stores all its configuration files. The default -is $HOME/.ipython if IPYTHONDIR is not defined. - -You can see the computed value of IPYTHONDIR with `ipython locate`. - -.SH FILES - -IPython uses various configuration files stored in profiles within IPYTHONDIR. -To generate the default configuration files and start configuring IPython, -do 'ipython profile create', and edit '*_config.py' files located in -IPYTHONDIR/profile_default. - -.SH AUTHORS -IPython is written by the IPython Development Team . From 939d9d72d7989447f1bec323560c9d8955956604 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Wed, 23 Oct 2024 12:50:55 +0200 Subject: [PATCH 38/60] fix [[]] on user_roles_editable_org_unit_type_ids --- iaso/models/base.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/iaso/models/base.py b/iaso/models/base.py index e4a6d7b6d5..0352eb5dac 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -1447,6 +1447,16 @@ class Meta: def __str__(self): return "%s -- %s" % (self.user, self.account) + def get_user_roles_editable_org_unit_type_ids(self): + try: + return self.annotated_user_roles_editable_org_unit_type_ids or [] + except AttributeError: + return list( + self.user_roles.values_list("editable_org_unit_types__id", flat=True) + .distinct() + .exclude(editable_org_unit_types__id__isnull=True) + ) + def as_dict(self, small=False): user_roles = self.user_roles.all() user_group_permissions = list( @@ -1461,12 +1471,9 @@ def as_dict(self, small=False): editable_org_unit_type_ids = self.annotated_editable_org_unit_types_ids except AttributeError: editable_org_unit_type_ids = [out.pk for out in self.editable_org_unit_types.all()] - try: - user_roles_editable_org_unit_type_ids = self.annotated_user_roles_editable_org_unit_type_ids - except AttributeError: - user_roles_editable_org_unit_type_ids = ( - list(self.user_roles.values_list("editable_org_unit_types", flat=True)), - ) + + user_roles_editable_org_unit_type_ids = self.get_user_roles_editable_org_unit_type_ids() + if not small: return { "id": self.id, @@ -1522,12 +1529,8 @@ def as_short_dict(self): editable_org_unit_type_ids = self.annotated_editable_org_unit_types_ids except AttributeError: editable_org_unit_type_ids = [out.pk for out in self.editable_org_unit_types.all()] - try: - user_roles_editable_org_unit_type_ids = self.annotated_user_roles_editable_org_unit_type_ids - except AttributeError: - user_roles_editable_org_unit_type_ids = ( - list(self.user_roles.values_list("editable_org_unit_types", flat=True)), - ) + + user_roles_editable_org_unit_type_ids = self.get_user_roles_editable_org_unit_type_ids() return { "id": self.id, "first_name": self.user.first_name, From d589e048b96186261ac1a6d4f6e0e98a10167668 Mon Sep 17 00:00:00 2001 From: kemar Date: Wed, 23 Oct 2024 14:54:19 +0200 Subject: [PATCH 39/60] Use `.distinct("id")` and remove useless `or` --- iaso/models/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iaso/models/base.py b/iaso/models/base.py index 0352eb5dac..e8ace2f29b 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -1449,11 +1449,11 @@ def __str__(self): def get_user_roles_editable_org_unit_type_ids(self): try: - return self.annotated_user_roles_editable_org_unit_type_ids or [] + return self.annotated_user_roles_editable_org_unit_type_ids except AttributeError: return list( self.user_roles.values_list("editable_org_unit_types__id", flat=True) - .distinct() + .distinct("id") .exclude(editable_org_unit_types__id__isnull=True) ) From c3483eb059dc4172fcc3276ee046e0719c796477 Mon Sep 17 00:00:00 2001 From: kemar Date: Wed, 23 Oct 2024 15:26:41 +0200 Subject: [PATCH 40/60] Rename `editable_org_unit_types` to `editable_org_unit_type_ids` in the payload of `/api/userroles/` --- iaso/api/user_roles.py | 20 +++++++++---------- iaso/tests/api/test_user_roles.py | 32 ++++++++++--------------------- 2 files changed, 19 insertions(+), 33 deletions(-) diff --git a/iaso/api/user_roles.py b/iaso/api/user_roles.py index ba799bf51d..181009df5a 100644 --- a/iaso/api/user_roles.py +++ b/iaso/api/user_roles.py @@ -23,29 +23,27 @@ class Meta: fields = ("id", "name", "codename") -class OrgUnitTypeNestedReadSerializer(serializers.ModelSerializer): - class Meta: - model = OrgUnitType - fields = ["id", "name", "short_name"] - - class UserRoleSerializer(serializers.ModelSerializer): permissions = serializers.SerializerMethodField("get_permissions") name = serializers.CharField(source="group.name") created_at = TimestampField(read_only=True) updated_at = TimestampField(read_only=True) + editable_org_unit_type_ids = serializers.PrimaryKeyRelatedField( + source="editable_org_unit_types", + queryset=OrgUnitType.objects.all(), + required=False, + allow_null=True, + many=True, + ) class Meta: model = UserRole - fields = ["id", "name", "permissions", "editable_org_unit_types", "created_at", "updated_at"] + fields = ["id", "name", "permissions", "editable_org_unit_type_ids", "created_at", "updated_at"] def to_representation(self, instance): user_role = super().to_representation(instance) account_id = user_role["name"].split("_")[0] user_role["name"] = user_role["name"].removeprefix(f"{account_id}_") - user_role["editable_org_unit_types"] = OrgUnitTypeNestedReadSerializer( - instance.editable_org_unit_types.only("id", "name", "short_name").order_by("id"), many=True - ).data return user_role def get_permissions(self, obj): @@ -98,7 +96,7 @@ def update(self, user_role, validated_data): user_role.editable_org_unit_types.set(editable_org_unit_types) return user_role - def validate_editable_org_unit_types(self, editable_org_unit_types): + def validate_editable_org_unit_type_ids(self, editable_org_unit_types) -> QuerySet[OrgUnitType]: account = self.context.get("request").user.iaso_profile.account project_org_unit_types = set(Project.objects.filter(account=account).values_list("unit_types__id", flat=True)) for org_unit_type in editable_org_unit_types: diff --git a/iaso/tests/api/test_user_roles.py b/iaso/tests/api/test_user_roles.py index dc46fe0096..804c5635c9 100644 --- a/iaso/tests/api/test_user_roles.py +++ b/iaso/tests/api/test_user_roles.py @@ -47,16 +47,14 @@ def setUpTestData(cls): def test_create_user_role(self): self.client.force_authenticate(self.user) - payload = {"name": "New user role name", "editable_org_unit_types": [self.org_unit_type.id]} + payload = {"name": "New user role name", "editable_org_unit_type_ids": [self.org_unit_type.id]} response = self.client.post("/api/userroles/", data=payload, format="json") r = self.assertJSONResponse(response, 201) self.assertEqual(r["name"], payload["name"]) self.assertIsNotNone(r["id"]) - self.assertEqual( - r["editable_org_unit_types"], [{"id": self.org_unit_type.id, "name": "Org unit type", "short_name": "OUT"}] - ) + self.assertEqual(r["editable_org_unit_type_ids"], [self.org_unit_type.id]) def test_create_user_role_without_name(self): self.client.force_authenticate(self.user) @@ -76,9 +74,7 @@ def test_retrieve_user_role(self): self.user_role.refresh_from_db() expected_name = self.user_role.group.name.removeprefix(f"{self.account.id}_") self.assertEqual(r["name"], expected_name) - self.assertEqual( - r["editable_org_unit_types"], [{"id": self.org_unit_type.id, "name": "Org unit type", "short_name": "OUT"}] - ) + self.assertEqual(r["editable_org_unit_type_ids"], [self.org_unit_type.id]) def test_retrieve_user_role_read_only(self): self.client.force_authenticate(self.user_with_no_permissions) @@ -87,9 +83,7 @@ def test_retrieve_user_role_read_only(self): r = self.assertJSONResponse(response, 200) self.assertEqual(r["id"], self.user_role.pk) - self.assertEqual( - r["editable_org_unit_types"], [{"id": self.org_unit_type.id, "name": "Org unit type", "short_name": "OUT"}] - ) + self.assertEqual(r["editable_org_unit_type_ids"], [self.org_unit_type.id]) def test_list_without_search(self): self.client.force_authenticate(self.user) @@ -112,8 +106,8 @@ def test_list_with_search_on_user_role_name(self): expected_name = self.user_role.group.name.removeprefix(f"{self.account.id}_") self.assertEqual(r["results"][0]["name"], expected_name) self.assertEqual( - r["results"][0]["editable_org_unit_types"], - [{"id": self.org_unit_type.id, "name": "Org unit type", "short_name": "OUT"}], + r["results"][0]["editable_org_unit_type_ids"], + [self.org_unit_type.id], ) def test_partial_update_invalid_org_unit_type(self): @@ -124,14 +118,14 @@ def test_partial_update_invalid_org_unit_type(self): self.client.force_authenticate(self.user) payload = { "name": self.user_role.group.name, - "editable_org_unit_types": [invalid_org_unit_type.pk], + "editable_org_unit_type_ids": [invalid_org_unit_type.pk], } response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") r = self.assertJSONResponse(response, 400) self.assertEqual( - r["editable_org_unit_types"], + r["editable_org_unit_type_ids"], [ f"`{invalid_org_unit_type.name} ({invalid_org_unit_type.pk})` is not a valid Org Unit Type for this account." ], @@ -144,20 +138,14 @@ def test_partial_update_no_modification(self): self.client.force_authenticate(self.user) payload = { "name": self.user_role.group.name, - "editable_org_unit_types": [self.org_unit_type.id, new_org_unit_type.id], + "editable_org_unit_type_ids": [self.org_unit_type.id, new_org_unit_type.id], } response = self.client.put(f"/api/userroles/{self.user_role.id}/", data=payload, format="json") r = self.assertJSONResponse(response, 200) self.assertEqual(r["name"], payload["name"]) - self.assertEqual( - r["editable_org_unit_types"], - [ - {"id": self.org_unit_type.id, "name": "Org unit type", "short_name": "OUT"}, - {"id": new_org_unit_type.id, "name": "New org unit type", "short_name": "NOUT"}, - ], - ) + self.assertEqual(r["editable_org_unit_type_ids"], [self.org_unit_type.id, new_org_unit_type.id]) def test_partial_update_no_permission(self): self.client.force_authenticate(self.user_with_no_permissions) From 639601549c827a2379ce55ed95c94a4957cc105e Mon Sep 17 00:00:00 2001 From: kemar Date: Wed, 23 Oct 2024 15:34:27 +0200 Subject: [PATCH 41/60] Add a missing property `editable_org_unit_type_ids` --- hat/assets/js/apps/Iaso/domains/userRoles/config.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hat/assets/js/apps/Iaso/domains/userRoles/config.tsx b/hat/assets/js/apps/Iaso/domains/userRoles/config.tsx index 77b3879d06..5f83fd4b67 100644 --- a/hat/assets/js/apps/Iaso/domains/userRoles/config.tsx +++ b/hat/assets/js/apps/Iaso/domains/userRoles/config.tsx @@ -46,6 +46,10 @@ export const useGetUserRolesColumns = ( id={settings.row.original.id} name={settings.row.original.name} permissions={settings.row.original.permissions} + editable_org_unit_type_ids={ + settings.row.original + .editable_org_unit_type_ids + } iconProps={{}} /> Date: Thu, 24 Oct 2024 09:22:24 +0200 Subject: [PATCH 42/60] Allow to launch an analysis when there is no any duplicates --- .../duplicates/list/AnalyseAction.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/entities/duplicates/list/AnalyseAction.tsx b/hat/assets/js/apps/Iaso/domains/entities/duplicates/list/AnalyseAction.tsx index 0c97c8db51..d638b3e6b3 100644 --- a/hat/assets/js/apps/Iaso/domains/entities/duplicates/list/AnalyseAction.tsx +++ b/hat/assets/js/apps/Iaso/domains/entities/duplicates/list/AnalyseAction.tsx @@ -39,8 +39,9 @@ export const AnalyseAction: FunctionComponent = ({ {!latestAnalysis && !isFetchingLatestAnalysis && formatMessage(MESSAGES.noAnalysis)} - {latestAnalysis && ( - <> + + <> + {latestAnalysis && ( @@ -67,7 +68,9 @@ export const AnalyseAction: FunctionComponent = ({ - + )} + + {latestAnalysis && ( - - - - - + )} + + + + - - )} + + ); }; From 2cfb95ca1739c8792743cb659290de13b123c525 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Thu, 24 Oct 2024 11:06:28 +0200 Subject: [PATCH 43/60] merge migrations --- iaso/migrations/0306_merge_20241024_0902.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 iaso/migrations/0306_merge_20241024_0902.py diff --git a/iaso/migrations/0306_merge_20241024_0902.py b/iaso/migrations/0306_merge_20241024_0902.py new file mode 100644 index 0000000000..5f5be018ec --- /dev/null +++ b/iaso/migrations/0306_merge_20241024_0902.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.16 on 2024-10-24 09:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0304_auto_20241015_1017"), + ("iaso", "0305_userrole_editable_org_unit_types"), + ] + + operations = [] From eda6afd2186c37440060f48e46c7322eb8af28a8 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Thu, 24 Oct 2024 11:31:47 +0200 Subject: [PATCH 44/60] this is using orgunit popup, not instance popup --- .../components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx | 4 ++-- .../components/orgUnitMap/OrgUnitMap/MarkersList.tsx | 6 +++--- .../components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx index bedd5c8887..70946b7afe 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/FormsMarkers.tsx @@ -35,8 +35,8 @@ export const FormsMarkers: FunctionComponent = ({ color={f.color} keyId={f.id} PopupComponent={InstancePopup} - popupProps={i => ({ - instanceId: i.id, + popupProps={o => ({ + orgUnitId: o.id, })} updateOrgUnitLocation={updateOrgUnitLocation} /> diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx index b54a10e4bd..be2000c33f 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/MarkersList.tsx @@ -10,7 +10,7 @@ type Props = { color?: string; keyId: string | number; updateOrgUnitLocation: (orgUnit: OrgUnit) => void; - popupProps?: any; + popupProps?: (orgUnit: OrgUnit) => Record; }; export const MarkerList: FunctionComponent = ({ @@ -26,11 +26,11 @@ export const MarkerList: FunctionComponent = ({ key={keyId} items={locationsList} PopupComponent={PopupComponent} - popupProps={() => ({ + popupProps={orgUnit => ({ displayUseLocation: true, replaceLocation: selectedOrgUnit => updateOrgUnitLocation(selectedOrgUnit), - ...popupProps, + ...popupProps?.(orgUnit), })} isCircle markerProps={() => ({ diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx index 27ef137a55..872da8a534 100644 --- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx +++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SelectedMarkers.tsx @@ -38,7 +38,7 @@ export const SelectedMarkers: FunctionComponent = ({ ({ - instanceId: o.id, + orgUnitId: o.id, })} color={mappedOrgUnit.color} keyId={mappedOrgUnit.id} From 6e169f62e5c917d733abc3f4b9c0fe6e7aab716d Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Thu, 24 Oct 2024 11:52:48 +0200 Subject: [PATCH 45/60] code review --- .../instances/components/InstancesFiltersComponent.js | 6 ++---- hat/assets/js/apps/Iaso/domains/instances/index.js | 4 ---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js index af1d0a4d2a..39186bfd08 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js +++ b/hat/assets/js/apps/Iaso/domains/instances/components/InstancesFiltersComponent.js @@ -79,12 +79,12 @@ const InstancesFiltersComponent = ({ formDetails, tableColumns, tab, - isInstancesFilterUpdated, - setIsInstancesFilterUpdated, }) => { const { formatMessage } = useSafeIntl(); const classes = useStyles(); + const [isInstancesFilterUpdated, setIsInstancesFilterUpdated] = + useState(false); const [hasLocationLimitError, setHasLocationLimitError] = useState(false); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); @@ -597,8 +597,6 @@ InstancesFiltersComponent.propTypes = { periodType: PropTypes.string, possibleFields: PropTypes.array, formDetails: PropTypes.object, - setIsInstancesFilterUpdated: PropTypes.func.isRequired, - isInstancesFilterUpdated: PropTypes.bool.isRequired, }; export default InstancesFiltersComponent; diff --git a/hat/assets/js/apps/Iaso/domains/instances/index.js b/hat/assets/js/apps/Iaso/domains/instances/index.js index bf28c913a4..041d04e9e4 100644 --- a/hat/assets/js/apps/Iaso/domains/instances/index.js +++ b/hat/assets/js/apps/Iaso/domains/instances/index.js @@ -55,8 +55,6 @@ const Instances = () => { const { formatMessage } = useSafeIntl(); const queryClient = useQueryClient(); const redirectToReplace = useRedirectToReplace(); - const [isInstancesFilterUpdated, setIsInstancesFilterUpdated] = - useState(false); const [selection, setSelection] = useState(selectionInitialState); const [tableColumns, setTableColumns] = useState([]); const [tab, setTab] = useState(params.tab ?? 'list'); @@ -170,8 +168,6 @@ const Instances = () => { formDetails={formDetails} tableColumns={tableColumns} tab={tab} - setIsInstancesFilterUpdated={setIsInstancesFilterUpdated} - isInstancesFilterUpdated={isInstancesFilterUpdated} /> {tab === 'list' && isSingleFormSearch && ( From f7bd4d91b6a90535e89b738e404d98aeb9dd1771 Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Wed, 14 Aug 2024 16:23:15 +0200 Subject: [PATCH 46/60] WC2-434 Add AccountSwitch component to TopBarComponent --- .../Iaso/components/nav/AccountSwitch.tsx | 75 +++++++++++++++++++ .../Iaso/components/nav/TopBarComponent.js | 7 ++ 2 files changed, 82 insertions(+) create mode 100644 hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx diff --git a/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx b/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx new file mode 100644 index 0000000000..59af168457 --- /dev/null +++ b/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx @@ -0,0 +1,75 @@ +import React, { FunctionComponent, useState } from 'react'; + +import { Menu, MenuItem, Typography } from '@mui/material'; +import { makeStyles } from '@mui/styles'; + +import { useCurrentUser } from '../../utils/usersUtils'; + +const useStyles = makeStyles(theme => ({ + accountSwitchButton: { + padding: theme.spacing(0), + }, +})); + +type Props = { + color?: 'inherit' | 'primary' | 'secondary'; +}; + +export const AccountSwitch: FunctionComponent = ({ + color = 'inherit', +}) => { + const currentUser = useCurrentUser(); + const classes = useStyles(); + + const [anchorEl, setAnchorEl] = useState(null); + const handleClickListItem = event => { + setAnchorEl(event.currentTarget); + }; + + const handleAccountSwitch = accountId => { + console.log('accountId', accountId); + // setLocale(localeCode); + // saveCurrentUser({ + // language: localeCode, + // }); + setAnchorEl(null); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + if (currentUser.accounts) { + return ( + <> + + {currentUser.account.name} + + + {currentUser.accounts.map(account => ( + handleAccountSwitch(account.id)} + > + {account.name} + + ))} + + + ); + } else { + return null; + } +}; diff --git a/hat/assets/js/apps/Iaso/components/nav/TopBarComponent.js b/hat/assets/js/apps/Iaso/components/nav/TopBarComponent.js index af7161256b..aceba62de1 100644 --- a/hat/assets/js/apps/Iaso/components/nav/TopBarComponent.js +++ b/hat/assets/js/apps/Iaso/components/nav/TopBarComponent.js @@ -14,6 +14,7 @@ import { ThemeConfigContext } from '../../domains/app/contexts/ThemeConfigContex import { useCurrentUser } from '../../utils/usersUtils.ts'; import { useSidebar } from '../../domains/app/contexts/SideBarContext.tsx'; +import { AccountSwitch } from './AccountSwitch.tsx'; import { CurrentUserInfos } from './CurrentUser/index.tsx'; import { LogoutButton } from './LogoutButton.tsx'; import { HomePageButton } from './HomePageButton.tsx'; @@ -122,9 +123,15 @@ function TopBar(props) { version={window.IASO_VERSION} /> + + + + + + From 3b2c1707f3078e37053c7dab8abb571938bf243f Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Fri, 16 Aug 2024 11:08:36 +0200 Subject: [PATCH 47/60] WC2-434 Add backend endpoint to switch account v1 --- .../Iaso/components/nav/AccountSwitch.tsx | 8 ++--- .../js/apps/Iaso/hooks/useSwitchAccount.tsx | 9 ++++++ iaso/api/accounts.py | 29 +++++++++++++++++-- 3 files changed, 40 insertions(+), 6 deletions(-) create mode 100644 hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx diff --git a/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx b/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx index 59af168457..1b99039de7 100644 --- a/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx +++ b/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx @@ -4,6 +4,7 @@ import { Menu, MenuItem, Typography } from '@mui/material'; import { makeStyles } from '@mui/styles'; import { useCurrentUser } from '../../utils/usersUtils'; +import { useSwitchAccount } from '../../hooks/useSwitchAccount'; const useStyles = makeStyles(theme => ({ accountSwitchButton: { @@ -26,12 +27,11 @@ export const AccountSwitch: FunctionComponent = ({ setAnchorEl(event.currentTarget); }; + const { mutateAsync: switchAccount, isLoading } = useSwitchAccount(); + const handleAccountSwitch = accountId => { console.log('accountId', accountId); - // setLocale(localeCode); - // saveCurrentUser({ - // language: localeCode, - // }); + switchAccount(accountId); setAnchorEl(null); }; diff --git a/hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx b/hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx new file mode 100644 index 0000000000..46cb198666 --- /dev/null +++ b/hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx @@ -0,0 +1,9 @@ +import { UseMutationResult } from 'react-query'; +import { patchRequest } from '../libs/Api'; +import { useSnackMutation } from '../libs/apiHooks'; + +export const useSwitchAccount = (): UseMutationResult => + useSnackMutation({ + mutationFn: accountId => + patchRequest('/api/accounts/switch/', { account_id: accountId }), + }); diff --git a/iaso/api/accounts.py b/iaso/api/accounts.py index 0d20a90041..465ac6850a 100644 --- a/iaso/api/accounts.py +++ b/iaso/api/accounts.py @@ -1,12 +1,18 @@ """This api is only there so the default version on an account can be modified""" + from .common import ModelViewSet, HasPermission from iaso.models import Account, SourceVersion -from rest_framework import serializers, permissions +from rest_framework import serializers, permissions, status +from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 from rest_framework.request import Request from hat.menupermissions import models as permission +from django.contrib.auth.models import User +from django.contrib.auth import login +from rest_framework.response import Response + class AccountSerializer(serializers.ModelSerializer): class Meta: @@ -60,4 +66,23 @@ class AccountViewSet(ModelViewSet): results_key = "accounts" queryset = Account.objects.all() # FIXME: USe a PATCH in the future, it make more sense regarding HTTP method semantic - http_method_names = ["put"] + http_method_names = ["patch", "put"] + + @action(detail=False, methods=["patch"], url_path="switch") + def switch(self, request): + print("SWITCH ACCOUNT!") + print("SWITCH ACCOUNT!") + print("SWITCH ACCOUNT!") + print(request.data) + account_id = request.data.get("account_id", None) + + # current_user = request.user + # print("current_user.backend", current_user.backend) + + # account = Account.objects.get(id=account_id) + user = User.objects.get(id=3) + user.backend = "django.contrib.auth.backends.ModelBackend" + + login(request, user) + + return Response(status=status.HTTP_204_NO_CONTENT) From c81bea2c66ded9a7f778a3c45e381cd54520cec1 Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Mon, 19 Aug 2024 15:01:54 +0200 Subject: [PATCH 48/60] WC2-434 Multi-account users first rough version With very rough working "invitation". --- .../Iaso/components/nav/AccountSwitch.tsx | 7 +- .../js/apps/Iaso/domains/home/HomeOnline.tsx | 7 +- hat/assets/js/apps/Iaso/utils/usersUtils.ts | 19 ++-- iaso/admin.py | 2 + iaso/api/accounts.py | 30 +++--- iaso/api/profiles/profiles.py | 64 ++++++++++--- ...er_tenantuser_main_user_user_constraint.py | 57 ++++++++++++ iaso/models/__init__.py | 1 + iaso/models/base.py | 91 +++++++++---------- iaso/models/tenant_users.py | 38 ++++++++ 10 files changed, 228 insertions(+), 88 deletions(-) create mode 100644 iaso/migrations/0295_tenantuser_tenantuser_main_user_user_constraint.py create mode 100644 iaso/models/tenant_users.py diff --git a/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx b/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx index 1b99039de7..c4234a2a05 100644 --- a/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx +++ b/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx @@ -27,19 +27,20 @@ export const AccountSwitch: FunctionComponent = ({ setAnchorEl(event.currentTarget); }; - const { mutateAsync: switchAccount, isLoading } = useSwitchAccount(); + const { mutateAsync: switchAccount } = useSwitchAccount(); const handleAccountSwitch = accountId => { console.log('accountId', accountId); switchAccount(accountId); setAnchorEl(null); + window.location.href = '/'; }; const handleClose = () => { setAnchorEl(null); }; - if (currentUser.accounts) { + if (currentUser.other_accounts.length > 0) { return ( <> = ({ open={Boolean(anchorEl)} onClose={handleClose} > - {currentUser.accounts.map(account => ( + {currentUser.other_accounts.map(account => ( ({ root: { @@ -129,8 +130,12 @@ export const HomeOnline: FunctionComponent = () => { version={(window as any).IASO_VERSION} /> + - + + + + diff --git a/hat/assets/js/apps/Iaso/utils/usersUtils.ts b/hat/assets/js/apps/Iaso/utils/usersUtils.ts index 20fbb82988..15da01c30f 100644 --- a/hat/assets/js/apps/Iaso/utils/usersUtils.ts +++ b/hat/assets/js/apps/Iaso/utils/usersUtils.ts @@ -52,6 +52,15 @@ export type SourceVersion = { version?: DataSource; }; +export type Account = { + name: string; + id: number; + created_at: number; + updated_at: number; + default_version?: DefaultVersion; + feature_flags: string[]; +}; + export type User = { id: number; first_name: string; @@ -59,14 +68,8 @@ export type User = { username?: string; user_name?: string; email: string; - account: { - name: string; - id: number; - created_at: number; - updated_at: number; - default_version?: DefaultVersion; - feature_flags: string[]; - }; + account: Account; + other_accounts: Account[]; permissions: string[]; is_staff?: boolean; is_superuser: boolean; diff --git a/iaso/admin.py b/iaso/admin.py index 90ee14c2ac..8459e64664 100644 --- a/iaso/admin.py +++ b/iaso/admin.py @@ -71,6 +71,7 @@ StorageLogEntry, StoragePassword, Task, + TenantUser, UserRole, Workflow, WorkflowChange, @@ -996,3 +997,4 @@ class GroupSetAdmin(admin.ModelAdmin): admin.site.register(BulkCreateUserCsvFile) admin.site.register(Report) admin.site.register(ReportVersion) +admin.site.register(TenantUser) diff --git a/iaso/api/accounts.py b/iaso/api/accounts.py index 465ac6850a..43df0fd2dc 100644 --- a/iaso/api/accounts.py +++ b/iaso/api/accounts.py @@ -59,7 +59,8 @@ class AccountViewSet(ModelViewSet): permission_classes = [ permissions.IsAuthenticated, - HasPermission(permission.SOURCES), # type: ignore + # TODO: How to remove this only for the switch? + # HasPermission(permission.SOURCES), # type: ignore HasAccountPermission, ] serializer_class = AccountSerializer @@ -70,19 +71,18 @@ class AccountViewSet(ModelViewSet): @action(detail=False, methods=["patch"], url_path="switch") def switch(self, request): - print("SWITCH ACCOUNT!") - print("SWITCH ACCOUNT!") - print("SWITCH ACCOUNT!") - print(request.data) + # TODO: Make sure the account_id is present account_id = request.data.get("account_id", None) - # current_user = request.user - # print("current_user.backend", current_user.backend) - - # account = Account.objects.get(id=account_id) - user = User.objects.get(id=3) - user.backend = "django.contrib.auth.backends.ModelBackend" - - login(request, user) - - return Response(status=status.HTTP_204_NO_CONTENT) + current_user = request.user + account_users = current_user.tenant_user.get_all_account_users() + user_to_login = next( + (u for u in account_users if u.iaso_profile and u.iaso_profile.account_id == account_id), None + ) + + if user_to_login: + user_to_login.backend = "django.contrib.auth.backends.ModelBackend" + login(request, user_to_login) + return Response(user_to_login.iaso_profile.account.as_dict()) + else: + return Response(status=status.HTTP_404_NOT_FOUND) diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index df7e49b72d..b9f00ab0d5 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -1,7 +1,8 @@ +import copy from typing import Any, List, Optional, Union from django.conf import settings -from django.contrib.auth import models, update_session_auth_hash +from django.contrib.auth import login, models, update_session_auth_hash from django.contrib.auth.models import Permission, User from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core.exceptions import BadRequest, ObjectDoesNotExist @@ -27,7 +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, 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 @@ -355,11 +356,18 @@ def get_row(profile: Profile, **_) -> List[Any]: def retrieve(self, request, *args, **kwargs): pk = kwargs.get("pk") if pk == PK_ME: + # if the user is a main_user, login as an account user + # TODO: remember the last account_user + if request.user.tenant_users.exists(): + account_user = request.user.tenant_users.first().account_user + account_user.backend = "django.contrib.auth.backends.ModelBackend" + login(request, account_user) + try: profile = request.user.iaso_profile profile_dict = profile.as_dict() return Response(profile_dict) - except ObjectDoesNotExist: + except Profile.DoesNotExist: return Response( { "first_name": request.user.first_name, @@ -658,6 +666,9 @@ def get_subject_by_language(self, language="en"): return self.EMAIL_SUBJECT_FR if language == "fr" else self.EMAIL_SUBJECT_EN def create(self, request): + current_profile = request.user.iaso_profile + current_account = current_profile.account + username = request.data.get("user_name") password = request.data.get("password", "") send_email_invitation = request.data.get("send_email_invitation") @@ -666,9 +677,40 @@ def create(self, request): return JsonResponse({"errorKey": "user_name", "errorMessage": _("Nom d'utilisateur requis")}, status=400) if not password and not send_email_invitation: return JsonResponse({"errorKey": "password", "errorMessage": _("Mot de passe requis")}, status=400) - existing_user = User.objects.filter(username__iexact=username) - if existing_user: - return JsonResponse({"errorKey": "user_name", "errorMessage": _("Nom d'utilisateur existant")}, status=400) + + try: + existing_user = User.objects.get(username__iexact=username) + main_user = None + if existing_user: + user_in_same_account = ( + existing_user.iaso_profile and existing_user.iaso_profile.account == current_account + ) + if user_in_same_account: + return JsonResponse( + {"errorKey": "user_name", "errorMessage": _("Nom d'utilisateur existant")}, status=400 + ) + else: + # TODO: invitation + # TODO what if no iaso_profile? + # TODO what if already main user? + old_username = username + username = f"{username}_{current_account.name.lower().replace(' ', '_')}" + + # duplicate existing_user into main user and account user + main_user = copy.copy(existing_user) + + existing_user.username = ( + f"{old_username}_{existing_user.iaso_profile.account.name.lower().replace(' ', '_')}" + ) + existing_user.set_unusable_password() + existing_user.save() + + main_user.pk = None + main_user.save() + + TenantUser.objects.create(main_user=main_user, account_user=existing_user) + except User.DoesNotExist: + pass # no existing user, simply create a new user user = User() user.first_name = request.data.get("first_name", "") @@ -677,14 +719,15 @@ def create(self, request): user.email = request.data.get("email", "") permissions = request.data.get("user_permissions", []) - current_profile = request.user.iaso_profile - current_account = current_profile.account - modules_permissions = self.module_permissions(current_account) if password != "": user.set_password(password) user.save() + + if existing_user: + TenantUser.objects.create(main_user=main_user, account_user=user) + for permission_codename in permissions: if permission_codename in modules_permissions: permission = get_object_or_404(Permission, codename=permission_codename) @@ -694,7 +737,6 @@ def create(self, request): # Create an Iaso profile for the new user and attach it to the same account # as the currently authenticated user - current_profile = request.user.iaso_profile user.profile = Profile.objects.create( user=user, account=current_account, @@ -714,7 +756,7 @@ def create(self, request): user_roles = request.data.get("user_roles", []) for user_role_id in user_roles: # Get only a user role linked to the account's user - user_role_item = get_object_or_404(UserRole, pk=user_role_id, account=current_profile.account) + user_role_item = get_object_or_404(UserRole, pk=user_role_id, account=current_account) user_group_item = get_object_or_404(models.Group, pk=user_role_item.group.id) profile.user.groups.add(user_group_item) profile.user_roles.add(user_role_item) diff --git a/iaso/migrations/0295_tenantuser_tenantuser_main_user_user_constraint.py b/iaso/migrations/0295_tenantuser_tenantuser_main_user_user_constraint.py new file mode 100644 index 0000000000..7846f007fb --- /dev/null +++ b/iaso/migrations/0295_tenantuser_tenantuser_main_user_user_constraint.py @@ -0,0 +1,57 @@ +# Generated by Django 4.2.14 on 2024-08-19 07:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("iaso", "0294_project_redirection_url"), + ] + + operations = [ + migrations.CreateModel( + name="TenantUser", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "account_user", + models.OneToOneField( + on_delete=django.db.models.deletion.PROTECT, + related_name="tenant_user", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "main_user", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="tenant_users", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "indexes": [ + models.Index(fields=["created_at"], name="iaso_tenant_created_003e12_idx"), + models.Index(fields=["updated_at"], name="iaso_tenant_updated_31049b_idx"), + ], + }, + ), + migrations.AddConstraint( + model_name="tenantuser", + constraint=models.UniqueConstraint(fields=("main_user", "account_user"), name="main_user_user_constraint"), + ), + ] diff --git a/iaso/models/__init__.py b/iaso/models/__init__.py index 83d8441891..7966341e52 100644 --- a/iaso/models/__init__.py +++ b/iaso/models/__init__.py @@ -15,3 +15,4 @@ from .deduplication import EntityDuplicateAnalyzis, EntityDuplicate from .microplanning import Planning, Team from .payments import Payment, PotentialPayment, PaymentLot +from .tenant_users import TenantUser diff --git a/iaso/models/base.py b/iaso/models/base.py index e8ace2f29b..925f30005d 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -1474,54 +1474,44 @@ def as_dict(self, small=False): user_roles_editable_org_unit_type_ids = self.get_user_roles_editable_org_unit_type_ids() - if not small: - return { - "id": self.id, - "first_name": self.user.first_name, - "user_name": self.user.username, - "last_name": self.user.last_name, - "email": self.user.email, - "account": self.account.as_small_dict(), - "permissions": permissions, - "user_permissions": user_permissions, - "is_superuser": self.user.is_superuser, - "org_units": [o.as_small_dict() for o in self.org_units.all().order_by("name")], - "user_roles": list(role.id for role in user_roles), - "user_roles_permissions": list(role.as_dict() for role in user_roles), - "language": self.language, - "organization": self.organization, - "user_id": self.user.id, - "dhis2_id": self.dhis2_id, - "home_page": self.home_page, - "phone_number": self.phone_number.as_e164 if self.phone_number else None, - "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, - "projects": [p.as_dict() for p in self.projects.all().order_by("name")], - "editable_org_unit_type_ids": editable_org_unit_type_ids, - "user_roles_editable_org_unit_type_ids": user_roles_editable_org_unit_type_ids, + other_accounts = [] + user_infos = self.user + if hasattr(self.user, "tenant_user"): + other_accounts = self.user.tenant_user.get_other_accounts() + user_infos = self.user.tenant_user.main_user + + result = { + "id": self.id, + "first_name": user_infos.first_name, + "user_name": user_infos.username, + "last_name": user_infos.last_name, + "email": user_infos.email, + "permissions": permissions, + "user_permissions": user_permissions, + "is_superuser": self.user.is_superuser, + "user_roles": list(role.id for role in user_roles), + "user_roles_permissions": list(role.as_dict() for role in user_roles), + "language": self.language, + "organization": self.organization, + "user_id": self.user.id, + "dhis2_id": self.dhis2_id, + "home_page": self.home_page, + "phone_number": self.phone_number.as_e164 if self.phone_number else None, + "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, + "projects": [p.as_dict() for p in self.projects.all().order_by("name")], + "other_accounts": [account.as_dict() for account in other_accounts], + "editable_org_unit_type_ids": editable_org_unit_type_ids, + "user_roles_editable_org_unit_type_ids": user_roles_editable_org_unit_type_ids, + } + + if small: + return result | { + "org_units": [o.as_very_small_dict() for o in self.org_units.all()], } else: - return { - "id": self.id, - "first_name": self.user.first_name, - "user_name": self.user.username, - "last_name": self.user.last_name, - "email": self.user.email, - "permissions": permissions, - "user_permissions": user_permissions, - "is_superuser": self.user.is_superuser, - "org_units": [o.as_very_small_dict() for o in self.org_units.all()], - "user_roles": list(role.id for role in user_roles), - "user_roles_permissions": list(role.as_dict() for role in user_roles), - "language": self.language, - "user_id": self.user.id, - "dhis2_id": self.dhis2_id, - "home_page": self.home_page, - "organization": self.organization, - "phone_number": self.phone_number.as_e164 if self.phone_number else None, - "country_code": region_code_for_number(self.phone_number).lower() if self.phone_number else None, - "projects": [p.as_dict() for p in self.projects.all()], - "editable_org_unit_type_ids": editable_org_unit_type_ids, - "user_roles_editable_org_unit_type_ids": user_roles_editable_org_unit_type_ids, + return result | { + "account": self.account.as_small_dict(), + "org_units": [o.as_small_dict() for o in self.org_units.all().order_by("name")], } def as_short_dict(self): @@ -1531,12 +1521,13 @@ def as_short_dict(self): editable_org_unit_type_ids = [out.pk for out in self.editable_org_unit_types.all()] user_roles_editable_org_unit_type_ids = self.get_user_roles_editable_org_unit_type_ids() + return { "id": self.id, - "first_name": self.user.first_name, - "user_name": self.user.username, - "last_name": self.user.last_name, - "email": self.user.email, + "first_name": user_infos.first_name, + "user_name": user_infos.username, + "last_name": user_infos.last_name, + "email": user_infos.email, "language": self.language, "user_id": self.user.id, "phone_number": self.phone_number.as_e164 if self.phone_number else None, diff --git a/iaso/models/tenant_users.py b/iaso/models/tenant_users.py new file mode 100644 index 0000000000..c08ccd2b1b --- /dev/null +++ b/iaso/models/tenant_users.py @@ -0,0 +1,38 @@ +from django.contrib.auth.models import User +from django.db import models + + +class TenantUser(models.Model): + main_user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="tenant_users") + account_user = models.OneToOneField(User, on_delete=models.PROTECT, related_name="tenant_user") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["main_user", "account_user"], name="main_user_user_constraint"), + ] + indexes = [ + models.Index(fields=["created_at"]), + models.Index(fields=["updated_at"]), + ] + + @property + def account(self): + return self.account_user.iaso_profile and self.account_user.iaso_profile.account + + def get_all_account_users(self): + return [tu.account_user for tu in self.main_user.tenant_users.all()] + + def get_other_accounts(self): + return [tu.account for tu in self.main_user.tenant_users.exclude(pk=self.pk)] + + def __str__(self): + return "%s -- %s (%s)" % (self.main_user, self.account_user, self.account_user.iaso_profile.account) + + def as_dict(self): + return { + "id": self.id, + "main_user_id": self.main_user_id, + "account": self.account_user.iaso_profile.account.as_dict(), + } From b5d1bd2f5b9e3d47f0c01f0fef7a2c25562ca7e9 Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Mon, 16 Sep 2024 16:43:46 +0200 Subject: [PATCH 49/60] WC2-434 Fix migrations after rebase --- iaso/migrations/0300_merge_20240916_1441.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 iaso/migrations/0300_merge_20240916_1441.py diff --git a/iaso/migrations/0300_merge_20240916_1441.py b/iaso/migrations/0300_merge_20240916_1441.py new file mode 100644 index 0000000000..de1ebf25c8 --- /dev/null +++ b/iaso/migrations/0300_merge_20240916_1441.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.14 on 2024-09-16 14:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0295_tenantuser_tenantuser_main_user_user_constraint"), + ("iaso", "0299_merge_0297_entity_merged_to_0298_profile_organization"), + ] + + operations = [] From a452307d0246662dcb176b9684fee2b028c92d60 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Tue, 17 Sep 2024 10:20:58 +0200 Subject: [PATCH 50/60] fix async account change --- .../js/apps/Iaso/components/nav/AccountSwitch.tsx | 15 +++++---------- .../js/apps/Iaso/hooks/useSwitchAccount.tsx | 6 +++++- iaso/api/profiles/profiles.py | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx b/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx index c4234a2a05..533e3600a9 100644 --- a/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx +++ b/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx @@ -3,8 +3,8 @@ import React, { FunctionComponent, useState } from 'react'; import { Menu, MenuItem, Typography } from '@mui/material'; import { makeStyles } from '@mui/styles'; -import { useCurrentUser } from '../../utils/usersUtils'; import { useSwitchAccount } from '../../hooks/useSwitchAccount'; +import { useCurrentUser } from '../../utils/usersUtils'; const useStyles = makeStyles(theme => ({ accountSwitchButton: { @@ -27,14 +27,10 @@ export const AccountSwitch: FunctionComponent = ({ setAnchorEl(event.currentTarget); }; - const { mutateAsync: switchAccount } = useSwitchAccount(); - - const handleAccountSwitch = accountId => { - console.log('accountId', accountId); - switchAccount(accountId); + const { mutateAsync: switchAccount } = useSwitchAccount(() => { setAnchorEl(null); window.location.href = '/'; - }; + }); const handleClose = () => { setAnchorEl(null); @@ -62,7 +58,7 @@ export const AccountSwitch: FunctionComponent = ({ handleAccountSwitch(account.id)} + onClick={() => switchAccount(account.id)} > {account.name} @@ -70,7 +66,6 @@ export const AccountSwitch: FunctionComponent = ({ ); - } else { - return null; } + return null; }; diff --git a/hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx b/hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx index 46cb198666..783538fd5a 100644 --- a/hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx +++ b/hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx @@ -2,8 +2,12 @@ import { UseMutationResult } from 'react-query'; import { patchRequest } from '../libs/Api'; import { useSnackMutation } from '../libs/apiHooks'; -export const useSwitchAccount = (): UseMutationResult => +export const useSwitchAccount = ( + onSuccess?: () => void, +): UseMutationResult => useSnackMutation({ mutationFn: accountId => patchRequest('/api/accounts/switch/', { account_id: accountId }), + options: { onSuccess: onSuccess || (() => null) }, + showSucessSnackBar: false, }); diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index b9f00ab0d5..398a8ea0bf 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -725,7 +725,7 @@ def create(self, request): user.set_password(password) user.save() - if existing_user: + if main_user: TenantUser.objects.create(main_user=main_user, account_user=user) for permission_codename in permissions: From cbab0c0f3f33999135e416b77fa0bba95ec0b6f3 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Thu, 19 Sep 2024 14:38:25 +0200 Subject: [PATCH 51/60] make it pretty --- .../Iaso/components/nav/AccountSwitch.tsx | 170 +++++++++++++----- .../Iaso/components/nav/CurrentUser/index.tsx | 36 ++-- .../Iaso/components/nav/TopBarComponent.js | 11 +- .../js/apps/Iaso/domains/home/HomeOnline.tsx | 11 +- 4 files changed, 147 insertions(+), 81 deletions(-) diff --git a/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx b/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx index 533e3600a9..d4de431107 100644 --- a/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx +++ b/hat/assets/js/apps/Iaso/components/nav/AccountSwitch.tsx @@ -1,17 +1,17 @@ -import React, { FunctionComponent, useState } from 'react'; - -import { Menu, MenuItem, Typography } from '@mui/material'; -import { makeStyles } from '@mui/styles'; +import { + ClickAwayListener, + Grow, + MenuItem, + MenuList, + Paper, + Popper, + Typography, +} from '@mui/material'; +import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; import { useSwitchAccount } from '../../hooks/useSwitchAccount'; import { useCurrentUser } from '../../utils/usersUtils'; -const useStyles = makeStyles(theme => ({ - accountSwitchButton: { - padding: theme.spacing(0), - }, -})); - type Props = { color?: 'inherit' | 'primary' | 'secondary'; }; @@ -20,52 +20,128 @@ export const AccountSwitch: FunctionComponent = ({ color = 'inherit', }) => { const currentUser = useCurrentUser(); - const classes = useStyles(); - const [anchorEl, setAnchorEl] = useState(null); - const handleClickListItem = event => { - setAnchorEl(event.currentTarget); - }; + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); const { mutateAsync: switchAccount } = useSwitchAccount(() => { - setAnchorEl(null); + setOpen(false); window.location.href = '/'; }); - const handleClose = () => { - setAnchorEl(null); + const handleToggle = () => { + setOpen(prevOpen => !prevOpen); + }; + + const handleClose = (event: Event | React.SyntheticEvent) => { + if ( + anchorRef.current && + anchorRef.current.contains(event.target as HTMLElement) + ) { + return; + } + setOpen(false); }; - if (currentUser.other_accounts.length > 0) { + function handleListKeyDown(event: React.KeyboardEvent) { + if (event.key === 'Tab') { + event.preventDefault(); + setOpen(false); + } else if (event.key === 'Escape') { + setOpen(false); + } + } + + // Return focus to the button when we transitioned from !open -> open + const prevOpen = useRef(open); + useEffect(() => { + if (prevOpen.current === true && open === false) { + anchorRef.current?.focus(); + } + prevOpen.current = open; + }, [open]); + const menuListKeyDownHandler = React.useCallback(handleListKeyDown, []); + + if (currentUser.other_accounts.length === 0) { return ( - <> - - {currentUser.account.name} - - - {currentUser.other_accounts.map(account => ( - switchAccount(account.id)} - > - {account.name} - - ))} - - + theme.spacing(0), + fontSize: 16, + }} + > + {currentUser.account.name} + ); } - return null; + + return ( + <> + theme.spacing(0), + cursor: 'pointer', + fontSize: 16, + '&:hover': { + color: theme => theme.palette.secondary.main, + }, + }} + aria-controls={open ? 'account-menu' : undefined} + aria-expanded={open ? 'true' : undefined} + aria-haspopup="true" + > + {currentUser.account.name} + + + {({ TransitionProps }) => ( + + + + + {currentUser.other_accounts.map(account => ( + + switchAccount(account.id) + } + > + {account.name} + + ))} + + + + + )} + + + ); }; diff --git a/hat/assets/js/apps/Iaso/components/nav/CurrentUser/index.tsx b/hat/assets/js/apps/Iaso/components/nav/CurrentUser/index.tsx index 413fe2c6a4..fd3d6a6b67 100644 --- a/hat/assets/js/apps/Iaso/components/nav/CurrentUser/index.tsx +++ b/hat/assets/js/apps/Iaso/components/nav/CurrentUser/index.tsx @@ -1,12 +1,12 @@ import React, { FunctionComponent, useState } from 'react'; -import { Popover, Typography } from '@mui/material'; +import { Box, Popover, Typography } from '@mui/material'; import { makeStyles } from '@mui/styles'; -import classnames from 'classnames'; import { useSafeIntl } from 'bluesquare-components'; +import MESSAGES from '../../../domains/app/components/messages'; import { getDefaultSourceVersion } from '../../../domains/dataSources/utils'; import { User } from '../../../utils/usersUtils'; -import MESSAGES from '../../../domains/app/components/messages'; +import { AccountSwitch } from '../AccountSwitch'; type Props = { currentUser: User; @@ -23,9 +23,11 @@ const useStyles = makeStyles(theme => ({ currentUserInfos: { display: 'block', textAlign: 'right', - }, - account: { - fontSize: 9, + cursor: 'pointer', + fontSize: 16, + '&:hover': { + color: theme.palette.secondary.main, + }, }, popOverInfos: { display: 'block', @@ -58,27 +60,17 @@ export const CurrentUserInfos: FunctionComponent = ({ return ( <> - - - {currentUser?.user_name} - - - - {currentUser?.account?.name} - - - + {currentUser?.user_name} + + - + ({ menuButton: { @@ -78,7 +77,7 @@ function TopBar(props) { container item direction="row" - xs={9} + xs={7} alignItems="center" > {!displayBackButton && displayMenuButton && ( @@ -112,7 +111,7 @@ function TopBar(props) { {currentUser && !isMobileLayout && ( - + - - - - diff --git a/hat/assets/js/apps/Iaso/domains/home/HomeOnline.tsx b/hat/assets/js/apps/Iaso/domains/home/HomeOnline.tsx index 26341aee8b..d285640cd0 100644 --- a/hat/assets/js/apps/Iaso/domains/home/HomeOnline.tsx +++ b/hat/assets/js/apps/Iaso/domains/home/HomeOnline.tsx @@ -13,7 +13,6 @@ import { useSidebar } from '../app/contexts/SideBarContext'; import { ThemeConfigContext } from '../app/contexts/ThemeConfigContext'; import { LangSwitch } from './components/LangSwitch'; import { useHomeButtons } from './hooks/useHomeButtons'; -import { AccountSwitch } from '../../components/nav/AccountSwitch'; const useStyles = makeStyles(theme => ({ root: { @@ -130,9 +129,13 @@ export const HomeOnline: FunctionComponent = () => { version={(window as any).IASO_VERSION} /> - - - + From 4d9fa527fb1c6b0f969c24f01ab61333e14c0946 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Fri, 20 Sep 2024 11:05:03 +0200 Subject: [PATCH 52/60] fix user creation and missing iaso_profile --- iaso/api/profiles/profiles.py | 60 +++++++++++++++++++---------------- iaso/models/tenant_users.py | 23 +++++++++++--- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index 398a8ea0bf..4d5c3321ac 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -675,40 +675,46 @@ def create(self, request): if not username: return JsonResponse({"errorKey": "user_name", "errorMessage": _("Nom d'utilisateur requis")}, status=400) + else: + existing_user = User.objects.filter(username__iexact=username) + if existing_user: + return JsonResponse( + {"errorKey": "user_name", "errorMessage": _("Nom d'utilisateur existant")}, status=400 + ) if not password and not send_email_invitation: return JsonResponse({"errorKey": "password", "errorMessage": _("Mot de passe requis")}, status=400) - + main_user = None try: existing_user = User.objects.get(username__iexact=username) - main_user = None - if existing_user: - user_in_same_account = ( - existing_user.iaso_profile and existing_user.iaso_profile.account == current_account + user_in_same_account = False + try: + if existing_user.iaso_profile and existing_user.iaso_profile.account == current_account: + user_in_same_account = True + except User.iaso_profile.RelatedObjectDoesNotExist: + # User doesn't have an iaso_profile, so they're not in the same account + pass + + if user_in_same_account: + return JsonResponse( + {"errorKey": "user_name", "errorMessage": _("Nom d'utilisateur existant")}, status=400 ) - if user_in_same_account: - return JsonResponse( - {"errorKey": "user_name", "errorMessage": _("Nom d'utilisateur existant")}, status=400 - ) - else: - # TODO: invitation - # TODO what if no iaso_profile? - # TODO what if already main user? - old_username = username - username = f"{username}_{current_account.name.lower().replace(' ', '_')}" - - # duplicate existing_user into main user and account user - main_user = copy.copy(existing_user) - - existing_user.username = ( - f"{old_username}_{existing_user.iaso_profile.account.name.lower().replace(' ', '_')}" - ) - existing_user.set_unusable_password() - existing_user.save() + else: + # TODO: invitation + # TODO what if already main user? + old_username = username + username = f"{username}_{current_account.name.lower().replace(' ', '_')}" + + # duplicate existing_user into main user and account user + main_user = copy.copy(existing_user) + + existing_user.username = f"{old_username}_{'unknown' if not hasattr(existing_user, 'iaso_profile') else existing_user.iaso_profile.account.name.lower().replace(' ', '_')}" + existing_user.set_unusable_password() + existing_user.save() - main_user.pk = None - main_user.save() + main_user.pk = None + main_user.save() - TenantUser.objects.create(main_user=main_user, account_user=existing_user) + TenantUser.objects.create(main_user=main_user, account_user=existing_user) except User.DoesNotExist: pass # no existing user, simply create a new user diff --git a/iaso/models/tenant_users.py b/iaso/models/tenant_users.py index c08ccd2b1b..a753d61fc9 100644 --- a/iaso/models/tenant_users.py +++ b/iaso/models/tenant_users.py @@ -19,20 +19,35 @@ class Meta: @property def account(self): - return self.account_user.iaso_profile and self.account_user.iaso_profile.account + try: + return self.account_user.iaso_profile.account if self.account_user.iaso_profile else None + except User.iaso_profile.RelatedObjectDoesNotExist: + return None def get_all_account_users(self): return [tu.account_user for tu in self.main_user.tenant_users.all()] def get_other_accounts(self): - return [tu.account for tu in self.main_user.tenant_users.exclude(pk=self.pk)] + return [tu.account for tu in self.main_user.tenant_users.exclude(pk=self.pk) if tu.account] def __str__(self): - return "%s -- %s (%s)" % (self.main_user, self.account_user, self.account_user.iaso_profile.account) + account_name = "Unknown" + try: + if self.account_user.iaso_profile: + account_name = self.account_user.iaso_profile.account + except User.iaso_profile.RelatedObjectDoesNotExist: + pass + return f"{self.main_user} -- {self.account_user} ({account_name})" def as_dict(self): + account_dict = None + try: + if self.account_user.iaso_profile: + account_dict = self.account_user.iaso_profile.account.as_dict() + except User.iaso_profile.RelatedObjectDoesNotExist: + pass return { "id": self.id, "main_user_id": self.main_user_id, - "account": self.account_user.iaso_profile.account.as_dict(), + "account": account_dict, } From 9c275a0a740bfe96b15f2899f4db1554f12574c0 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Fri, 20 Sep 2024 13:22:48 +0200 Subject: [PATCH 53/60] User has no tenant_user --- iaso/models/base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/iaso/models/base.py b/iaso/models/base.py index 925f30005d..c3f9bdcc6f 100644 --- a/iaso/models/base.py +++ b/iaso/models/base.py @@ -1522,6 +1522,10 @@ def as_short_dict(self): user_roles_editable_org_unit_type_ids = self.get_user_roles_editable_org_unit_type_ids() + user_infos = self.user + if hasattr(self.user, "tenant_user") and self.user.tenant_user: + user_infos = self.user.tenant_user.main_user + return { "id": self.id, "first_name": user_infos.first_name, From 04fcc1953225c8563ef2d619e44abd00c7ea1aa7 Mon Sep 17 00:00:00 2001 From: Beygorghor Date: Fri, 20 Sep 2024 14:09:15 +0200 Subject: [PATCH 54/60] tests fix --- iaso/api/accounts.py | 19 ++++++++++--------- iaso/tests/api/test_account.py | 10 +++++----- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/iaso/api/accounts.py b/iaso/api/accounts.py index 43df0fd2dc..562c07f493 100644 --- a/iaso/api/accounts.py +++ b/iaso/api/accounts.py @@ -1,17 +1,17 @@ """This api is only there so the default version on an account can be modified""" -from .common import ModelViewSet, HasPermission -from iaso.models import Account, SourceVersion - -from rest_framework import serializers, permissions, status +from django.contrib.auth import login +from django.contrib.auth.models import User +from rest_framework import permissions, serializers, status from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 from rest_framework.request import Request +from rest_framework.response import Response + from hat.menupermissions import models as permission +from iaso.models import Account, SourceVersion -from django.contrib.auth.models import User -from django.contrib.auth import login -from rest_framework.response import Response +from .common import HasPermission, ModelViewSet class AccountSerializer(serializers.ModelSerializer): @@ -59,8 +59,7 @@ class AccountViewSet(ModelViewSet): permission_classes = [ permissions.IsAuthenticated, - # TODO: How to remove this only for the switch? - # HasPermission(permission.SOURCES), # type: ignore + HasPermission(permission.SOURCES), # type: ignore HasAccountPermission, ] serializer_class = AccountSerializer @@ -72,6 +71,8 @@ class AccountViewSet(ModelViewSet): @action(detail=False, methods=["patch"], url_path="switch") def switch(self, request): # TODO: Make sure the account_id is present + self.permission_classes = [permissions.IsAuthenticated, HasAccountPermission] + self.check_permissions(request) account_id = request.data.get("account_id", None) current_user = request.user diff --git a/iaso/tests/api/test_account.py b/iaso/tests/api/test_account.py index 0d87ba6f25..5b6efecbfa 100644 --- a/iaso/tests/api/test_account.py +++ b/iaso/tests/api/test_account.py @@ -24,35 +24,35 @@ def setUpTestData(cls): cls.wha_version = m.SourceVersion.objects.create(data_source=wha_datasource, number=1) def test_account_list_without_auth(self): - """GET /projects/ without auth should result in a 403 (before the method not authorized?)""" + """GET /account/ without auth should result in a 403 (before the method not authorized?)""" self.client.force_authenticate(self.jim) response = self.client.get("/api/accounts/") self.assertJSONResponse(response, 403) def test_account_list_with_auth(self): - """GET /projects/ with auth should result in a 405 as method is not allowed""" + """GET /account/ with auth should result in a 405 as method is not allowed""" self.client.force_authenticate(self.jane) response = self.client.get("/api/accounts/") self.assertJSONResponse(response, 405) def test_account_delete_forbidden(self): - """DELETE /projects/ with auth should result in a 405 as method is not allowed""" + """DELETE /account/ with auth should result in a 405 as method is not allowed""" self.client.force_authenticate(self.jane) response = self.client.delete("/api/accounts/") self.assertJSONResponse(response, 405) def test_account_post_forbidden(self): - """POST /projects/ with auth should result in a 405 as method is not allowed""" + """POST /account/ with auth should result in a 405 as method is not allowed""" self.client.force_authenticate(self.jane) response = self.client.post("/api/accounts/", {"default_version": self.ghi_version}) self.assertJSONResponse(response, 405) def test_account_detail_forbidden(self): - """POST /projects/ with auth should result in a 405 as method is not allowed""" + """POST /account/ with auth should result in a 405 as method is not allowed""" self.client.force_authenticate(self.jane) response = self.client.get(f"/api/accounts/{self.ghi.pk}/") From 734aa43f9c45f2c52fe13f8bb4406136feec929f Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Wed, 9 Oct 2024 14:40:41 +0200 Subject: [PATCH 55/60] Fix migrations (merge) --- iaso/migrations/0304_merge_20241009_1240.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 iaso/migrations/0304_merge_20241009_1240.py diff --git a/iaso/migrations/0304_merge_20241009_1240.py b/iaso/migrations/0304_merge_20241009_1240.py new file mode 100644 index 0000000000..37fdd81209 --- /dev/null +++ b/iaso/migrations/0304_merge_20241009_1240.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.14 on 2024-10-09 12:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0300_merge_20240916_1441"), + ("iaso", "0303_merge_20241003_1256"), + ] + + operations = [] From 1bd0e64f4a86255a5761f5b72b0acfa78de01b32 Mon Sep 17 00:00:00 2001 From: Christophe Gerard Date: Mon, 21 Oct 2024 15:08:23 +0200 Subject: [PATCH 56/60] improve the admin --- iaso/admin.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 5 deletions(-) diff --git a/iaso/admin.py b/iaso/admin.py index 8459e64664..9348f35b39 100644 --- a/iaso/admin.py +++ b/iaso/admin.py @@ -1,15 +1,17 @@ from copy import copy from typing import Any, Protocol -import requests from django import forms as django_forms -from django.contrib.admin import RelatedOnlyFieldListFilter, widgets +from django.contrib import admin +from django.contrib.admin import widgets +from django.contrib.admin.widgets import AutocompleteSelect from django.contrib.gis import admin, forms from django.contrib.gis.db import models as geomodels from django.contrib.postgres.fields import ArrayField from django.db import models -from django.http import HttpResponseRedirect -from django.urls import reverse +from django.db.models import Count, Q +from django.http import HttpResponseRedirect, JsonResponse +from django.urls import path, reverse from django.utils.html import format_html, format_html_join from django.utils.safestring import mark_safe from django_json_widget.widgets import JSONEditorWidget @@ -988,6 +990,85 @@ class GroupSetAdmin(admin.ModelAdmin): autocomplete_fields = ["source_version"] +@admin.register(TenantUser) +class TenantUserAdmin(admin.ModelAdmin): + list_display = ( + "main_user", + "account_user", + "account", + "created_at", + "updated_at", + "all_accounts_count", + "is_self_account", + ) + list_filter = ("account_user__iaso_profile__account",) + search_fields = ("main_user__username", "account_user__username", "account_user__iaso_profile__account__name") + raw_id_fields = ("main_user", "account_user") + readonly_fields = ("created_at", "updated_at", "account", "all_account_users", "other_accounts") + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path("main-user-autocomplete/", self.main_user_autocomplete, name="main-user-autocomplete"), + ] + return custom_urls + urls + + def main_user_autocomplete(self, request): + term = request.GET.get("term", "") + queryset = ( + TenantUser.objects.filter(Q(main_user__username__icontains=term) | Q(main_user__email__icontains=term)) + .values_list("main_user__id", "main_user__username") + .distinct()[:10] + ) + results = [{"id": user_id, "text": username} for user_id, username in queryset] + return JsonResponse({"results": results}) + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == "main_user": + kwargs["widget"] = AutocompleteSelect( + db_field.remote_field, self.admin_site, attrs={"data-ajax--url": "main-user-autocomplete/"} + ) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def account(self, obj): + return obj.account + + account.admin_order_field = "account_user__iaso_profile__account" + account.short_description = "Account" + + def all_accounts_count(self, obj): + return obj.main_user.tenant_users.count() + + all_accounts_count.short_description = "Total Accounts" + + def is_self_account(self, obj): + return obj.main_user == obj.account_user + + is_self_account.boolean = True + is_self_account.short_description = "Self Account" + + def all_account_users(self, obj): + users = obj.get_all_account_users() + return format_html("
".join(user.username for user in users)) + + all_account_users.short_description = "All Account Users" + + def other_accounts(self, obj): + accounts = obj.get_other_accounts() + return format_html("
".join(str(account) for account in accounts)) + + other_accounts.short_description = "Other Accounts" + + def get_queryset(self, request): + return super().get_queryset(request).select_related("main_user", "account_user__iaso_profile__account") + + class Media: + js = ("admin/js/vendor/select2/select2.full.min.js", "admin/js/autocomplete.js") + css = { + "all": ("admin/css/vendor/select2/select2.min.css",), + } + + admin.site.register(AccountFeatureFlag) admin.site.register(Device) admin.site.register(DeviceOwnership) @@ -997,4 +1078,3 @@ class GroupSetAdmin(admin.ModelAdmin): admin.site.register(BulkCreateUserCsvFile) admin.site.register(Report) admin.site.register(ReportVersion) -admin.site.register(TenantUser) From c89a1598bafd541a43f3229cefd313cd41967659 Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Tue, 15 Oct 2024 12:04:48 +0200 Subject: [PATCH 57/60] IA-3516 Adapt the token API to multi-account users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a user request a token to api/token we should look if that user has a tenant_user for the account corresponding to the app_id in the url, and if it’s the case, send a token for that tenant_user on that account instead of the token for the login user. --- hat/settings.py | 6 +++++- iaso/api/profiles/profiles.py | 2 +- iaso/serializers.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 iaso/serializers.py diff --git a/hat/settings.py b/hat/settings.py index c892f60310..457a0b8a8c 100644 --- a/hat/settings.py +++ b/hat/settings.py @@ -414,7 +414,11 @@ def is_superuser(u): ), } -SIMPLE_JWT = {"ACCESS_TOKEN_LIFETIME": timedelta(days=3650), "REFRESH_TOKEN_LIFETIME": timedelta(days=3651)} +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(days=3650), + "REFRESH_TOKEN_LIFETIME": timedelta(days=3651), + "TOKEN_OBTAIN_SERIALIZER": "iaso.serializers.CustomTokenObtainPairSerializer", +} AWS_S3_REGION_NAME = os.getenv("AWS_S3_REGION_NAME", "eu-central-1") AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index 4d5c3321ac..6e284f1c61 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -357,7 +357,7 @@ def retrieve(self, request, *args, **kwargs): pk = kwargs.get("pk") if pk == PK_ME: # if the user is a main_user, login as an account user - # TODO: remember the last account_user + # TODO: This is not a clean side-effect and should be improved. if request.user.tenant_users.exists(): account_user = request.user.tenant_users.first().account_user account_user.backend = "django.contrib.auth.backends.ModelBackend" diff --git a/iaso/serializers.py b/iaso/serializers.py new file mode 100644 index 0000000000..bbfb9aab42 --- /dev/null +++ b/iaso/serializers.py @@ -0,0 +1,35 @@ +from django.contrib.auth.models import User + +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +from iaso.models import Project + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + def validate(self, attrs): + """ + Override this method to be able to take into account the app_id query param. + + If the user is a multi-account user, we return a token for the correct + "account user" (based on the `app_id`) instead of the main user that was + used for logging in. + """ + data = super().validate(attrs) + + if self.user.tenant_users.exists(): + request = self.context.get("request") + if request: + app_id = request.query_params.get("app_id", None) + project = Project.objects.get(app_id=app_id) + + account_user = User.objects.filter( + tenant_user__main_user=self.user, + iaso_profile__account=project.account, + ).first() + + refresh = self.get_token(account_user) + + data["refresh"] = str(refresh) + data["access"] = str(refresh.access_token) + + return data From f2a87be5b6b7796e331751285803073b3701c3a7 Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Tue, 15 Oct 2024 14:08:22 +0200 Subject: [PATCH 58/60] IA-3516 Better error-handling and tests --- iaso/serializers.py | 18 ++++++-- iaso/tests/api/test_token.py | 89 ++++++++++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/iaso/serializers.py b/iaso/serializers.py index bbfb9aab42..5a8a4ae9dc 100644 --- a/iaso/serializers.py +++ b/iaso/serializers.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import User from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from rest_framework_simplejwt.exceptions import AuthenticationFailed from iaso.models import Project @@ -8,25 +9,34 @@ class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): def validate(self, attrs): """ - Override this method to be able to take into account the app_id query param. + Override this method to be able to handle multi-account users. If the user is a multi-account user, we return a token for the correct - "account user" (based on the `app_id`) instead of the main user that was - used for logging in. + "account user" (based on the `app_id` query params) instead of the + main user that was used for logging in. """ data = super().validate(attrs) + err_msg = "No active account found with the given credentials" + if self.user.tenant_users.exists(): request = self.context.get("request") if request: app_id = request.query_params.get("app_id", None) - project = Project.objects.get(app_id=app_id) + + try: + project = Project.objects.get(app_id=app_id) + except Project.DoesNotExist: + raise AuthenticationFailed(err_msg) account_user = User.objects.filter( tenant_user__main_user=self.user, iaso_profile__account=project.account, ).first() + if account_user is None: + raise AuthenticationFailed(err_msg) + refresh = self.get_token(account_user) data["refresh"] = str(refresh) diff --git a/iaso/tests/api/test_token.py b/iaso/tests/api/test_token.py index c69dddff6a..b328f01190 100644 --- a/iaso/tests/api/test_token.py +++ b/iaso/tests/api/test_token.py @@ -1,7 +1,10 @@ +import jwt from unittest import mock +from django.contrib.auth.models import User from django.core.files import File +from hat.settings import SECRET_KEY from iaso import models as m from iaso.test import APITestCase @@ -41,8 +44,8 @@ class TokenAPITestCase(APITestCase): def setUpTestData(cls): data_source = m.DataSource.objects.create(name="counsil") version = m.SourceVersion.objects.create(data_source=data_source, number=1) - star_wars = m.Account.objects.create(name="Star Wars", default_version=version) - cls.yoda = cls.create_user_with_profile(username="yoda", account=star_wars) + cls.default_account = m.Account.objects.create(name="Star Wars", default_version=version) + cls.yoda = cls.create_user_with_profile(username="yoda", account=cls.default_account) cls.yoda.set_password("IMomLove") cls.yoda.save() @@ -54,7 +57,7 @@ def setUpTestData(cls): cls.project = m.Project.objects.create( name="Hydroponic gardens", app_id="stars.empire.agriculture.hydroponics", - account=star_wars, + account=cls.default_account, needs_authentication=True, ) @@ -86,6 +89,9 @@ def authenticate_using_token(self): response_data = response.json() access_token = response_data.get("access") + payload = jwt.decode(access_token, SECRET_KEY, algorithms=["HS256"]) + self.assertEquals(payload["user_id"], self.yoda.id) + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") return response_data @@ -103,6 +109,14 @@ def test_acquire_token_and_authenticate(self): self.assertTrue(self.form_2.id in form_ids) + def test_incorrect_username_or_password(self): + response = self.client.post(f"/api/token/", data={"username": "yoda", "password": "incorrect"}, format="json") + self.assertJSONResponse(response, 401) + self.assertEquals( + response.json()["detail"], + "No active account found with the given credentials", + ) + def test_acquire_token_and_post_instance(self): """Test upload to a project that requires authentication""" # Unauthenticated case is already tested in test_api @@ -272,3 +286,72 @@ def test_unauthenticated_post_org_unit(self): has_problems=True, exception_contains_string="Could not find project for user", ) + + def test_multi_account_user(self): + main_user = User.objects.create(username="main_user") + main_user.set_password("MainPass1") + main_user.save() + + account_a = self.default_account + m.Project.objects.create(app_id="account.a", account=account_a) + account_user_a = self.create_user_with_profile(username="User_A", account=account_a) + m.TenantUser.objects.create(main_user=main_user, account_user=account_user_a) + + data_source_b = m.DataSource.objects.create(name="Source B") + version_b = m.SourceVersion.objects.create(data_source=data_source_b, number=1) + account_b = m.Account.objects.create(name="Account B", default_version=version_b) + m.Project.objects.create(app_id="account.b", account=account_b) + account_user_b = self.create_user_with_profile(username="User_B", account=account_b) + m.TenantUser.objects.create(main_user=main_user, account_user=account_user_b) + + login = {"username": "main_user", "password": "MainPass1"} + + # Login with main user and app_id for Account A + response = self.client.post(f"/api/token/?app_id=account.a", data=login, format="json") + self.assertJSONResponse(response, 200) + access_token = response.json().get("access") + payload = jwt.decode(access_token, SECRET_KEY, algorithms=["HS256"]) + # returns token for account_user_a + self.assertEquals(payload["user_id"], account_user_a.id) + + # Login with main user and app_id for Account B + response = self.client.post(f"/api/token/?app_id=account.b", data=login, format="json") + self.assertJSONResponse(response, 200) + access_token = response.json().get("access") + payload = jwt.decode(access_token, SECRET_KEY, algorithms=["HS256"]) + # returns token for account_user_a + self.assertEquals(payload["user_id"], account_user_b.id) + + def test_multi_account_user_incorrect_app_id(self): + main_user = User.objects.create(username="main_user") + main_user.set_password("MainPass1") + main_user.save() + + account_a = self.default_account + m.Project.objects.create(app_id="account.a", account=account_a) + account_user_a = self.create_user_with_profile(username="User_A", account=account_a) + m.TenantUser.objects.create(main_user=main_user, account_user=account_user_a) + + # Create account B with project, but without link to main user + data_source_b = m.DataSource.objects.create(name="Source B") + version_b = m.SourceVersion.objects.create(data_source=data_source_b, number=1) + account_b = m.Account.objects.create(name="Account B", default_version=version_b) + m.Project.objects.create(app_id="account.b", account=account_b) + + login = {"username": "main_user", "password": "MainPass1"} + + # Login with main user and app_id for Account B + response = self.client.post(f"/api/token/?app_id=account.b", data=login, format="json") + self.assertJSONResponse(response, 401) + self.assertEquals( + response.json()["detail"], + "No active account found with the given credentials", + ) + + # Login with main user and non-existent app_id + response = self.client.post(f"/api/token/?app_id=account.c", data=login, format="json") + self.assertJSONResponse(response, 401) + self.assertEquals( + response.json()["detail"], + "No active account found with the given credentials", + ) From 646e846b6bef38c04adaab869d8f6fb2e61d9e6a Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Thu, 24 Oct 2024 13:47:03 +0200 Subject: [PATCH 59/60] Fix migrations (merge) --- iaso/migrations/0307_merge_20241024_1145.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 iaso/migrations/0307_merge_20241024_1145.py diff --git a/iaso/migrations/0307_merge_20241024_1145.py b/iaso/migrations/0307_merge_20241024_1145.py new file mode 100644 index 0000000000..a3cc0438d6 --- /dev/null +++ b/iaso/migrations/0307_merge_20241024_1145.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.14 on 2024-10-24 11:45 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("iaso", "0304_merge_20241009_1240"), + ("iaso", "0306_merge_20241024_0902"), + ] + + operations = [] From c8142a92494ed87118ec63843f5bf609d5b5be89 Mon Sep 17 00:00:00 2001 From: Bram Jans Date: Thu, 24 Oct 2024 14:01:13 +0200 Subject: [PATCH 60/60] Fix: CI + add prefetch_related for tenant_user --- iaso/api/profiles/profiles.py | 1 + iaso/tests/api/test_profiles.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/iaso/api/profiles/profiles.py b/iaso/api/profiles/profiles.py index 6e284f1c61..ab6004a88a 100644 --- a/iaso/api/profiles/profiles.py +++ b/iaso/api/profiles/profiles.py @@ -266,6 +266,7 @@ def list(self, request): queryset = queryset.prefetch_related( "user", "user_roles", + "user__tenant_user", "org_units", "org_units__version", "org_units__version__data_source", diff --git a/iaso/tests/api/test_profiles.py b/iaso/tests/api/test_profiles.py index 768068429f..841e07580b 100644 --- a/iaso/tests/api/test_profiles.py +++ b/iaso/tests/api/test_profiles.py @@ -296,7 +296,7 @@ def test_profile_list_read_only_permissions(self): """GET /profiles/ with auth (user has read only permissions)""" self.client.force_authenticate(self.jane) - with self.assertNumQueries(11): + with self.assertNumQueries(12): response = self.client.get("/api/profiles/") self.assertJSONResponse(response, 200) profile_url = "/api/profiles/%s/" % self.jane.iaso_profile.id