@@ -393,12 +386,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}
/>
({
root: {
...commonStyles(theme).mapContainer,
@@ -55,30 +42,10 @@ export const InstancesMap: FunctionComponent = ({
}) => {
const classes = useStyles();
const [isClusterActive, setIsClusterActive] = useState(true);
- 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 [currentTile, setCurrentTile] = useState(tiles.osm);
+ // This should be fixed, notification reducer as been removed a while ago
+ useShowWarning({ instances, notifications: [], fetching });
const bounds = useMemo(() => {
if (instances) {
@@ -89,7 +56,7 @@ export const InstancesMap: FunctionComponent = ({
if (fetching) return null;
return (
-
+
= ({
iconCreateFunction={clusterCustomMarker}
>
({
+ instanceId: instance.id,
+ })}
items={instances}
- onMarkerClick={fetchAndDispatchDetail}
PopupComponent={InstancePopup}
/>
@@ -129,7 +98,9 @@ export const InstancesMap: FunctionComponent = ({
{!isClusterActive && (
({
+ instanceId: instance.id,
+ })}
PopupComponent={InstancePopup}
/>
)}
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/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.js b/hat/assets/js/apps/Iaso/domains/instances/hooks.js
index 8972cb0764..9b866346e3 100644
--- a/hat/assets/js/apps/Iaso/domains/instances/hooks.js
+++ b/hat/assets/js/apps/Iaso/domains/instances/hooks.js
@@ -1,23 +1,7 @@
import { getRequest, patchRequest, postRequest } from 'Iaso/libs/Api.ts';
import { useSnackMutation, useSnackQuery } from 'Iaso/libs/apiHooks.ts';
-import { fetchOrgUnitsTypes } from '../../utils/requests';
-import { setOrgUnitTypes } from '../orgUnits/actions';
import MESSAGES from './messages';
-import { useFetchOnMount } from '../../hooks/fetchOnMount';
-
-export const useInstancesFiltersData = (formId, setFetchingOrgUnitTypes) => {
- 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/instances/hooks/speedDialActions.tsx b/hat/assets/js/apps/Iaso/domains/instances/hooks/speedDialActions.tsx
index 18e1476e90..5f123798ab 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 [
{
@@ -44,13 +43,9 @@ export const useBaseActions = (
icon: (
(
+ renderTrigger={openDialog => (
)}
@@ -71,13 +66,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/instances/index.js b/hat/assets/js/apps/Iaso/domains/instances/index.js
index 49e8bb613d..041d04e9e4 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,8 @@ const Instances = () => {
const params = useParamsObject(baseUrl);
const classes = useStyles();
const { formatMessage } = useSafeIntl();
- const dispatch = useDispatch();
const queryClient = useQueryClient();
const redirectToReplace = useRedirectToReplace();
-
const [selection, setSelection] = useState(selectionInitialState);
const [tableColumns, setTableColumns] = useState([]);
const [tab, setTab] = useState(params.tab ?? 'list');
@@ -199,12 +196,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 642bd3e243..0000000000
--- a/hat/assets/js/apps/Iaso/domains/instances/reducer.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import {
- SET_INSTANCES_FETCHING,
- SET_CURRENT_INSTANCE,
- SET_INSTANCES_FILTER_UDPATED,
-} from './actions';
-
-export const instancesInitialState = {
- fetching: true,
- current: null,
- isInstancesFilterUpdated: false,
-};
-
-export const instancesReducer = (
- state = instancesInitialState,
- 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 };
- }
-
- default:
- return state;
- }
-};
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 6ee64091ed..0000000000
--- a/hat/assets/js/apps/Iaso/domains/orgUnits/actions.js
+++ /dev/null
@@ -1,18 +0,0 @@
-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';
-
-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 cd619a04f2..11adce49f4 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,30 @@
-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 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,26 +62,26 @@ const OrgUnitPopupComponent = ({
displayUseLocation,
replaceLocation,
titleMessage,
- currentOrgUnit,
+ orgUnitId,
}) => {
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 ? 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 && (
<>
>
)}
@@ -167,7 +168,7 @@ const OrgUnitPopupComponent = ({
)}
{},
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..d2dcda0f8a 100644
--- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsMap.tsx
+++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/OrgUnitsMap.tsx
@@ -15,17 +15,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 +27,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';
@@ -50,6 +38,7 @@ import { InnerDrawer } from '../../../components/nav/InnerDrawer/Index';
import tiles from '../../../constants/mapTiles';
import { useGetOrgUnitTypes } from '../hooks/requests/useGetOrgUnitTypes';
import MESSAGES from '../messages';
+import { OrgUnitsMapComments } from './orgUnitMap/OrgUnitsMapComments';
type OrgUnitWithSearchIndex = Omit & {
search_index: number;
@@ -64,21 +53,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 => {
@@ -88,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 =
@@ -111,15 +93,14 @@ export const OrgUnitsMap: FunctionComponent = ({
getSearchColor,
orgUnits,
}) => {
- const classes: Record = useStyles();
+ const classes = 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 [selectedOrgUnit, setSelectedOrgUnit] = useState<
+ OrgUnit | undefined
+ >();
const { formatMessage }: { formatMessage: IntlFormatMessage } =
useSafeIntl();
@@ -151,9 +132,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 => ({
@@ -161,6 +141,7 @@ export const OrgUnitsMap: FunctionComponent = ({
})}
TooltipComponent={Tooltip}
isCircle
+ onMarkerClick={o => setSelectedOrgUnit(o)}
/>
@@ -181,9 +162,8 @@ export const OrgUnitsMap: FunctionComponent = ({
),
})}
items={orgUnitsBySearch}
- onMarkerClick={o => setCurrentOrgUnitId(o.id)}
- popupProps={() => ({
- currentOrgUnit,
+ popupProps={instance => ({
+ orgUnitId: instance.id,
})}
PopupComponent={OrgUnitPopupComponent}
tooltipProps={e => ({
@@ -191,10 +171,11 @@ export const OrgUnitsMap: FunctionComponent = ({
})}
TooltipComponent={Tooltip}
isCircle
+ onMarkerClick={o => setSelectedOrgUnit(o)}
/>
));
- }, [currentOrgUnit, getSearchColor, isClusterActive, orgUnits.locations]);
+ }, [getSearchColor, isClusterActive, orgUnits.locations]);
if (!bounds && orgUnitsTotal.length > 0) {
return (
@@ -222,7 +203,7 @@ export const OrgUnitsMap: FunctionComponent = ({
}
>
@@ -266,13 +247,10 @@ export const OrgUnitsMap: FunctionComponent = ({
),
}}
data={o.geo_json}
- eventHandlers={{
- click: () =>
- setCurrentOrgUnitId(o.id),
- }}
+ onClick={() => setSelectedOrgUnit(o)}
>
{o.name}
@@ -305,13 +283,12 @@ export const OrgUnitsMap: FunctionComponent = ({
),
}}
data={o.geo_json}
- eventHandlers={{
- click: () =>
- setCurrentOrgUnitId(o.id),
- }}
+ onClick={() =>
+ setSelectedOrgUnit(o)
+ }
>
{o.name}
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..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
@@ -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,12 +32,12 @@ 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}
+ 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 3299ce6048..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
@@ -9,28 +9,28 @@ type Props = {
locationsList: any[];
color?: string;
keyId: string | number;
- fetchDetail: (orgUnit: OrgUnit) => void;
updateOrgUnitLocation: (orgUnit: OrgUnit) => void;
+ popupProps?: (orgUnit: OrgUnit) => Record;
};
export const MarkerList: FunctionComponent = ({
locationsList,
- fetchDetail,
color = '#000000',
keyId,
updateOrgUnitLocation,
PopupComponent = OrgUnitPopupComponent,
+ popupProps,
}) => {
return (
({
+ popupProps={orgUnit => ({
displayUseLocation: true,
replaceLocation: selectedOrgUnit =>
updateOrgUnitLocation(selectedOrgUnit),
+ ...popupProps?.(orgUnit),
})}
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 3f40087627..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 { fetchInstanceDetail, fetchSubOrgUnitDetail } = useRedux();
const setAncestor = useCallback(() => {
const ancestor = getAncestorWithGeojson(currentOrgUnit);
if (ancestor) {
@@ -540,8 +538,8 @@ export const OrgUnitMap: FunctionComponent = ({
titleMessage={formatMessage(
MESSAGES.ouParent,
)}
- currentOrgUnit={
- state.ancestorWithGeoJson.value
+ orgUnitId={
+ state.ancestorWithGeoJson.value.id
}
/>
@@ -553,7 +551,6 @@ export const OrgUnitMap: FunctionComponent = ({
= ({
mappedOrgUnitTypesSelected
}
mappedSourcesSelected={mappedSourcesSelected}
- fetchSubOrgUnitDetail={fetchSubOrgUnitDetail}
updateOrgUnitLocation={updateOrgUnitLocation}
/>
>
@@ -570,18 +566,15 @@ 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 92f96d12e0..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,
})}
@@ -62,6 +56,7 @@ export const OrgUnitTypesSelectedShapes: FunctionComponent = ({
MESSAGES.ouChild,
)}
displayUseLocation
+ orgUnitId={o.id}
replaceLocation={selectedOrgUnit =>
updateOrgUnitLocation(
selectedOrgUnit,
@@ -78,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..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
@@ -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 => ({
+ orgUnitId: 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 e5500d995e..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,14 +22,12 @@ export const SourceShape: FunctionComponent = ({
color: source.color,
}}
data={shape.geo_json}
- eventHandlers={{
- click: onClick,
- }}
>
);
diff --git a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourcesSelectedShapes.tsx b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourcesSelectedShapes.tsx
index dc8696f07b..4fdbf27c78 100644
--- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourcesSelectedShapes.tsx
+++ b/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/SourcesSelectedShapes.tsx
@@ -7,13 +7,11 @@ import { MappedOrgUnit } from './types';
type Props = {
mappedSourcesSelected: MappedOrgUnit[];
updateOrgUnitLocation: (orgUnit: OrgUnit) => 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 25366f5de5..0000000000
--- a/hat/assets/js/apps/Iaso/domains/orgUnits/components/orgUnitMap/OrgUnitMap/useRedux.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-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 { setCurrentSubOrgUnit as setCurrentSubOrgUnitAction } from '../../../actions';
-
-export const useRedux = () => {
- const dispatch = useDispatch();
- const setCurrentSubOrgUnit = useCallback(
- o => dispatch(setCurrentSubOrgUnitAction(o)),
- [dispatch],
- );
-
- const setCurrentInstance = useCallback(
- i => dispatch(setCurrentInstanceAction(i)),
- [dispatch],
- );
-
- const fetchSubOrgUnitDetail = useCallback(
- orgUnit => {
- setCurrentSubOrgUnit(null);
- fetchOrgUnitDetail(orgUnit.id).then(subOrgUnit =>
- setCurrentSubOrgUnit(subOrgUnit),
- );
- },
- [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/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 && (
{
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();
@@ -247,12 +244,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 +266,6 @@ const OrgUnitDetail = () => {
.then(ou => {
setCurrentOrgUnit(ou);
setOrgUnitLocationModified(false);
- dispatch(resetOrgUnits());
if (isNewOrgunit) {
redirectToReplace(baseUrl, {
...params,
@@ -284,7 +279,6 @@ const OrgUnitDetail = () => {
},
[
currentOrgUnit,
- dispatch,
isNewOrgunit,
params,
redirectToReplace,
@@ -327,7 +321,7 @@ const OrgUnitDetail = () => {
}
}
}
- }, [originalOrgUnit, dispatch, isNewOrgunit, params, redirectToReplace]);
+ }, [originalOrgUnit, isNewOrgunit, params, redirectToReplace]);
// Set selected sources for current org unit
useEffect(() => {
@@ -359,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/domains/orgUnits/reducer.js b/hat/assets/js/apps/Iaso/domains/orgUnits/reducer.js
deleted file mode 100644
index 6b5ae3c368..0000000000
--- a/hat/assets/js/apps/Iaso/domains/orgUnits/reducer.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import {
- RESET_ORG_UNITS,
- SET_ORG_UNIT_TYPES,
- SET_SOURCES,
- SET_SUB_ORG_UNIT,
-} from './actions';
-
-export const orgUnitsInitialState = {
- currentSubOrgUnit: null,
- orgUnitTypes: [],
- sources: null,
- orgUnitLevel: [],
- filtersUpdated: false,
- groups: [],
-};
-
-export const orgUnitsReducer = (state = orgUnitsInitialState, action = {}) => {
- 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 };
- }
- 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/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/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/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
new file mode 100644
index 0000000000..2795d7f6b9
--- /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 { SaveUserRoleQuery } from '../hooks/requests/useSaveUserRole';
+import MESSAGES from '../messages';
+
+type Props = {
+ userRole: Partial;
+ 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/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={{}}
/>
;
};
@@ -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/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/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/domains/users/components/ProtectedRoute.spec.js b/hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js
deleted file mode 100644
index 0cd80caf04..0000000000
--- a/hat/assets/js/apps/Iaso/domains/users/components/ProtectedRoute.spec.js
+++ /dev/null
@@ -1,145 +0,0 @@
-import { expect } from 'chai';
-import React from 'react';
-import nock from 'nock';
-import { ErrorBoundary, theme } from 'bluesquare-components';
-import { ThemeProvider } from '@mui/material';
-import { shallow } from 'enzyme';
-import {
- renderWithMutableStore,
- mockedStore,
-} 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';
-
-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 redirectSpy = sinon.spy(redirectToAction);
-
-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();
- redirectSpy.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/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))
}
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/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/hooks/useSwitchAccount.tsx b/hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx
new file mode 100644
index 0000000000..783538fd5a
--- /dev/null
+++ b/hat/assets/js/apps/Iaso/hooks/useSwitchAccount.tsx
@@ -0,0 +1,13 @@
+import { UseMutationResult } from 'react-query';
+import { patchRequest } from '../libs/Api';
+import { useSnackMutation } from '../libs/apiHooks';
+
+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/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 b794436243..0000000000
--- a/hat/assets/js/apps/Iaso/redux/store.js
+++ /dev/null
@@ -1,34 +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';
-import {
- orgUnitsInitialState,
- orgUnitsReducer,
-} from '../domains/orgUnits/reducer';
-
-const store = createStore(
- {
- orgUnits: orgUnitsInitialState,
- instances: instancesInitialState,
- },
- {
- orgUnits: orgUnitsReducer,
- 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/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/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/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;
+};
diff --git a/hat/assets/js/apps/Iaso/utils/requests.js b/hat/assets/js/apps/Iaso/utils/requests.js
index cec8337eef..9c5ccd8ddb 100644
--- a/hat/assets/js/apps/Iaso/utils/requests.js
+++ b/hat/assets/js/apps/Iaso/utils/requests.js
@@ -43,16 +43,8 @@ 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}`)
+ getRequest(`/api/instances/${instanceId}/`)
.then(instance => instance)
.catch(error => {
openSnackBar(errorSnackBar('fetchInstanceError', null, error));
diff --git a/hat/assets/js/apps/Iaso/utils/usersUtils.ts b/hat/assets/js/apps/Iaso/utils/usersUtils.ts
index 2801437744..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;
@@ -79,6 +82,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,14 +122,17 @@ 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))
);
};
};
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 79a90c3fba..0000000000
--- a/hat/assets/js/test/utils/redux.js
+++ /dev/null
@@ -1,33 +0,0 @@
-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';
-
-import { renderWithMuiTheme } from './muiTheme';
-
-const middlewares = [thunk];
-const mockStore = configureStore(middlewares);
-
-const getMockedStore = storeObject => mockStore(storeObject);
-
-const initialState = {
- orgUnits: orgUnitsInitialState,
- instances: instancesInitialState,
-};
-
-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/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/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/iaso/admin.py b/iaso/admin.py
index 90ee14c2ac..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
@@ -71,6 +73,7 @@
StorageLogEntry,
StoragePassword,
Task,
+ TenantUser,
UserRole,
Workflow,
WorkflowChange,
@@ -987,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)
diff --git a/iaso/api/accounts.py b/iaso/api/accounts.py
index 0d20a90041..562c07f493 100644
--- a/iaso/api/accounts.py
+++ b/iaso/api/accounts.py
@@ -1,11 +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
+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 .common import HasPermission, ModelViewSet
class AccountSerializer(serializers.ModelSerializer):
@@ -60,4 +66,24 @@ 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):
+ # 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
+ 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/org_unit_change_request_configurations/views_mobile.py b/iaso/api/org_unit_change_request_configurations/views_mobile.py
index a20e4feb28..59a36f1e2a 100644
--- a/iaso/api/org_unit_change_request_configurations/views_mobile.py
+++ b/iaso/api/org_unit_change_request_configurations/views_mobile.py
@@ -1,8 +1,9 @@
+from itertools import chain
+
import django_filters
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
@@ -17,12 +18,12 @@
)
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):
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
@@ -42,9 +43,62 @@ def get_queryset(self):
.prefetch_related(
"possible_types", "possible_parent_types", "group_sets", "editable_reference_forms", "other_groups"
)
- .order_by("id")
+ .order_by("org_unit_type_id")
)
@swagger_auto_schema(manual_parameters=[app_id_param])
def list(self, request: Request, *args, **kwargs) -> Response:
- return super().list(request, *args, **kwargs)
+ """
+ Because some Org Unit Type restrictions are also configurable at the `Profile` level,
+ we implement the following logic in the list view:
+
+ 1. If `Profile.editable_org_unit_types` empty:
+
+ - return `OrgUnitChangeRequestConfiguration` content
+
+ 2. If `Profile.editable_org_unit_types` not empty:
+
+ 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`
+
+ 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)
+ queryset = self.get_queryset()
+
+ 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.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 = [
+ 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 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)
+
+ queryset = self.filter_queryset(queryset)
+
+ page = self.paginate_queryset(queryset)
+ if page is not None:
+ serializer = self.get_serializer(page, many=True)
+ return self.get_paginated_response(serializer.data)
+
+ serializer = self.get_serializer(queryset, many=True)
+ return Response(serializer.data)
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/api/profiles/profiles.py b/iaso/api/profiles/profiles.py
index 4588277d53..ab6004a88a 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
@@ -220,7 +221,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).with_editable_org_unit_types()
def list(self, request):
limit = request.GET.get("limit", None)
@@ -260,11 +261,12 @@ def list(self, request):
teams=teams,
managed_users_only=managed_users_only,
ids=ids,
- )
+ ).order_by("id")
queryset = queryset.prefetch_related(
"user",
"user_roles",
+ "user__tenant_user",
"org_units",
"org_units__version",
"org_units__version__data_source",
@@ -275,6 +277,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)
@@ -354,11 +357,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: 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"
+ 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,
@@ -500,13 +510,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_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.")
+ return editable_org_unit_types
@staticmethod
def update_user_own_profile(request):
@@ -659,17 +667,57 @@ 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")
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)
- existing_user = User.objects.filter(username__iexact=username)
- if existing_user:
- return JsonResponse({"errorKey": "user_name", "errorMessage": _("Nom d'utilisateur existant")}, status=400)
+ main_user = None
+ try:
+ existing_user = User.objects.get(username__iexact=username)
+ 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
+ )
+ 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()
+
+ 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", "")
@@ -678,14 +726,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 main_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)
@@ -695,7 +744,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,
@@ -715,7 +763,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/api/user_roles.py b/iaso/api/user_roles.py
index 039605fdb5..181009df5a 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 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):
@@ -26,42 +26,38 @@ class Meta:
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", "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"] = self.remove_prefix_from_str(user_role["name"], account_id + "_")
+ user_role["name"] = user_role["name"].removeprefix(f"{account_id}_")
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 +69,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,8 +93,19 @@ 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
+ 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:
+ 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 for this account."
+ )
+ return editable_org_unit_types
+
class UserRolesViewSet(ModelViewSet):
f"""Roles API
@@ -120,7 +126,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/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/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 = []
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),
+ ]
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 = []
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/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 = []
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 = []
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 668cfd90a3..c3f9bdcc6f 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
@@ -1410,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")
@@ -1429,12 +1439,24 @@ 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")]
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
+ except AttributeError:
+ return list(
+ self.user_roles.values_list("editable_org_unit_types__id", flat=True)
+ .distinct("id")
+ .exclude(editable_org_unit_types__id__isnull=True)
+ )
+
def as_dict(self, small=False):
user_roles = self.user_roles.all()
user_group_permissions = list(
@@ -1445,66 +1467,77 @@ def as_dict(self, small=False):
)
all_permissions = user_group_permissions + user_permissions
permissions = list(set(all_permissions))
- 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": list(self.editable_org_unit_types.values_list("id", flat=True)),
+ 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()]
+
+ user_roles_editable_org_unit_type_ids = self.get_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": list(self.editable_org_unit_types.values_list("id", flat=True)),
+ 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):
+ 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()]
+
+ 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": 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,
"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": editable_org_unit_type_ids,
+ "user_roles_editable_org_unit_type_ids": user_roles_editable_org_unit_type_ids,
}
def has_a_team(self):
@@ -1513,6 +1546,19 @@ 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: set[int] = None
+ ) -> bool:
+ 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
+
class ExportRequest(models.Model):
id = models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")
@@ -1669,6 +1715,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/models/tenant_users.py b/iaso/models/tenant_users.py
new file mode 100644
index 0000000000..a753d61fc9
--- /dev/null
+++ b/iaso/models/tenant_users.py
@@ -0,0 +1,53 @@
+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):
+ 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) if tu.account]
+
+ def __str__(self):
+ 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": account_dict,
+ }
diff --git a/iaso/serializers.py b/iaso/serializers.py
new file mode 100644
index 0000000000..5a8a4ae9dc
--- /dev/null
+++ b/iaso/serializers.py
@@ -0,0 +1,45 @@
+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
+
+
+class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
+ def validate(self, attrs):
+ """
+ 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` 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)
+
+ 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)
+ data["access"] = str(refresh.access_token)
+
+ return data
diff --git a/iaso/tasks/org_units_bulk_update.py b/iaso/tasks/org_units_bulk_update.py
index 781fd42c1a..6598e21b0d 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 = 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
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/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..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
@@ -1,5 +1,8 @@
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
@@ -12,19 +15,143 @@ class MobileOrgUnitChangeRequestConfigurationAPITestCase(OUCRCAPIBase):
def test_list_ok(self):
self.client.force_authenticate(self.user_ash_ketchum)
- with self.assertNumQueries(7):
+ with self.assertNumQueries(9):
# 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. 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
+ 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)
+
+ # 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]
+ )
+
+ # 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):
+ 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))
+
+ # `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)
+
+ 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,
+ "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(),
+ },
+ # 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,
+ "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,
+ },
+ # 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,
+ "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,
+ },
+ # 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,
+ "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,
+ },
+ # 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,
+ "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):
response = self.client.get(f"{self.MOBILE_OUCRC_API_URL}?app_id={self.app_id}")
self.assertJSONResponse(response, status.HTTP_401_UNAUTHORIZED)
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}/")
diff --git a/iaso/tests/api/test_org_units_bulk_update.py b/iaso/tests/api/test_org_units_bulk_update.py
index ee24bedafc..e5f193f5ed 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,47 @@ 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_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.
+ """
+ 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_REJECTED},
+ 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")
+
+ 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()
+
+ 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)"""
diff --git a/iaso/tests/api/test_orgunits.py b/iaso/tests/api/test_orgunits.py
index 966b6cd26d..c8458f6a34 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_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.
+ """
+ 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_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.
+ """
+ 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
diff --git a/iaso/tests/api/test_profiles.py b/iaso/tests/api/test_profiles.py
index 2c6f86333f..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(17):
+ with self.assertNumQueries(12):
response = self.client.get("/api/profiles/")
self.assertJSONResponse(response, 200)
profile_url = "/api/profiles/%s/" % self.jane.iaso_profile.id
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",
+ )
diff --git a/iaso/tests/api/test_user_roles.py b/iaso/tests/api/test_user_roles.py
index 9c2be70b30..804c5635c9 100644
--- a/iaso/tests/api/test_user_roles.py
+++ b/iaso/tests/api/test_user_roles.py
@@ -6,16 +6,21 @@
class UserRoleAPITestCase(APITestCase):
@classmethod
def setUpTestData(cls):
- star_wars = m.Account.objects.create(name="Star Wars")
- cls.star_wars = star_wars
- 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)
- 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.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 +37,27 @@ 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_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_type_ids"], [self.org_unit_type.id])
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 +65,28 @@ 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_type_ids"], [self.org_unit_type.id])
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_type_ids"], [self.org_unit_type.id])
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 +94,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 +102,78 @@ 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_type_ids"],
+ [self.org_unit_type.id],
+ )
+
+ 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_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_type_ids"],
+ [
+ f"`{invalid_org_unit_type.name} ({invalid_org_unit_type.pk})` is not a valid Org Unit Type for this account."
+ ],
)
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.project.unit_types.add(new_org_unit_type.pk)
+
+ self.client.force_authenticate(self.user)
+ payload = {
+ "name": self.user_role.group.name,
+ "editable_org_unit_type_ids": [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_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)
- 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 +182,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 +197,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])),
)
diff --git a/iaso/tests/models/test_profile.py b/iaso/tests/models/test_profile.py
index 626c6b2bc1..c0aee1dff7 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
@@ -18,3 +20,76 @@ 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")
+ 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(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(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(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()
+
+ 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]
+ )
diff --git a/package-lock.json b/package-lock.json
index 05996f651b..d44e9d0429 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",
@@ -116,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",
@@ -4427,17 +4422,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",
@@ -12993,12 +12977,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",
@@ -19109,35 +19087,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",
@@ -19168,11 +19117,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",
@@ -19354,23 +19298,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/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..88268fd208 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",
@@ -155,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",
@@ -166,4 +161,4 @@
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.15.1"
}
-}
\ No newline at end of file
+}
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';
diff --git a/plugins/test/js/index.js b/plugins/test/js/index.js
index 9a6b3d2623..e0335bb900 100644
--- a/plugins/test/js/index.js
+++ b/plugins/test/js/index.js
@@ -1,16 +1,14 @@
-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 { redirectTo } from 'Iaso/routing/actions';
import { getRequest } from 'Iaso/libs/Api';
import tableColumns from './columns';
import MESSAGES from './messages';
@@ -22,7 +20,6 @@ const useStyles = makeStyles(theme => ({
}));
const TestApp = () => {
- const dispatch = useDispatch();
const classes = useStyles();
const intl = useSafeIntl();
const [fetching, setFetching] = useState(true);
@@ -56,7 +53,7 @@ const TestApp = () => {
baseUrl={baseUrl}
params={{}}
redirectTo={(key, params) =>
- dispatch(redirectTo(key, params))
+ console.log('redirectTo', key, params)
}
/>