diff --git a/admin/src/js/controllers/edit-user.js b/admin/src/js/controllers/edit-user.js
index 7e84ab66f57..01ec48af231 100644
--- a/admin/src/js/controllers/edit-user.js
+++ b/admin/src/js/controllers/edit-user.js
@@ -1,6 +1,8 @@
const moment = require('moment');
const passwordTester = require('simple-password-tester');
const phoneNumber = require('@medic/phone-number');
+const cht = require('@medic/cht-datasource');
+const chtDatasource = cht.getDatasource(cht.getRemoteDataContext());
const PASSWORD_MINIMUM_LENGTH = 8;
const PASSWORD_MINIMUM_SCORE = 50;
const USERNAME_ALLOWED_CHARS = /^[a-z0-9_-]+$/;
@@ -66,6 +68,7 @@ angular
// If $scope.model === {}, we're creating a new user.
return Settings()
.then(settings => {
+ $scope.permissions = settings.permissions;
$scope.roles = settings.roles;
$scope.allowTokenLogin = allowTokenLogin(settings);
if (!$scope.model) {
@@ -89,7 +92,7 @@ angular
phone: $scope.model.phone,
// FacilitySelect is what binds to the select, place is there to
// compare to later to see if it's changed once we've run computeFields();
- facilitySelect: $scope.model.facility_id,
+ facilitySelect: $scope.model.facility_id || [],
place: $scope.model.facility_id,
roles: getRoles($scope.model.roles),
// ^ Same with contactSelect vs. contact
@@ -100,6 +103,23 @@ angular
});
};
+ const fetchDocsByIds = (ids) => {
+ return DB()
+ .allDocs({ keys: ids, include_docs: true });
+ };
+
+ const usersPlaces = (ids) => {
+ return fetchDocsByIds(ids)
+ .then((docs) => processDocs(docs))
+ .then((filteredDocs) => filteredDocs.map((doc) => doc._id));
+ };
+
+ const processDocs = function (result) {
+ return result.rows
+ .filter((row) => row.doc && !row.value.deleted)
+ .map((row) => row.doc);
+ };
+
this.setupPromise = determineEditUserModel()
.then(model => {
$scope.editUserModel = model;
@@ -115,15 +135,16 @@ angular
const personTypes = contactTypes.filter(type => type.person).map(type => type.id);
Select2Search($('#edit-user-profile [name=contactSelect]'), personTypes);
const placeTypes = contactTypes.filter(type => !type.person).map(type => type.id);
- Select2Search($('#edit-user-profile [name=facilitySelect]'), placeTypes);
+ return usersPlaces($scope.editUserModel.facilitySelect).then(facilityIds => {
+ Select2Search($('#edit-user-profile [name=facilitySelect]'), placeTypes, { initialValue: facilityIds });
+ });
});
const validateRequired = (fieldName, fieldDisplayName) => {
if (!$scope.editUserModel[fieldName]) {
- Translate.fieldIsRequired(fieldDisplayName)
- .then(function(value) {
- $scope.errors[fieldName] = value;
- });
+ Translate.fieldIsRequired(fieldDisplayName).then(function (value) {
+ $scope.errors[fieldName] = value;
+ });
return false;
}
return true;
@@ -232,6 +253,24 @@ angular
return true;
};
+ const validatePlacesPermission = () => {
+ if (!$scope.editUserModel.place || $scope.editUserModel.place.length <= 1) {
+ return true;
+ }
+
+ const userHasPermission = chtDatasource.v1.hasPermissions(
+ ['can_have_multiple_places'], $scope.editUserModel.roles, $scope.permissions
+ );
+
+ if (!userHasPermission) {
+ $translate('permission.description.can_have_multiple_places.not_allowed').then(value => {
+ $scope.errors.multiFacility = value;
+ });
+ }
+ return userHasPermission;
+ };
+
+
const isOnlineUser = (roles) => {
if (!$scope.roles) {
return true;
@@ -254,24 +293,55 @@ angular
return hasPlace && hasContact;
};
+ const validateFacilityHierarchy = () => {
+ const placeIds = $scope.editUserModel.place;
+
+ if (!placeIds || placeIds.length === 1) {
+ return $q.resolve(true);
+ }
+
+ return fetchDocsByIds(placeIds)
+ .then(result => {
+ const places = result.rows.map(row => row.doc);
+ const isSameHierarchy = ContactTypes.isSameContactType(places);
+
+ if (!isSameHierarchy) {
+ $translate('permission.description.can_have_multiple_places.incompatible_place').then(value => {
+ $scope.errors.multiFacility = value;
+ });
+ }
+ return isSameHierarchy;
+ })
+ .catch(err => {
+ $log.error('Error validating facility hierarchy', err);
+ return false;
+ });
+ };
+
const validateContactIsInPlace = () => {
- const placeId = $scope.editUserModel.place;
+ const placeIds = $scope.editUserModel.place;
const contactId = $scope.editUserModel.contact;
- if (!placeId || !contactId) {
+ if (!placeIds || !contactId) {
return $q.resolve(true);
}
- return DB()
- .get(contactId)
- .then(function(contact) {
- let parent = contact.parent;
- let valid = false;
- while (parent) {
- if (parent._id === placeId) {
- valid = true;
- break;
- }
- parent = parent.parent;
- }
+
+ const getParent = (contactId) => {
+ return DB().get(contactId).then(contact => contact.parent);
+ };
+
+ const checkParent = (parent, placeIds) => {
+ if (!parent) {
+ return false;
+ }
+ if (placeIds.includes(parent._id)) {
+ return true;
+ }
+ return checkParent(parent.parent, placeIds);
+ };
+
+ return getParent(contactId)
+ .then(function (parent) {
+ const valid = checkParent(parent, placeIds);
if (!valid) {
$translate('configuration.user.place.contact').then(value => {
$scope.errors.contact = value;
@@ -371,9 +441,8 @@ angular
};
const computeFields = () => {
- $scope.editUserModel.place = $(
- '#edit-user-profile [name=facilitySelect]'
- ).val();
+ const placeValue = $('#edit-user-profile [name=facilitySelect]').val();
+ $scope.editUserModel.place = Array.isArray(placeValue) && placeValue.length === 0 ? null : placeValue;
$scope.editUserModel.contact = $(
'#edit-user-profile [name=contactSelect]'
).val();
@@ -449,7 +518,8 @@ angular
validateRole() &&
validateContactAndFacility() &&
validatePasswordForEditUser() &&
- validateEmailAddress();
+ validateEmailAddress() &&
+ validatePlacesPermission();
if (!synchronousValidations) {
$scope.setError();
@@ -458,6 +528,7 @@ angular
const asynchronousValidations = $q
.all([
+ validateFacilityHierarchy(),
validateContactIsInPlace(),
validateTokenLogin(),
])
diff --git a/admin/src/js/services/contact-types.js b/admin/src/js/services/contact-types.js
index c063928886a..068a5c24396 100644
--- a/admin/src/js/services/contact-types.js
+++ b/admin/src/js/services/contact-types.js
@@ -49,6 +49,11 @@ angular.module('inboxServices').service('ContactTypes', function(
*/
getPlaceTypes: () => Settings().then(config => contactTypesUtils.getPlaceTypes(config)),
+ /**
+ * @returns {boolean} returns whether the provided places have the same contact_type
+ */
+ isSameContactType: (places) => contactTypesUtils.isSameContactType(places),
+
/**
* Returns a Promise to resolve all the configured person contact types
*/
diff --git a/admin/src/js/services/create-user.js b/admin/src/js/services/create-user.js
index b0129901b05..4ac649194e1 100644
--- a/admin/src/js/services/create-user.js
+++ b/admin/src/js/services/create-user.js
@@ -1,7 +1,6 @@
(function () {
'use strict';
- const URL = '/api/v2/users';
angular.module('services').factory('CreateUser',
function (
@@ -20,6 +19,7 @@
* @param {Object} updates Updates you wish to make
*/
const createSingleUser = (updates) => {
+ const URL = '/api/v3/users';
if (!updates.username) {
return $q.reject('You must provide a username to create a user');
}
@@ -43,6 +43,7 @@
* @param {Object} data content of the csv file
*/
const createMultipleUsers = (data) => {
+ const URL = '/api/v2/users';
$log.debug('CreateMultipleUsers', URL, data);
return $http({
@@ -61,6 +62,6 @@
createMultipleUsers
};
});
-
+
}()
);
diff --git a/admin/src/js/services/select2-search.js b/admin/src/js/services/select2-search.js
index 95fec28dd59..30ab2474873 100644
--- a/admin/src/js/services/select2-search.js
+++ b/admin/src/js/services/select2-search.js
@@ -6,13 +6,15 @@ angular.module('inboxServices').factory('Select2Search',
function(
$log,
$q,
+ $timeout,
$translate,
ContactMuted,
+ DB,
LineageModelGenerator,
Search,
Session,
Settings
- ) {
+ ) { //NoSONAR
'use strict';
'ngInject';
@@ -26,18 +28,29 @@ angular.module('inboxServices').factory('Select2Search',
return $(format.sender(row.doc, $translate));
};
- const defaultTemplateSelection = function(row) {
- if (row.doc) {
- return row.doc.name + (row.doc.muted ? ' (' + $translate.instant('contact.muted') + ')': '');
+ const defaultTemplateSelection = (selection) => {
+ const formatRow = (row) => {
+ if (!row.doc) {
+ return row.text;
+ }
+
+ let formatted = row.doc.name;
+ if (row.doc.muted) {
+ formatted += `(${$translate.instant('contact.muted')})`;
+ }
+ return formatted;
+ };
+ if (Array.isArray(selection)) {
+ return selection.map((row) => formatRow(row)).join(', ');
}
- return row.text;
+ return formatRow(selection);
};
const defaultSendMessageExtras = function(row) {
return row;
};
- return function(selectEl, _types, options) {
+ return function(selectEl, _types, options) { //NoSONAR
options = options || {};
let currentQuery;
@@ -114,10 +127,53 @@ angular.module('inboxServices').factory('Select2Search',
});
};
- const resolveInitialValue = function(selectEl, initialValue) {
+ const addValue = (val) => {
+ if (!selectEl.children('option[value="' + val + '"]').length) {
+ selectEl.append($(''));
+ }
+ };
+
+ const getDocs = function (ids) {
+
+ return DB()
+ .allDocs({ keys: ids, include_docs: true })
+ .then((docs) => processDocs(docs));
+ };
+
+ const processDocs = function(result) {
+ const docIds = result.rows.map((row) => row.doc._id);
+ return hydrateDocs(docIds);
+ };
+
+ const hydrateDocs = function(docIds) {
+ return $q
+ .all(docIds.map(id => LineageModelGenerator.contact(id, { merge: true })))
+ .then(muteContacts);
+ };
+
+ const muteContacts = function (contacts) {
+ contacts.forEach((contact) => {
+ contact.doc.muted = ContactMuted(contact.doc);
+ });
+
+ return contacts.map((doc) => ({
+ id: doc._id,
+ doc: doc.doc,
+ }));
+ };
+
+ const populateSelectWithDocs = function (selectEl, docs) {
+ docs.forEach((doc) => {
+ updateSelect2DataWithDoc(selectEl, doc.id, doc.doc);
+ });
+ };
+
+ const resolveInitialValue = function(selectEl, initialValue) { //NoSONAR
if (initialValue) {
- if (!selectEl.children('option[value="' + initialValue + '"]').length) {
- selectEl.append($(''));
+ if (Array.isArray(initialValue)) {
+ initialValue.forEach((val) => addValue(val));
+ } else {
+ addValue(initialValue);
}
selectEl.val(initialValue);
} else {
@@ -125,15 +181,16 @@ angular.module('inboxServices').factory('Select2Search',
}
let resolution;
- let value = selectEl.val();
+ const value = selectEl.val();
if (!(value && value.length)) {
resolution = $q.resolve();
} else {
if (Array.isArray(value)) {
- // NB: For now we only support resolving one initial value
- // multiple is not an existing use case for us
- value = value[0];
+ resolution = getDocs(value) //NoSONAR
+ .then(docs => populateSelectWithDocs(selectEl, docs))
+ .catch(err => $log.error('Select2 failed to get documents', err));
}
+
if (phoneNumber.validate(Settings, value)) {
// Raw phone number, don't resolve from DB
const text = templateSelection({ text: value });
@@ -147,12 +204,43 @@ angular.module('inboxServices').factory('Select2Search',
}
}
- return resolution.then(function() {
- selectEl.trigger('change');
+ return resolution.then(function() { //NoSONAR
+ $timeout(() => { //NoSONAR
+ selectEl.trigger('change');
+ });
return selectEl;
});
};
+ const updateSelect2DataWithDoc = function (selectEl, docId, doc) {
+ const select2Data = selectEl.select2('data') || [];
+ const selected = select2Data.find((d) => d.id === docId);
+ if (selected) {
+ selected.doc = doc;
+ } else {
+ select2Data.push({
+ id: docId,
+ doc: doc,
+ text: doc.name,
+ });
+ }
+ selectEl.select2('data', select2Data);
+ $timeout(() => { //NoSONAR
+ selectEl.trigger('change');
+ });
+ };
+
+ const updateDocument = function (selectEl, docId) {
+ return getDoc(docId)
+ .then((doc) => {
+ updateSelect2DataWithDoc(selectEl, docId, doc);
+ return selectEl;
+ })
+ .catch((err) => {
+ $log.error('Select2 failed to get document', err);
+ });
+ };
+
const initSelect2 = function(selectEl) {
selectEl.select2({
ajax: {
@@ -172,6 +260,8 @@ angular.module('inboxServices').factory('Select2Search',
.on('click', function() {
selectEl.append($(''));
selectEl.trigger('change');
+
+ return selectEl;
});
selectEl.after(button);
}
@@ -185,17 +275,12 @@ angular.module('inboxServices').factory('Select2Search',
e.params.data.id;
if (docId) {
- getDoc(docId).then(function(doc) {
- selectEl.select2('data')[0].doc = doc;
- selectEl.trigger('change');
- })
- .catch(err => $log.error('Select2 failed to get document', err));
+ updateDocument(selectEl, docId);
}
});
}
};
-
initSelect2(selectEl);
return resolveInitialValue(selectEl, initialValue);
};
diff --git a/admin/src/templates/edit_user.html b/admin/src/templates/edit_user.html
index 6c2aa32844b..ad5cd677e5c 100644
--- a/admin/src/templates/edit_user.html
+++ b/admin/src/templates/edit_user.html
@@ -55,13 +55,15 @@
{{errors.roles}}
-
+
-
diff --git a/admin/tests/unit/controllers/edit-user.spec.js b/admin/tests/unit/controllers/edit-user.spec.js
index 802cdd04b98..a10a725bfa8 100644
--- a/admin/tests/unit/controllers/edit-user.spec.js
+++ b/admin/tests/unit/controllers/edit-user.spec.js
@@ -8,6 +8,7 @@ describe('EditUserCtrl controller', () => {
let mockEditCurrentUser;
let scope;
let dbGet;
+ let dbAllDocs;
let UpdateUser;
let CreateUser;
let UserSettings;
@@ -21,6 +22,7 @@ describe('EditUserCtrl controller', () => {
module('adminApp');
dbGet = sinon.stub();
+ dbAllDocs = sinon.stub();
UpdateUser = sinon.stub().resolves();
CreateUser = {
createSingleUser: sinon.stub().resolves()
@@ -29,10 +31,14 @@ describe('EditUserCtrl controller', () => {
Settings = sinon.stub().resolves({
roles: {
'district-manager': { name: 'xyz', offline: true },
+ 'community-health-assistant': { name: 'xyz', offline: true },
'data-entry': { name: 'abc' },
supervisor: { name: 'qrt', offline: true },
'national-manager': { name: 'national-manager', offline: false },
- }
+ },
+ permissions: {
+ can_have_multiple_places: ['community-health-assistant'],
+ },
});
http = { get: sinon.stub() };
userToEdit = {
@@ -41,7 +47,7 @@ describe('EditUserCtrl controller', () => {
fullname: 'user.fullname',
email: 'user@email.com',
phone: 'user.phone',
- facility_id: 'abc',
+ facility_id: ['abc'],
contact_id: 'xyz',
roles: [ 'district-manager', 'supervisor' ],
language: 'zz',
@@ -64,6 +70,7 @@ describe('EditUserCtrl controller', () => {
'DB',
KarmaUtils.mockDB({
get: dbGet,
+ allDocs: dbAllDocs,
})
);
$provide.value('UpdateUser', UpdateUser);
@@ -348,6 +355,24 @@ describe('EditUserCtrl controller', () => {
});
});
+ it('should allow only user with permission to have multiple places', () => {
+ return mockEditAUser(userToEdit)
+ .setupPromise.then(() => {
+ mockContact(userToEdit.contact_id);
+ mockFacility(['facility_id', 'facility_id_2']);
+ mockContactGet(userToEdit.contact_id);
+ translate.withArgs('permission.description.can_have_multiple_places.not_allowed')
+ .resolves('The person with selected role cannot have multiple places');
+
+ return scope.editUser();
+ })
+ .then(() => {
+ chai.expect(scope.errors.multiFacility).to.equal(
+ 'The person with selected role cannot have multiple places'
+ );
+ });
+ });
+
it('user is updated', () => {
mockContact(userToEdit.contact_id);
@@ -363,7 +388,7 @@ describe('EditUserCtrl controller', () => {
scope.editUserModel.fullname = 'fullname';
scope.editUserModel.email = 'email@email.com';
scope.editUserModel.phone = 'phone';
- scope.editUserModel.facilitySelect = 'facility_id';
+ scope.editUserModel.facilitySelect = ['facility_id'];
scope.editUserModel.contactSelect = 'contact_id';
scope.editUserModel.password = 'medic.1234';
scope.editUserModel.passwordConfirm = 'medic.1234';
@@ -397,6 +422,64 @@ describe('EditUserCtrl controller', () => {
});
});
+ it('user is updated with multiple places', () => {
+ mockContact(userToEdit.contact_id);
+ mockFacility(['facility_id', 'facility_id_2']);
+ mockContactGet(userToEdit.contact_id);
+ http.get.withArgs('/api/v1/users-info').resolves({
+ data: { total_docs: 20000, warn_docs: 800, warn: false, limit: 10000 },
+ });
+
+ dbAllDocs.resolves({
+ rows: [
+ { doc: { _id: 'facility_id' } },
+ { doc: { _id: 'facility_id_2' } },
+ ],
+ });
+
+ return mockEditAUser(userToEdit)
+ .setupPromise.then(() => {
+ scope.editUserModel.fullname = 'fullname';
+ scope.editUserModel.email = 'email@email.com';
+ scope.editUserModel.phone = 'phone';
+ scope.editUserModel.facilitySelect = ['facility_id', 'facility_id_2'];
+ scope.editUserModel.contactSelect = 'contact_id';
+ scope.editUserModel.password = 'medic.1234';
+ scope.editUserModel.passwordConfirm = 'medic.1234';
+ scope.editUserModel.roles = ['community-health-assistant'];
+
+ return scope.editUser();
+ })
+ .then(() => {
+ chai.expect(UpdateUser.called).to.equal(true);
+ const updateUserArgs = UpdateUser.getCall(0).args;
+
+ chai.expect(updateUserArgs[0]).to.equal('user.name');
+
+ const updates = updateUserArgs[1];
+ chai.expect(updates.fullname).to.equal(scope.editUserModel.fullname);
+ chai.expect(updates.email).to.equal(scope.editUserModel.email);
+ chai.expect(updates.phone).to.equal(scope.editUserModel.phone);
+ chai
+ .expect(updates.place)
+ .to.deep.equal(['facility_id', 'facility_id_2']);
+ chai.expect(updates.contact).to.equal(scope.editUserModel.contact_id);
+ chai.expect(updates.roles).to.deep.equal(scope.editUserModel.roles);
+ chai.expect(updates.password).to.deep.equal(scope.editUserModel.password);
+ chai.expect(http.get.callCount).to.equal(1);
+ chai.expect(http.get.args[0]).to.deep.equal([
+ '/api/v1/users-info',
+ {
+ params: {
+ role: ['community-health-assistant'],
+ facility_id: scope.editUserModel.place,
+ contact_id: scope.editUserModel.contact,
+ },
+ },
+ ]);
+ });
+ });
+
it('sorts roles when saving', () => {
mockContact(userToEdit.contact_id);
mockFacility(userToEdit.facility_id);
@@ -443,7 +526,7 @@ describe('EditUserCtrl controller', () => {
scope.editUserModel.fullname = 'fullname';
scope.editUserModel.email = 'email@email.com';
scope.editUserModel.phone = 'phone';
- scope.editUserModel.facilitySelect = 'facility_id';
+ scope.editUserModel.facilitySelect = ['facility_id'];
scope.editUserModel.contactSelect = 'contact_id';
scope.editUserModel.password = 'medic.1234';
scope.editUserModel.passwordConfirm = 'medic.1234';
@@ -469,7 +552,7 @@ describe('EditUserCtrl controller', () => {
it('should not save user if offline and is warned by users-info', () => {
mockContact('new_contact_id');
- mockFacility('new_facility_id');
+ mockFacility(['new_facility_id']);
mockContactGet('new_facility_id');
http.get
.withArgs('/api/v1/users-info')
@@ -481,7 +564,7 @@ describe('EditUserCtrl controller', () => {
scope.editUserModel.fullname = 'fullname';
scope.editUserModel.email = 'email@email.com';
scope.editUserModel.phone = 'phone';
- scope.editUserModel.facilitySelect = 'new_facility';
+ scope.editUserModel.facilitySelect = ['new_facility'];
scope.editUserModel.contactSelect = 'new_contact';
scope.editUserModel.password = 'medic.1234';
scope.editUserModel.passwordConfirm = 'medic.1234';
@@ -495,7 +578,7 @@ describe('EditUserCtrl controller', () => {
chai.expect(http.get.callCount).to.equal(1);
chai.expect(http.get.args[0]).to.deep.equal([
'/api/v1/users-info',
- { params: { role: [ 'supervisor' ], facility_id: 'new_facility_id', contact_id: 'new_contact_id' }}
+ { params: { role: [ 'supervisor' ], facility_id: ['new_facility_id'], contact_id: 'new_contact_id' }}
]);
chai.expect(scope.setError.callCount).to.equal(1);
chai.expect(scope.setError.args[0]).to.deep.equal([
@@ -515,7 +598,7 @@ describe('EditUserCtrl controller', () => {
it('should save user if offline and warned when user clicks on submit the 2nd time', () => {
mockContact('new_contact_id');
- mockFacility('new_facility_id');
+ mockFacility(['new_facility_id']);
mockContactGet('new_facility_id');
http.get
.withArgs('/api/v1/users-info')
@@ -528,7 +611,7 @@ describe('EditUserCtrl controller', () => {
scope.editUserModel.fullname = 'fullname';
scope.editUserModel.email = 'email@email.com';
scope.editUserModel.phone = 'phone';
- scope.editUserModel.facilitySelect = 'new_facility';
+ scope.editUserModel.facilitySelect = ['new_facility'];
scope.editUserModel.contactSelect = 'new_contact';
scope.editUserModel.password = 'medic.1234';
scope.editUserModel.passwordConfirm = 'medic.1234';
@@ -541,7 +624,7 @@ describe('EditUserCtrl controller', () => {
chai.expect(http.get.callCount).to.equal(1);
chai.expect(http.get.args[0]).to.deep.equal([
'/api/v1/users-info',
- { params: { role: [ 'supervisor' ], facility_id: 'new_facility_id', contact_id: 'new_contact_id' }}
+ { params: { role: [ 'supervisor' ], facility_id: ['new_facility_id'], contact_id: 'new_contact_id' }}
]);
chai.expect(translate.callCount).to.equal(1);
diff --git a/admin/tests/unit/services/update-user.spec.js b/admin/tests/unit/services/update-user.spec.js
index 1b5d0aba7d3..62d25185931 100644
--- a/admin/tests/unit/services/update-user.spec.js
+++ b/admin/tests/unit/services/update-user.spec.js
@@ -82,7 +82,7 @@ describe('CreateUser service', () => {
chai.expect($http.callCount).to.equal(1);
chai.expect($http.args[0][0]).to.deep.equal({
method: 'POST',
- url: '/api/v2/users',
+ url: '/api/v3/users',
data: {username: 'user', some: 'updates'},
headers: {
'Accept': 'application/json',
diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties
index 8b1155bd8e8..4962d4f1738 100644
--- a/api/resources/translations/messages-en.properties
+++ b/api/resources/translations/messages-en.properties
@@ -790,7 +790,7 @@ instance.upgrade.error.get_upgrade = Error fetching upgrading progress
instance.upgrade.error.version_fetch = Error fetching available versions
instance.upgrade.feature_releases = Feature Releases Betas
instance.upgrade.install = Install
-instance.upgrade.interrupted = An unexpected server error caused the upgrade to be interrupted.
+instance.upgrade.interrupted = An unexpected server error caused the upgrade to be interrupted.
instance.upgrade.no_betas = There are no new betas you can upgrade to.
instance.upgrade.no_branches = No build branches available.
instance.upgrade.no_details = (details unavailable)
@@ -1027,6 +1027,8 @@ permission.description.can_view_unallocated_data_records = Allowed to see report
permission.description.can_view_users = Allowed to get a list of all configured users.
permission.description.can_write_wealth_quintiles = Allowed to update contacts with their wealth quintiles.
permission.description.can_upgrade = Allowed to upgrade the CHT Core Framework version via the API or admin interface.
+permission.description.can_have_multiple_places.not_allowed = The selected roles do not have permission to be assigned multiple places.
+permission.description.can_have_multiple_places.incompatible_place = The selected places must be of the same type (be at the same hierarchy level).
permissions = Permissions
person.field.alternate_phone = Alternative phone number
person.field.code = Code
diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties
index f0eaab8b01b..bf8fc983850 100644
--- a/api/resources/translations/messages-es.properties
+++ b/api/resources/translations/messages-es.properties
@@ -1027,6 +1027,8 @@ permission.description.can_view_unallocated_data_records = Ver informes que no t
permission.description.can_view_users = Ver la lista de todos los usuarios configurados.
permission.description.can_write_wealth_quintiles = Actualizar contactos con sus quintiles de riqueza.
permission.description.can_upgrade = Actualizar la versión de CHT Core Framework a través de la API o en la Gestión de la Aplicación.
+permission.description.can_have_multiple_places.not_allowed = Los roles seleccionados no tienen permiso para ser asignados a múltiples lugares.
+permission.description.can_have_multiple_places.incompatible_place = Los lugares seleccionados deben ser del mismo tipo (ser del mismo nivel de jerarquía).
permissions = Permisos
person.field.alternate_phone = Número de teléfono alternativo
person.field.code = Código
diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties
index 5926ed1b1c7..92431b1333d 100644
--- a/api/resources/translations/messages-fr.properties
+++ b/api/resources/translations/messages-fr.properties
@@ -1027,6 +1027,8 @@ permission.description.can_view_unallocated_data_records = Autorisé à voir les
permission.description.can_view_users = Autorisé à obtenir une liste de tous les utilisateurs configurés.
permission.description.can_write_wealth_quintiles = Autorisé à mettre à jour les contacts avec leurs quintiles de richesse.
permission.description.can_upgrade = Autorisé à mettre à niveau la version de CHT Core Framework via l'API ou l'interface d'administration.
+permission.description.can_have_multiple_places.not_allowed = Les rôles sélectionnés ne sont pas autorisés à se voir attribuer plusieurs places.
+permission.description.can_have_multiple_places.incompatible_place = Les places sélectionnées doivent être du même type (être au même niveau hiérarchique).
permissions = Permissions
person.field.alternate_phone = Numéro de téléphone alternatif
person.field.code = Code
diff --git a/api/resources/translations/messages-ne.properties b/api/resources/translations/messages-ne.properties
index e16a1ec10a5..0ad3076b698 100644
--- a/api/resources/translations/messages-ne.properties
+++ b/api/resources/translations/messages-ne.properties
@@ -949,6 +949,8 @@ permission.description.can_view_tasks_tab =
permission.description.can_view_unallocated_data_records =
permission.description.can_view_users =
permission.description.can_write_wealth_quintiles =
+permission.description.can_have_multiple_places.not_allowed = तपाईंले छानेको भूमिकाहरुलाई धेरै स्थानको प्रमुख बनाउन मिल्दैन.
+permission.description.can_have_multiple_places.incompatible_place = तपाईंले छानेका स्थानहरु एकै प्रकारको हुनु पर्छ (एकै तहमा भएको)।.
permissions =
person.field.alternate_phone = वैकल्पिक फोन नम्बर
person.field.code = कोड
diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties
index d7e2c7204f2..2843aae8213 100644
--- a/api/resources/translations/messages-sw.properties
+++ b/api/resources/translations/messages-sw.properties
@@ -1027,6 +1027,8 @@ permission.description.can_view_unallocated_data_records = Inaruhusiwa kuona rip
permission.description.can_view_users = Inaruhusiwa kupata orodha ya watumiaji wote waliosanidiwa.
permission.description.can_write_wealth_quintiles = Inaruhusiwa kusasisha watu na viwango vyao vya utajiri.
permission.description.can_upgrade = Inaruhusiwa kuboresha toleo la CHT kupitia API au kiolesura cha msimamizi.
+permission.description.can_have_multiple_places.not_allowed = Mtu aliye na majukumu yaliyochaguliwa hana ruhusa ya kupewa maeneo mengi.
+permission.description.can_have_multiple_places.incompatible_place = Maeneo yaliyochaguliwa lazima yawe ya aina moja (yawe katika kiwango sawa cha uongozi).
permissions = Ruhusa
person.field.alternate_phone = Nambari ya simu mbadala
person.field.code = Kanuni
diff --git a/shared-libs/contact-types-utils/src/index.js b/shared-libs/contact-types-utils/src/index.js
index d877183f30e..6a829002bcd 100644
--- a/shared-libs/contact-types-utils/src/index.js
+++ b/shared-libs/contact-types-utils/src/index.js
@@ -67,6 +67,11 @@ const isPlace = (config, contact) => {
return isPlaceType(type);
};
+const isSameContactType = (contacts) => {
+ const contactTypes = new Set(contacts.map(contact => getTypeId(contact)));
+ return contactTypes.size === 1;
+};
+
const isHardcodedType = type => HARDCODED_TYPES.includes(type);
const isOrphan = (type) => !type.parents || !type.parents.length;
@@ -93,6 +98,7 @@ module.exports = {
getContactType,
isPerson,
isPlace,
+ isSameContactType,
isHardcodedType,
HARDCODED_TYPES,
getContactTypes,
diff --git a/shared-libs/contact-types-utils/test/index.js b/shared-libs/contact-types-utils/test/index.js
index 7c56002eb38..6850c28db89 100644
--- a/shared-libs/contact-types-utils/test/index.js
+++ b/shared-libs/contact-types-utils/test/index.js
@@ -155,6 +155,45 @@ describe('ContactType Utils', () => {
});
});
+ describe('isSameContactType', () => {
+ it('should return true for hardcoded contacts of the same type', () => {
+ chai.expect(utils.isSameContactType([
+ { type: 'contact', contact_type: 'health_center' },
+ { type: 'contact', contact_type: 'health_center' },
+ ])).to.equal(true);
+ });
+ it('should return true for configurable contacts of the same type', () => {
+ chai.expect(utils.isSameContactType([
+ { type: 'my_health_center' },
+ { type: 'my_health_center' },
+ ])).to.equal(true);
+ });
+ it('should return true for a mix of hardcoded and configurable types of the same hierarchy', () => {
+ chai.expect(utils.isSameContactType([
+ { type: 'health_center' },
+ { type: 'contact', contact_type: 'health_center' },
+ ])).to.equal(true);
+ });
+ it('should return false for hardcoded contacts of different type', () => {
+ chai.expect(utils.isSameContactType([
+ { type: 'contact', contact_type: 'health_center' },
+ { type: 'contact', contact_type: 'district_hospital' },
+ ])).to.equal(false);
+ });
+ it('should return false for configurable contacts of different type', () => {
+ chai.expect(utils.isSameContactType([
+ { type: 'my_health_center' },
+ { type: 'health_center' },
+ ])).to.equal(false);
+ });
+ it('should return true for a mix of hardcoded and configurable types of the same hierarchy', () => {
+ chai.expect(utils.isSameContactType([
+ { type: 'health_center' },
+ { type: 'contact', contact_type: 'my_health_center' },
+ ])).to.equal(false);
+ });
+ });
+
describe('isPlaceType', () => {
it('should return false for no type', () => {
chai.expect(utils.isPlaceType(false)).to.equal(false);
diff --git a/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js b/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js
new file mode 100644
index 00000000000..ebed922623f
--- /dev/null
+++ b/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js
@@ -0,0 +1,73 @@
+const utils = require('@utils');
+const usersAdminPage = require('@page-objects/default/users/user.wdio.page');
+const adminPage = require('@page-objects/default/admin/admin.wdio.page');
+const commonPage = require('@page-objects/default/common/common.wdio.page');
+const contactPage = require('@page-objects/default/contacts/contacts.wdio.page');
+const loginPage = require('@page-objects/default/login/login.wdio.page');
+
+const placeFactory = require('@factories/cht/contacts/place');
+const personFactory = require('@factories/cht/contacts/person');
+
+const offlineUserRole = 'chw';
+const username = 'jackuser';
+const password = 'Jacktest@123';
+const places = placeFactory.generateHierarchy();
+const districtHospital = places.get('district_hospital');
+const districtHospital2 = placeFactory.place().build({
+ name: 'district_hospital',
+ type: 'district_hospital',
+});
+
+const person = personFactory.build({
+ parent: {
+ _id: districtHospital._id,
+ parent: districtHospital.parent,
+ },
+ roles: [offlineUserRole],
+});
+
+const docs = [...places.values(), person, districtHospital2];
+
+describe('User Test Cases ->', () => {
+ before(async () => {
+ const settings = await utils.getSettings();
+ const permissions = {
+ ...settings.permissions,
+ can_have_multiple_places: [offlineUserRole],
+ };
+ await utils.updateSettings({ permissions }, true);
+ await utils.saveDocs(docs);
+ await loginPage.cookieLogin();
+ });
+
+ beforeEach(async () => {
+ if (await usersAdminPage.addUserDialog().isDisplayed()) {
+ await usersAdminPage.closeAddUserDialog();
+ }
+ await usersAdminPage.goToAdminUser();
+ await usersAdminPage.openAddUserDialog();
+ });
+
+ describe('Creating Users ->', () => {
+ after(async () => await utils.deleteUsers([{ username: username }]));
+
+ it('should add user with multiple places with permission', async () => {
+ await usersAdminPage.inputAddUserFields(
+ username,
+ 'Jack',
+ offlineUserRole,
+ [districtHospital.name, districtHospital2.name],
+ person.name,
+ password
+ );
+ await usersAdminPage.saveUser();
+ await adminPage.logout();
+ await loginPage.login({ username, password });
+ await commonPage.goToPeople();
+ await contactPage.selectLHSRowByText(districtHospital2.name);
+ await commonPage.openMoreOptionsMenu();
+
+ expect(await commonPage.isMenuOptionEnabled('delete', 'contacts')).to.be.false;
+ });
+ });
+});
diff --git a/tests/e2e/default/contacts/edit-person-home-place.wdio-spec.js b/tests/e2e/default/contacts/edit-person-home-place.wdio-spec.js
index ca4d102fd44..36643469153 100644
--- a/tests/e2e/default/contacts/edit-person-home-place.wdio-spec.js
+++ b/tests/e2e/default/contacts/edit-person-home-place.wdio-spec.js
@@ -6,7 +6,7 @@ const placeFactory = require('@factories/cht/contacts/place');
const userFactory = require('@factories/cht/users/users');
const personFactory = require('@factories/cht/contacts/person');
-describe.skip('Edit Person Under Area', () => {
+describe('Edit Person Under Area', () => {
const places = placeFactory.generateHierarchy();
const healthCenter = places.get('health_center');
diff --git a/tests/e2e/default/contacts/person-under-area.wdio-spec.js b/tests/e2e/default/contacts/person-under-area.wdio-spec.js
index 0bb93d28626..3e634a0cc60 100644
--- a/tests/e2e/default/contacts/person-under-area.wdio-spec.js
+++ b/tests/e2e/default/contacts/person-under-area.wdio-spec.js
@@ -45,7 +45,7 @@ const person2 = personFactory.build(
const docs = [...places.values(), healthCenter2, person1, person2];
-describe.skip('Create Person Under Area', () => {
+describe('Create Person Under Area', () => {
before(async () => {
await utils.saveDocs(docs);
await loginPage.cookieLogin();
diff --git a/tests/e2e/default/users/add-user.wdio-spec.js b/tests/e2e/default/users/add-user.wdio-spec.js
index 364806d6dbc..07b96112524 100644
--- a/tests/e2e/default/users/add-user.wdio-spec.js
+++ b/tests/e2e/default/users/add-user.wdio-spec.js
@@ -11,21 +11,33 @@ const password = 'Jacktest@123';
const incorrectpassword = 'Passwor';
const places = placeFactory.generateHierarchy();
const districtHospital = places.get('district_hospital');
+const districtHospital2 = placeFactory.place().build({
+ name: 'district_hospital',
+ type: 'district_hospital',
+});
const person = personFactory.build(
{
parent: {
_id: districtHospital._id,
parent: districtHospital.parent
- }
+ },
+ roles: [offlineUserRole]
}
);
-const docs = [...places.values(), person];
+
+const docs = [...places.values(), person, districtHospital2];
describe('User Test Cases ->', () => {
before(async () => {
+ const settings = await utils.getSettings();
+ const permissions = {
+ ...settings.permissions,
+ can_have_multiple_places: [offlineUserRole],
+ };
+ await utils.updateSettings({ permissions }, true);
await utils.saveDocs(docs);
await loginPage.cookieLogin();
});
@@ -54,6 +66,19 @@ describe('User Test Cases ->', () => {
await usersAdminPage.saveUser();
expect(await usersAdminPage.getAllUsernames()).to.include.members([username]);
});
+
+ it('should add user with multiple places with permission', async () => {
+ await usersAdminPage.inputAddUserFields(
+ 'new_jack',
+ 'Jack',
+ offlineUserRole,
+ [districtHospital.name, districtHospital2.name],
+ person.name,
+ password
+ );
+ await usersAdminPage.saveUser();
+ expect(await usersAdminPage.getAllUsernames()).to.include.members([username]);
+ });
});
describe('Invalid entries -> ', () => {
@@ -94,5 +119,20 @@ describe('User Test Cases ->', () => {
expect(await usersAdminPage.getPlaceErrorText()).to.contain('required');
expect(await usersAdminPage.getContactErrorText()).to.contain('required');
});
+
+ it('should require user to have permission for multiple places', async () => {
+ await usersAdminPage.inputAddUserFields(
+ username,
+ 'Jack',
+ onlineUserRole,
+ [districtHospital.name, districtHospital2.name],
+ person.name,
+ password
+ );
+ await usersAdminPage.saveUser(false);
+ expect(await usersAdminPage.getPlaceErrorText()).to.contain(
+ 'The selected roles do not have permission to be assigned multiple places.'
+ );
+ });
});
});
diff --git a/tests/integration/api/controllers/users.spec.js b/tests/integration/api/controllers/users.spec.js
index 8e62073b4c9..42f40668274 100644
--- a/tests/integration/api/controllers/users.spec.js
+++ b/tests/integration/api/controllers/users.spec.js
@@ -1716,7 +1716,7 @@ describe('Users API', () => {
});
- describe.skip('POST/GET api/v2/users', () => {
+ describe('POST/GET api/v2/users', () => {
before(async () => {
await utils.saveDoc(parentPlace);
});
diff --git a/tests/page-objects/default/users/user.wdio.page.js b/tests/page-objects/default/users/user.wdio.page.js
index ea694406184..31cb879093f 100644
--- a/tests/page-objects/default/users/user.wdio.page.js
+++ b/tests/page-objects/default/users/user.wdio.page.js
@@ -48,7 +48,7 @@ const scrollToBottomOfModal = async () => {
});
};
-const inputAddUserFields = async (username, fullname, role, place, contact, password, confirmPassword = password) => {
+const inputAddUserFields = async (username, fullname, role, places, contact, password, confirmPassword = password) => {
await (await userName()).setValue(username);
await (await userFullName()).setValue(fullname);
await (await $(`#role-select input[value="${role}"]`)).click();
@@ -57,8 +57,14 @@ const inputAddUserFields = async (username, fullname, role, place, contact, pass
// scrollIntoView doesn't work because they're within a scrollable div (the modal)
await scrollToBottomOfModal();
- if (!_.isEmpty(place)) {
- await selectPlace(place);
+ if (!_.isEmpty(places)) {
+ if (Array.isArray(places)) {
+ for (const name of places) {
+ await selectPlace([name]);
+ }
+ } else {
+ await selectPlace([places]);
+ }
}
if (!_.isEmpty(contact)) {
@@ -78,7 +84,9 @@ const setSelect2 = async (id, value) => {
await input.waitForExist();
await input.click();
- const searchField = await $('.select2-search__field');
+ const searchField = await $(
+ `.select2-container--open .select2-search__field`
+ );
await searchField.waitForExist();
await searchField.setValue(value);
@@ -88,8 +96,26 @@ const setSelect2 = async (id, value) => {
await option.click();
};
-const selectPlace = async (place) => {
- await setSelect2('facilitySelect', place);
+const setPlaceSelectMultiple = async (value) => {
+ const input = await $(`span.select2-selection--multiple`);
+ await input.waitForExist();
+ await input.click();
+
+ const searchField = await $('span.select2-selection--multiple .select2-search__field');
+ await searchField.waitForExist();
+ await searchField.setValue(value);
+
+ const option = await $('.name');
+ await option.waitForExist();
+ await option.waitForClickable();
+ await option.click();
+ await browser.waitUntil(async () => await (await $('.select2-selection__choice')).isDisplayed(), 1000);
+};
+
+const selectPlace = async (places) => {
+ for (const place of places) {
+ await setPlaceSelectMultiple(place);
+ }
};
const selectContact = async (associatedContact) => {
diff --git a/webapp/src/ts/modules/contacts/contacts-more-menu.component.html b/webapp/src/ts/modules/contacts/contacts-more-menu.component.html
index f6fbb8c6b68..f80c14ddc26 100644
--- a/webapp/src/ts/modules/contacts/contacts-more-menu.component.html
+++ b/webapp/src/ts/modules/contacts/contacts-more-menu.component.html
@@ -12,7 +12,7 @@
{{ 'Edit' | translate }}
-