From 774b12a1667caffd453262bf66c459aeb9b6e668 Mon Sep 17 00:00:00 2001 From: Jonathan Niles Date: Tue, 25 Jan 2022 09:02:18 +0100 Subject: [PATCH] feat(odk): implement ODK Central Link This commit implements the ODK Central link with BHIMA, developped during the `prosani-sprint`. The goal is to have a functional, optional link to ODK Central that can be merged into the BHIMA repository. Closes #6235. --- client/src/i18n/en/form.json | 6 +- client/src/i18n/en/odk.json | 25 + client/src/i18n/en/tree.json | 1 + client/src/i18n/fr/form.json | 6 +- client/src/i18n/fr/odk.json | 25 + client/src/i18n/fr/tree.json | 1 + .../modules/odk-settings/odk-settings.html | 139 +++++ .../src/modules/odk-settings/odk-settings.js | 103 ++++ .../odk-settings/odk-settings.routes.js | 9 + .../odk-settings/odk-settings.service.js | 62 ++ client/src/modules/users/qrcode.modal.html | 9 + client/src/modules/users/qrcode.modal.js | 14 + .../users/templates/grid/action.cell.html | 8 +- client/src/modules/users/users.js | 22 +- server/config/routes.js | 3 + server/controllers/admin/odk-central.js | 582 ++++++++++++++++++ server/models/admin.sql | 10 + server/models/bhima.sql | 8 +- server/models/migrations/next/migrate.sql | 46 +- server/models/schema.sql | 31 + 20 files changed, 1087 insertions(+), 23 deletions(-) create mode 100644 client/src/i18n/en/odk.json create mode 100644 client/src/i18n/fr/odk.json create mode 100644 client/src/modules/odk-settings/odk-settings.html create mode 100644 client/src/modules/odk-settings/odk-settings.js create mode 100644 client/src/modules/odk-settings/odk-settings.routes.js create mode 100644 client/src/modules/odk-settings/odk-settings.service.js create mode 100644 client/src/modules/users/qrcode.modal.html create mode 100644 client/src/modules/users/qrcode.modal.js create mode 100644 server/controllers/admin/odk-central.js diff --git a/client/src/i18n/en/form.json b/client/src/i18n/en/form.json index 3ecd1ecea2..0e66eeb7fd 100644 --- a/client/src/i18n/en/form.json +++ b/client/src/i18n/en/form.json @@ -20,6 +20,7 @@ "CANCEL_EDIT": "Cancel Edit", "CASHBOX_MANAGEMENT": "Cashbox Management", "CLEAR": "Clear", + "SYNC" : "Sync", "CLEAR_GRID_CONFIGURATION": "Clear Grid Configuration", "CLOSE": "Close", "COLLAPSE_RECORDS" : "Collapse Records", @@ -552,8 +553,7 @@ "LAST_NAME": "Last Name", "LAST_PAGE": "Last Page Visited", "LATITUDE": "Latitude", - "LEGEND" : "Legend", - "LEVEL_OF_STUDY" : "Level of study", + "LEVEL_OF_STUDY" : "Level of study", "LIMIT": "Limit", "LIST_STRUCTURE": "List structure", "LOCATION_REGISTER": "Location Register", @@ -911,6 +911,7 @@ "CASHBOX": "Enter the cashbox", "CHOICE_FILTER": "Choice Filter", "CODE": "Enter code", + "CONDITION": "Condition", "COUNTRY": "Enter country", "CREDITOR": "Enter creditor", "CURRENCY": "Select currency", @@ -920,6 +921,7 @@ "DEFAULT_PURCHASE_ORDER_INTERVAL": "The default purchase order interval for calculating Maximum stock", "DESCRIPTION": "Enter description", "DOCUMENT_NAME": "Enter document name", + "ORIGIN": "Origin", "PATIENT_GROUP": "Enter patient group", "EMAIL": "Enter email", "LOT":"Enter Batch Number", diff --git a/client/src/i18n/en/odk.json b/client/src/i18n/en/odk.json new file mode 100644 index 0000000000..68dc762f82 --- /dev/null +++ b/client/src/i18n/en/odk.json @@ -0,0 +1,25 @@ +{ + "ODK" : { + "CONFIG_SETTINGS" : "ODK Central Configuration", + "IMPORTED_SUCCESSFULLY" : "Records successfully imported", + "LOAD_FOSA_DATA" : "Load data of FOSA", + "NO_RECORD_FOUND" : "No record found", + "ODK_ADMIN_PASSWORD" : "ODK Central Admin Password", + "ODK_ADMIN_USER" : "ODK Central Admin Email", + "ODK_CENTRAL_URL" : "ODK Central Server URL", + "ODK_INTEGRATION_SETTINGS" : "ODK Integration Settings", + "PROJECT_DETAILS" : "Project Details", + "PROJECT_FORMS" : "Number of Forms", + "PROJECT_ID" : "Project ID", + "PROJECT_NAME" : "Project Name", + "QRCODE" : "ODK QR Code", + "SHOW_QRCODE" : "Show QR Code", + "SYNC_ENTERPRISE" : "Synchronize enterprise settings", + "SYNC_FORMS" : "Synchronize forms", + "SYNC_SETTINGS" : "Synchronization Settings", + "SYNC_SUBMISSIONS" : "Synchronize submissions", + "SYNC_USERS" : "Synchronize users", + "TOTAL_APP_USERS" : "Number of App Users", + "TOTAL_FOUND" : "Total records found" + } +} diff --git a/client/src/i18n/en/tree.json b/client/src/i18n/en/tree.json index fa507d0425..191eece0cc 100644 --- a/client/src/i18n/en/tree.json +++ b/client/src/i18n/en/tree.json @@ -168,6 +168,7 @@ "STOCK_REPORT" : "Stock Inventories", "STOCK_REQUISITION":"Requisition", "STOCK_SETTINGS": "Stock Settings", + "ODK_SETTINGS" : "ODK Settings", "STOCK" : "Stock", "STOCK_VALUE" : "Stock Value Report", "SUBSIDY" : "Subsidy Management", diff --git a/client/src/i18n/fr/form.json b/client/src/i18n/fr/form.json index da6e97b100..34ee3176a5 100644 --- a/client/src/i18n/fr/form.json +++ b/client/src/i18n/fr/form.json @@ -17,6 +17,7 @@ "BACK": "Précédent", "BREAKDOWNS_PERCENTAGES": "Ventilation en pourcentage", "CANCEL": "Annuler", + "SYNC" : "Synchroniser", "CANCEL_EDIT": "Annuler Modifier", "CASHBOX_MANAGEMENT": "Gestion des Caisses", "CLEAR": "Effacer", @@ -556,8 +557,7 @@ "LAST_NAME": "Nom", "LAST_PAGE": "La dernière page visitée", "LATITUDE": "Latitude", - "LEGEND" : "Legende", - "LEVEL_OF_STUDY" : "Niveau d'étude", + "LEVEL_OF_STUDY" : "Niveau d'étude", "LIMIT": "Limite", "LIST_STRUCTURE": "Liste", "LOCATION_REGISTER": "Enregistrer une localisation", @@ -913,6 +913,7 @@ "CHOICE_FILTER": "Filtre", "CODE": "Entrer le code", "COUNTRY": "Entrer le nom du pays", + "CONDITION": "Condition", "CREDITOR": "Entrer un Créditeur", "CURRENCY": "Sélectionner la monnaie", "DEBTOR": "Entrer un Débiteur", @@ -929,6 +930,7 @@ "MAX_CREDIT": "Entrer le crédit maximale", "NAME": "Entrer le nom", "NOTES": "Commentaire", + "ORIGIN": "Origine", "PASSWORD": "Entrer le mot de passe", "PATIENT_ID": "Entrer identifiant patient", "PATIENT_NAME": "Entrer nom patient", diff --git a/client/src/i18n/fr/odk.json b/client/src/i18n/fr/odk.json new file mode 100644 index 0000000000..0682e9f671 --- /dev/null +++ b/client/src/i18n/fr/odk.json @@ -0,0 +1,25 @@ +{ + "ODK" : { + "CONFIG_SETTINGS" : "ODK Central Configuration", + "IMPORTED_SUCCESSFULLY" : "Données importées avec succès", + "LOAD_FOSA_DATA" : "Chargement des données des FOSA", + "NO_RECORD_FOUND" : "Aucun enregistrement trouvé", + "ODK_ADMIN_PASSWORD" : "Mot de passe de l'administrateur", + "ODK_ADMIN_USER" : "Adressse mail de l'administrateur", + "ODK_CENTRAL_URL" : "URL du serveur ODK Central", + "ODK_INTEGRATION_SETTINGS" : "Paramètres d'intégration ODK", + "PROJECT_DETAILS" : "Détails du projet", + "PROJECT_FORMS" : "Nombre de formulaire", + "PROJECT_ID" : "ID Projet", + "PROJECT_NAME" : "Nom du projet", + "QRCODE" : "ODK QR Code", + "SHOW_QRCODE" : "Afficher QR Code", + "SYNC_ENTERPRISE" : "Synchroniser les paramètres d'entreprise", + "SYNC_FORMS" : "Synchroniser les formulaires", + "SYNC_SETTINGS" : "Synchroniser les paramètres", + "SYNC_SUBMISSIONS" : "Synchroniser les soumissions des formulaires", + "SYNC_USERS" : "Synchroniser les utilisateurs", + "TOTAL_APP_USERS" : "Nombre des utilisateurs mobiles", + "TOTAL_FOUND" : "Total des enregistrements trouvés" + } +} diff --git a/client/src/i18n/fr/tree.json b/client/src/i18n/fr/tree.json index 3c4b7f4589..25540ebfa0 100644 --- a/client/src/i18n/fr/tree.json +++ b/client/src/i18n/fr/tree.json @@ -169,6 +169,7 @@ "STOCK_REQUISITION":"Réquisition", "STOCK_MOVEMENT_REPORT": "Rapport graphique des mouvements", "STOCK_SETTINGS": "Paramètres de stock", + "ODK_SETTINGS" : "Paramètres de l'ODK", "SUBSIDY":"Gestion des subventions", "TAGS" : "Gestion des étiquettes", "TRANSACTION_TYPE":"Types de Transaction", diff --git a/client/src/modules/odk-settings/odk-settings.html b/client/src/modules/odk-settings/odk-settings.html new file mode 100644 index 0000000000..42e1eaacd1 --- /dev/null +++ b/client/src/modules/odk-settings/odk-settings.html @@ -0,0 +1,139 @@ +
+
+
    +
  1. TREE.ADMIN
  2. +
  3. TREE.ODK_SETTINGS
  4. +
+
+
+ +
+
+
+
+
+
+ + ODK.ODK_INTEGRATION_SETTINGS +
+
+
+ + +
+
+
+
+ +
+ + +
+
+
+
+ +
+ + +
+
+
+
+
+ +
+
+ + + +
+
+
+ + ODK.SYNC_SETTINGS +
+ +
+
+ + +
+ +
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ +
+
+
+ +
+
+
+ + ODK.CONFIG_SETTINGS +
+ +
+
+ +
+
+
ODK.PROJECT_DETAILS
+
ODK.PROJECT_NAME: {{ODKSettingsCtrl.project.name}}
+
ODK.PROJECT_ID: {{ODKSettingsCtrl.project.id}}
+
ODK.PROJECT_FORMS: {{ODKSettingsCtrl.project.forms}}
+
FORM.LABELS.CREATED: {{ODKSettingsCtrl.project.createdAt | date}}
+
ODK.TOTAL_APP_USERS: {{ODKSettingsCtrl.appUsers.length | number}}
+
+
+ +
+
+ + +
+
diff --git a/client/src/modules/odk-settings/odk-settings.js b/client/src/modules/odk-settings/odk-settings.js new file mode 100644 index 0000000000..f1392d8138 --- /dev/null +++ b/client/src/modules/odk-settings/odk-settings.js @@ -0,0 +1,103 @@ +angular.module('bhima.controllers') + .controller('ODKSettingsController', ODKSettingsController); + +ODKSettingsController.$inject = [ + 'ODKSettingsService', 'util', 'NotifyService', 'SessionService', '$state', +]; + +/** + * ODK Settings Controller + * + * Provides configuration parameters for the link to ODK. + */ +function ODKSettingsController( + ODKSettings, util, Notify, Session, $state, +) { + const vm = this; + + vm.enterprise = Session.enterprise; + vm.settings = { }; + + // bind methods + vm.submit = submit; + vm.syncEnterprise = () => { + ODKSettings.syncEnterprise() + .then(() => { $state.reload(); }) + .catch(Notify.handleError); + }; + + vm.syncUsers = () => { + ODKSettings.syncUsers() + .then(() => ODKSettings.syncAppUsers()) + .then(() => { $state.reload(); }) + .catch(Notify.handleError); + }; + + vm.syncForms = () => { + ODKSettings.syncForms() + .then(() => { $state.reload(); }) + .catch(Notify.handleError); + }; + + vm.syncSubmissions = () => { + ODKSettings.syncSubmissions() + .then(() => { $state.reload(); }) + .catch(Notify.handleError); + }; + + // fired on startup + function startup() { + ODKSettings.read() + .then(settings => { + if (settings.length > 0) { + [vm.settings] = settings; + } + }) + .catch(Notify.handleError); + + vm.loading = true; + + ODKSettings.getProjectSettings() + .then(project => { + vm.project = project; + }) + .catch(Notify.handleError) + .finally(() => { vm.loading = false; }); + + ODKSettings.getAppUsers() + .then(appUsers => { + vm.appUsers = appUsers; + }) + .catch(Notify.handleError) + .finally(() => { vm.loading = false; }); + + // ODKSettings.getUserSettings() + // .then(users => { + // vm.users = users; + // }) + // .catch(Notify.handleError); + } + + // form submission + function submit(form) { + if (form.$invalid) { + Notify.danger('FORM.ERRORS.HAS_ERRORS'); + return 0; + } + + // make sure only fresh data is sent to the server. + if (form.$pristine) { + Notify.warn('FORM.WARNINGS.NO_CHANGES'); + return 0; + } + + const changes = angular.copy(vm.settings); + + return ODKSettings.create(changes) + .then(() => Notify.success('FORM.INFO.UPDATE_SUCCESS')) + .then(() => $state.reload()) // Should we just refresh the stock settings in the Session? + .catch(Notify.handleError); + } + + startup(); +} diff --git a/client/src/modules/odk-settings/odk-settings.routes.js b/client/src/modules/odk-settings/odk-settings.routes.js new file mode 100644 index 0000000000..9e5f0fc560 --- /dev/null +++ b/client/src/modules/odk-settings/odk-settings.routes.js @@ -0,0 +1,9 @@ +angular.module('bhima.routes') + .config(['$stateProvider', $stateProvider => { + $stateProvider + .state('odkSettings', { + url : '/admin/odk-settings', + controller : 'ODKSettingsController as ODKSettingsCtrl', + templateUrl : 'modules/odk-settings/odk-settings.html', + }); + }]); diff --git a/client/src/modules/odk-settings/odk-settings.service.js b/client/src/modules/odk-settings/odk-settings.service.js new file mode 100644 index 0000000000..524f509419 --- /dev/null +++ b/client/src/modules/odk-settings/odk-settings.service.js @@ -0,0 +1,62 @@ +angular.module('bhima.services') + .service('ODKSettingsService', ODKSettingsService); + +ODKSettingsService.$inject = ['PrototypeApiService']; + +function ODKSettingsService(Api) { + const baseUrl = '/admin/odk-settings/'; + const service = new Api(baseUrl); + + // + service.syncEnterprise = () => { + return service.$http.post(baseUrl.concat('sync-enterprise')) + .then(service.util.unwrapHttpResponse); + }; + + // + service.syncUsers = () => { + return service.$http.post(baseUrl.concat('sync-users')) + .then(service.util.unwrapHttpResponse); + }; + + // + service.syncAppUsers = () => { + return service.$http.post(baseUrl.concat('sync-app-users')) + .then(service.util.unwrapHttpResponse); + }; + + // + service.syncForms = () => { + return service.$http.post(baseUrl.concat('sync-forms')).then(service.util.unwrapHttpResponse); + }; + + service.syncSubmissions = () => { + return service.$http.post(baseUrl.concat('sync-submissions')).then(service.util.unwrapHttpResponse); + }; + // + service.syncStockMovements = () => { + return service.$http.post(baseUrl.concat('sync-stock-movements')) + .then(service.util.unwrapHttpResponse); + }; + + service.getProjectSettings = () => { + return service.$http.get(baseUrl.concat('project-settings')) + .then(service.util.unwrapHttpResponse); + }; + + service.getAppUsers = () => { + return service.$http.get(baseUrl.concat('app-users')) + .then(service.util.unwrapHttpResponse); + }; + + service.getAppUserQRCode = (userId) => { + return service.$http.get(baseUrl.concat(`app-users/${userId}/qrcode`)) + .then(service.util.unwrapHttpResponse); + }; + + service.getUserSettings = () => { + // todo + }; + + return service; +} diff --git a/client/src/modules/users/qrcode.modal.html b/client/src/modules/users/qrcode.modal.html new file mode 100644 index 0000000000..688a952e91 --- /dev/null +++ b/client/src/modules/users/qrcode.modal.html @@ -0,0 +1,9 @@ +
+ + + +
\ No newline at end of file diff --git a/client/src/modules/users/qrcode.modal.js b/client/src/modules/users/qrcode.modal.js new file mode 100644 index 0000000000..1f2c1a8a4d --- /dev/null +++ b/client/src/modules/users/qrcode.modal.js @@ -0,0 +1,14 @@ +angular.module('bhima.controllers') + .controller('UserQRCodeController', UserQRCodeController); + +UserQRCodeController.$inject = [ + 'data', '$uibModalInstance', +]; + +function UserQRCodeController(data, $uibModalInstance) { + const vm = this; + + vm.data = data; + + vm.close = $uibModalInstance.close; +} diff --git a/client/src/modules/users/templates/grid/action.cell.html b/client/src/modules/users/templates/grid/action.cell.html index 660fd1c508..c0c7722976 100644 --- a/client/src/modules/users/templates/grid/action.cell.html +++ b/client/src/modules/users/templates/grid/action.cell.html @@ -28,7 +28,13 @@
  • - FORM.LABELS.EDIT_ROLE + FORM.LABELS.EDIT_ROLE + +
  • +
  • +
  • + + ODK.QRCODE
  • diff --git a/client/src/modules/users/users.js b/client/src/modules/users/users.js index 772311febc..98b82acab1 100644 --- a/client/src/modules/users/users.js +++ b/client/src/modules/users/users.js @@ -1,17 +1,21 @@ angular.module('bhima.controllers') .controller('UsersController', UsersController); -UsersController.$inject = ['$state', '$uibModal', 'UserService', 'NotifyService', 'ModalService', 'uiGridConstants']; +UsersController.$inject = [ + '$state', '$uibModal', 'UserService', 'NotifyService', 'ModalService', 'uiGridConstants', + 'ODKSettingsService', +]; /** * Users Controller * This module is responsible for handling the CRUD operation on the user */ -function UsersController($state, $uibModal, Users, Notify, Modal, uiGridConstants) { +function UsersController($state, $uibModal, Users, Notify, Modal, uiGridConstants, ODKSettings) { const vm = this; vm.gridApi = {}; vm.toggleFilter = toggleFilter; vm.editRoles = editRoles; + vm.showQRCode = showQRCode; // this function selectively applies the muted cell classes to // disabled user entities @@ -160,12 +164,24 @@ function UsersController($state, $uibModal, Users, Notify, Modal, uiGridConstant }).result.then(() => { load(Users.filters.formatHTTP(true)); }); - } function toggleLoadingIndicator() { vm.loading = !vm.loading; } + function showQRCode(userId) { + ODKSettings.getAppUserQRCode(userId) + .then(qrcode => { + return $uibModal.open({ + templateUrl : 'modules/users/qrcode.modal.html', + controller : 'UserQRCodeController as UserQRCtrl', + resolve : { data : () => qrcode }, + }).result; + }) + .then(() => load(Users.filters.formatHTTP(true))) + .catch(Notify.handleError); + } + startup(); } diff --git a/server/config/routes.js b/server/config/routes.js index 1baeb3c4ff..4ebf074776 100644 --- a/server/config/routes.js +++ b/server/config/routes.js @@ -21,6 +21,7 @@ const units = require('../controllers/units'); const system = require('../controllers/system'); const report = require('../controllers/report'); const install = require('../controllers/install'); +const odk = require('../controllers/admin/odk-central'); // admin routes const rolesCtrl = require('../controllers/admin/roles'); @@ -1105,4 +1106,6 @@ exports.configure = function configure(app) { app.post('/configuration_analysis_tools', configurationAnalysisTools.create); app.put('/configuration_analysis_tools/:id', configurationAnalysisTools.update); app.delete('/configuration_analysis_tools/:id', configurationAnalysisTools.delete); + + app.use('/admin/odk-settings', odk.router); }; diff --git a/server/controllers/admin/odk-central.js b/server/controllers/admin/odk-central.js new file mode 100644 index 0000000000..2d5cdd5b66 --- /dev/null +++ b/server/controllers/admin/odk-central.js @@ -0,0 +1,582 @@ +/** + * @module odk-central + *api. + * @description + * This module contains the ODK Central API. + */ + +const router = require('express').Router(); +const debug = require('debug')('bhima:plugins:odk-central'); +const _ = require('lodash'); +const { json2csvAsync } = require('json-2-csv'); +const tempy = require('tempy'); +const path = require('path'); +const fs = require('fs/promises'); +const qrcode = require('qrcode'); +const pako = require('pako'); + +const central = require('@ima-worldhealth/odk-central-api-cjs'); +const db = require('../../lib/db'); +const util = require('../../lib/util'); +const core = require('../stock/core'); + +const { flux } = require('../../config/constants'); + +const odkCentralRoles = { + admin : 1, + projectManager : 5, + dataCollector : 8, +}; + +setupODKCentralConnection(); +async function setupODKCentralConnection() { + debug('initializing ODK Central link.'); + + // load the configuration from database if it exists + await loadODKCentralSettingsFromDatabase(); +} + +// BUILD QRCODE +async function buildQRCode(url, token, projectId, projectName) { + const data = { + general : { + protocol : 'odk_default', + server_url : `${url}/v1/key/${token}/projects/${projectId}`, + constraint_behavior : 'on_swipe', + }, + admin : { + edit_saved : false, + send_finalized : true, + automatic_update : true, + }, + project : { + name : projectName, + icon : 'P', + color : '#ff0000', + }, + }; + + const Uint8Array = new TextEncoder('utf-8').encode(JSON.stringify(data)); + const compressedSettings = pako.deflate(Uint8Array, { to : 'string' }); + const encodedS64 = Buffer.from(compressedSettings).toString('base64'); + + return qrcode.toDataURL(encodedS64); +} +// END BUILD QRCODE + +// utility function to format email addresses +function formatEmailAddr(email, enterpriseLabel) { + const [username, host] = email.split('@'); + return `${username}+${enterpriseLabel}@${host}`; +} + +function unformatEmailAddr(email) { + const [username, host] = email.split('@'); + const [prefix] = username.split('+'); + return `${prefix}@${host}`; +} + +async function defineUserAsDataCollector(userId) { + const odkProject = await db.one('SELECT odk_project_id AS id FROM odk_central_integration LIMIT 1;'); + await central.api.users.assignUserToProjectRole(odkProject.id, odkCentralRoles.dataCollector, userId); +} + +/** + * @function loadODKCentralSettingsFromDatabase + * + * @description + * Loads the ODK central settings from the database. + * + */ +async function loadODKCentralSettingsFromDatabase() { + const [odk] = await db.exec('SELECT * FROM odk_central_integration;'); + + if (!odk) { + debug('No odk_central_configuration found.'); + } else { + debug(`configuring ODK Central with url: ${odk.odk_central_url}`); + central.auth.setConfig(odk.odk_central_url, odk.odk_admin_user, odk.odk_admin_password); + debug('ODK Central link configured'); + } +} + +/** + * @function syncUsersWithCentral + * + * @description + * This function synchronizes user accounts with ODK Central if a configuration exists. + */ +async function syncUsersWithCentral() { + debug('Syncing BHIMA users with ODK Central users.'); + + // look for all users with depot permissions in the database + const users = await db.exec(` + SELECT user.id, user.display_name, user.email + FROM user WHERE user.deactivated <> 1 AND user.id NOT IN ( + SELECT bhima_user_id FROM odk_user + ) AND user.id IN (SELECT user_id FROM depot_permission); + `); + + // TODO(@jniles) LIMIT 1 is a hack. + const enterprise = await db.one('SELECT * FROM enterprise LIMIT 1'); + + debug(`There are ${users.length} users available in BHIMA.`); + + // pull the latest users from ODK Central. + const centralUsers = await central.api.users.listAllUsers(); + + debug(`There are ${centralUsers.length} users available in ODK Central.`); + + // get only central email addresses to use as a filter mask + const centralEmails = centralUsers.map(user => unformatEmailAddr(user.email)); + + debug(`Filtering out existing ODK Central users.`); + + // filter out all users who already have an email address in central + const usersToCreate = users.filter(user => !centralEmails.includes(formatEmailAddr(user.email, enterprise.label))); + + debug(`Found ${usersToCreate.length} users to create.`); + + // loop through users and create them in ODK Central. + for (const user of usersToCreate) { // eslint-disable-line + const password = util.uuid(); + const email = formatEmailAddr(user.email, enterprise.name); + debug(`Creating user ${email}.`); + // only for web user + // eslint-disable-next-line + const centralUser = await central.api.users.createUserWithPassword(email, password); + + // only for web user + // eslint-disable-next-line + await defineUserAsDataCollector(centralUser.id); + + // eslint-disable-next-line + await db.exec('INSERT INTO `odk_user` VALUES (?, ?, ?);', [centralUser.id, password, user.id]); + debug(`Finished with user ${email}.`); + } + + debug(`Created ${usersToCreate.length} users in ODK Central.`); +} + +/** + * @function syncUsersWithCentral + * + * @description + * This function synchronizes user accounts with ODK Central if a configuration exists. + */ +async function syncAppUsers() { + debug('Syncing BHIMA users with ODK Central app users.'); + + // look for all users with depot permissions in the database + const users = await db.exec(` + SELECT user.id, user.display_name, user.email + FROM user WHERE user.deactivated <> 1 AND user.id NOT IN ( + SELECT bhima_user_id FROM odk_app_user + ) AND user.id IN (SELECT user_id FROM depot_permission); + `); + + const config = await db.exec('SELECT odk_project_id FROM odk_central_integration LIMIT 1;'); + const projectId = config.length && config[0].odk_project_id; + + debug(`There are ${users.length} users available in BHIMA.`); + + // pull the latest app users from ODK Central. + const centralUsers = await central.api.users.listAllAppUsers(projectId); + + debug(`There are ${centralUsers.length} app-users available in ODK Central.`); + + // get only central name to use as a filter mask + const centralAppUserNames = centralUsers.map(user => user.displayName); + + debug(`Filtering out existing ODK Central app-users.`); + + // filter out all users who already have an email address in central + const usersToCreate = users + .filter(user => !centralAppUserNames.includes(user.displayName)); + + debug(`Found ${usersToCreate.length} users to create.`); + + // loop through users and create them in ODK Central. + for (const user of usersToCreate) { // eslint-disable-line + debug(`Creating app-user ${user.display_name}.`); + + // eslint-disable-next-line + const centralAppUser = await central.api.users.createAppUser(projectId, user.display_name); + + // eslint-disable-next-line + await db.exec('INSERT INTO `odk_app_user` VALUES (?, ?, ?, ?);', [centralAppUser.id, centralAppUser.token, user.display_name, user.id]); + debug(`Finished with user ${user.display_name}.`); + } + + debug(`Created ${usersToCreate.length} app users in ODK Central.`); +} + +/** + * @function syncEnterpriseWithCentral + */ +async function syncEnterpriseWithCentral() { + debug('Synchronizing BHIMA enterprise with ODK Central'); + const settings = await db.exec('SELECT * FROM odk_central_integration WHERE odk_project_id IS NULL;'); + + if (!settings.length) { + debug('Nothing to sync. Ignoring'); + return; + } + + const [enterprise] = await db.exec('SELECT * FROM enterprise;'); + + debug(`Creating a project on ODK Central for ${enterprise.name}.`); + + const result = await central.api.projects.createProject(enterprise.name); + + debug(`Created project on central with id: ${result.id}.`); + + await db.exec( + 'UPDATE odk_central_integration SET odk_project_id = ? WHERE enterprise_id = ?;', + [result.id, enterprise.id], + ); + + debug(`Finished synchronizing projects and enterprises.`); +} + +function getPeriodIdForDate(date) { + const month = date.getMonth() + 1; + const monthStr = month.toString().length === 1 + ? `0${month}` : `${month}`; + + const periodId = `${date.getFullYear()}${monthStr}`; + return periodId; +} + +function importODKSubmission(submission, user) { + // this is the depot targeted + + const transaction = db.transaction(); + + const date = new Date(submission.date); + const periodId = getPeriodIdForDate(date); + + const record = { + depot_uuid : db.bid(submission.depot_uuid), + entity_uuid : db.bid(submission.entity_uuid), + is_exit : 0, + flux_id : flux.FROM_OTHER_DEPOT, + document_uuid : db.bid(submission.document_uuid), + date, + description : submission.description, + user_id : user.id, + period_id : periodId, + }; + + for (const row of submission.barcode_repeat) { // eslint-disable-line + debug('processing:', JSON.stringify(row)); + const line = { ...record }; + line.uuid = db.bid(util.uuid()); + line.lot_uuid = db.bid(row.lot_uuid); + line.unit_cost = row.unit_cost; + line.quantity = 1; + + transaction.addQuery('INSERT INTO stock_movement SET ?', [line]); + } + + return transaction.execute(); + +} + +async function syncSubmissionsWithCentral(user) { + debug('Synchronizing submissions with ODK Central'); + + const integration = await db.exec('SELECT odk_project_id FROM odk_central_integration;'); + if (!integration.length) { + debug('No odk_project_id found! ODK Central integration not set up. Exiting early.'); + return; + } + + const odkProjectId = integration[0].odk_project_id; + const xmlFormId = 'bhima_pv_reception'; + + // const client = await central.auth.client(); + + // TODO(@jniles) - use the real ODK api for this. + // const searchParams = { $expand : '*', $count : true }; + // const submissions = + // await client.get(`projects/${odkProjectId}/forms/${xmlFormId}.svc/Submissions`, { searchParams }).json(); + + const submissions = await central.api.getSubmissionsJSONByProjectIdAndFormId(odkProjectId, xmlFormId); + + debug(`Got ${submissions.length} submission for ${xmlFormId}.`); + + // import the submissions + + for (const submission of submissions) { // eslint-disable-line + await importODKSubmission(submission, user); // eslint-disable-line + } + + debug(`Finished synchronizing submissions.`); +} + +/** + * @function syncFormsWithCentral + * + * @description + * This creates a stock exit form for each depot in the application. + * The strategy is to upload an XLSX form that + * + */ +async function syncFormsWithCentral() { + debug('Synchronizing forms with ODK Central'); + + const integration = await db.exec('SELECT odk_project_id FROM odk_central_integration;'); + if (!integration.length) { + debug('No odk_project_id found! ODK Central integration not set up. Exiting early.'); + return; + } + + const odkProjectId = integration[0].odk_project_id; + + const depots = await db.exec('SELECT buid(uuid) as uuid, depot.text FROM depot;'); + + debug(`Located ${depots.length} depots locally...`); + + const data = []; + + // Here, we pull out the current quantity in stock every depot, including lot numbers + // to create a lots.csv that will be uploaded to ODK Central to show inventory items as + // they are scanned. + + // eslint-disable-next-line + for (const depot of depots) { + debug(`Pulling lots for ${depot.text}`); + const lots = await core.getLotsDepot(depot.uuid, { // eslint-disable-line + month_average_consumption : 6, + average_consumption_algo : 'msh', + }); + + debug(`Found ${lots.length} lots.`); + + data.push(...lots + .map( + lot => _.pick(lot, [ + 'barcode', + 'uuid', 'lot_description', 'label', 'depot_text', 'depot_uuid', 'unit_cost', + 'text', 'unit_type', 'group_name', 'quantity', 'code', 'invenetory_uuid', + ]), + ), + ); + } + + // generate a CSV and store it in a temporary file so we can upload to ODK Central later + const lotsCsv = await json2csvAsync(data, { trimHeaderFields : true, trimFieldValues : true }); + const tmpLotsFile = tempy.file({ name : 'lots.csv' }); + await fs.writeFile(tmpLotsFile, lotsCsv); + + debug(`Wrote ${data.length} lots to temporary file: ${tmpLotsFile}`); + + // now we need to pull out the transfers out of depots to other depots + // this will power a selection menu in ODK Collect application. + + const toOtherDepotFluxId = 8; + + // get all stock exits to other depots + const allStockExitDocuments = await db.exec(` + SELECT BUID(document_uuid) as document_uuid, + dm.text AS documentReference, + BUID(depot_uuid) AS origin_depot_uuid, + BUID(entity_uuid) AS target_depot_uuid, + depot.text AS origin_depot_text, + dd.text AS target_depot_text, + CONCAT(dm.text, " (", DATE_FORMAT(MAX(date), "%Y-%m-%d"), ") - ", COUNT(*), " produits") AS label + FROM stock_movement + JOIN document_map dm ON stock_movement.document_uuid = dm.uuid + JOIN depot ON depot.uuid = stock_movement.depot_uuid + LEFT JOIN depot AS dd ON dd.uuid = stock_movement.entity_uuid + WHERE stock_movement.flux_id = ${toOtherDepotFluxId} + GROUP BY document_uuid; + `); + + const documentsCsv = await json2csvAsync(allStockExitDocuments, { trimHeaderFields : true, trimFieldValues : true }); + const tmpDocumentsFile = tempy.file({ name : 'transfers.csv' }); + await fs.writeFile(tmpDocumentsFile, documentsCsv); + debug(`Wrote ${allStockExitDocuments.length} transfer documents to temporary file: ${tmpDocumentsFile}`); + + debug(`Creating draft form for ODK`); + + const xlsxFormPath = path.join(__dirname, '../../../client/assets/pv-reception.xlsx'); + + debug('Uploading', xlsxFormPath, 'to ODK Central.'); + + const xmlFormId = 'bhima_pv_reception'; + + // first, check if this form exists, and clear the form if it exists + try { + const hasForm = await central.api.getFormByProjectIdAndFormId(odkProjectId, xmlFormId); + if (hasForm) { + debug('Found an existing form. Deleting it...'); + await central.api.forms.deleteForm(odkProjectId, xmlFormId); + } + } catch (e) { + // ignore + debug('No existing form found.'); + } + + // now we need to create a draft form on ODK Central + const draft = await central.api.forms.createFormFromXLSX(odkProjectId, xlsxFormPath, xmlFormId); + + debug(`Created draft form "${draft.name}" (id: ${draft.xmlFormId}, version: ${draft.version}). `); + + // let's add in the two attachments + let result = await central.api.forms.addAttachmentToDraftForm(odkProjectId, xmlFormId, tmpDocumentsFile); + debug(`Uploaded ${tmpDocumentsFile} with result success: ${result.success}`); + + result = await central.api.forms.addAttachmentToDraftForm(odkProjectId, xmlFormId, tmpLotsFile); + debug(`Uploaded ${tmpLotsFile} with result success: ${result.success}`); + + // add the IMA icon to the form + const srcIconFile = path.join(__dirname, '../../../client/assets/icon.png'); + result = await central.api.forms.addAttachmentToDraftForm(odkProjectId, xmlFormId, srcIconFile); + debug(`Uploaded ${srcIconFile} with result success: ${result.success}`); + + // now lets publish our draft + const published = await central.api.forms.publishDraftForm(odkProjectId, xmlFormId); + debug(`Published with result success: ${published.success}`); + + // now lets give all app-users access to this form + const allAppUsers = await central.api.users.listAllAppUsers(odkProjectId); + for (const user of allAppUsers) { // eslint-disable-line + debug(`Assigning "Data Collector" role (id:${odkCentralRoles.dataCollector}) to ${user.displayName}.`); + try { + await defineUserAsDataCollector(user.id); // eslint-disable-line + } catch (e) { + debug('User already defined.'); + } + } + +} + +/** + * + * + */ +router.get('/', async (req, res, next) => { + try { + const settings = await db.exec( + 'SELECT * FROM odk_central_integration WHERE enterprise_id = ?;', + [req.session.enterprise.id], + ); + res.status(200).json(settings); + } catch (e) { next(e); } +}); + +router.post('/', async (req, res, next) => { + const { enterprise } = req.session; + const odk = req.body; + + try { + await db.exec('DELETE FROM odk_central_integration WHERE enterprise_id = ?', [enterprise.id]); + await db.exec('INSERT INTO odk_central_integration SET ?;', [{ ...odk, enterprise_id : enterprise.id }]); + + loadODKCentralSettingsFromDatabase(); + + res.sendStatus(201); + } catch (e) { + next(e); + } +}); + +// add routes +router.post('/sync-users', async (req, res, next) => { + try { + await syncUsersWithCentral(); + res.sendStatus(201); + } catch (e) { next(e); } +}); + +router.post('/sync-submissions', async (req, res, next) => { + try { + await syncSubmissionsWithCentral(req.session.user); + res.sendStatus(201); + } catch (e) { next(e); } +}); + +router.post('/sync-app-users', async (req, res, next) => { + try { + await syncAppUsers(); + res.sendStatus(201); + } catch (e) { next(e); } +}); + +router.post('/sync-enterprise', async (req, res, next) => { + try { + await syncEnterpriseWithCentral(); + res.sendStatus(201); + } catch (e) { next(e); } +}); + +router.post('/sync-forms', async (req, res, next) => { + try { + await syncFormsWithCentral(); + res.sendStatus(201); + } catch (e) { next(e); } +}); + +// gets all mobile app users from central +router.get('/app-users', async (req, res, next) => { + try { + const config = await db.exec('SELECT odk_project_id FROM odk_central_integration;'); + const projectId = config.length && config[0].odk_project_id; + + // if no configuration, return an empty object + if (!projectId) { + res.status(200).json({}); + return; + } + + const appUsers = await central.api.users.listAllAppUsers(projectId); + res.status(200).json(appUsers); + + } catch (e) { + next(e); + } +}); + +// get user qr code +router.get('/app-users/:userId/qrcode', async (req, res, next) => { + try { + const { userId } = req.params; + + const config = await db.exec('SELECT odk_project_id, odk_central_url FROM odk_central_integration;'); + const projectId = config.length && config[0].odk_project_id; + const url = config.length && config[0].odk_central_url; + + const userDetails = await db.one( + 'SELECT odk_app_user_token AS token FROM odk_app_user WHERE bhima_user_id = ?', [userId], + ); + const { token } = userDetails; + + const data = await buildQRCode(url, token, projectId, req.session.enterprise.name); + res.status(200).send(data); + } catch (error) { + next(error); + } +}); + +// gets the project settings from central +router.get('/project-settings', async (req, res, next) => { + try { + const config = await db.exec('SELECT odk_project_id FROM odk_central_integration;'); + + const projectId = config.length && config[0].odk_project_id; + + // if no configuration, return an empty object + if (!projectId) { + res.status(200).json({}); + return; + } + + const project = await central.api.projects.getProjectById(projectId); + res.status(200).json(project); + } catch (e) { next(e); } +}); + +exports.router = router; +exports.loadODKCentralSettingsFromDatabase = loadODKCentralSettingsFromDatabase; diff --git a/server/models/admin.sql b/server/models/admin.sql index bc5721667b..4e5a485775 100644 --- a/server/models/admin.sql +++ b/server/models/admin.sql @@ -285,4 +285,14 @@ BEGIN GROUP BY gl.cost_center_id, gl.period_id, a.type_id; END $$ +/* + zRecomputeLotBarcodes() + + Recomputes the lot barcodes from the base data. +*/ +CREATE PROCEDURE zRecomputeLotBarcodes() +BEGIN + UPDATE lot SET barcode = CONCAT('LT', LEFT(HEX(lot.uuid), 8)); +END $$ + DELIMITER ; diff --git a/server/models/bhima.sql b/server/models/bhima.sql index 3070993fb9..a51238d372 100644 --- a/server/models/bhima.sql +++ b/server/models/bhima.sql @@ -129,7 +129,7 @@ INSERT INTO unit VALUES (245,'Debtor Summary Report','REPORT.DEBTOR_SUMMARY.TITLE','Debtor summary report',281,'/reports/debtor_summary'), (246,'Client Debts Report','TREE.CLIENT_DEBTS_REPORT','Client debts report',281,'/reports/client_debts'), (247,'Client Support Report','TREE.CLIENT_SUPPORT_REPORT','Client support report',281,'/reports/client_support'), - (248,'Analysis of Cashbox','REPORT.ANALYSIS_AUX_CASHBOX.TITLE','Analysis of auxiliary cashbox',281,'/reports/analysis_auxiliary_cashbox'), + (248,'Analysis of Cashboxes','REPORT.ANALYSIS_AUX_CASHBOXES.TITLE','Analysis of auxiliary cashboxes',281,'/reports/analysis_auxiliary_cashboxes'), (249,'Realized Profit Report','TREE.REALIZED_PROFIT_REPORT','Realized profit report / Collection on Invoicies',281,'/reports/realized_profit'), (250,'System Usage Statistics','REPORT.SYSTEM_USAGE_STAT.TITLE','System usage statistics',280,'/reports/system_usage_stat'), (251,'Indexes','TREE.INDEXES','The payroll index',57,'/PAYROLL_INDEX_FOLDER'), @@ -178,7 +178,8 @@ INSERT INTO unit VALUES (302, 'Cost Centers Accounts Report','TREE.COST_CENTER_ACCOUNTS_REPORT','Report of cc accounts values', 286,'/reports/cost_center_accounts'), (303, 'Cost Centers Balance Report','TREE.COST_CENTER_INCOME_EXPENSE_REPORT','Report of cc balance', 286,'/reports/cost_center_income_and_expense'), (304, '[SETTINGS] Settings', 'TREE.PAYROLL_SETTINGS', 'Payroll Settings', 57, '/payroll/setting'), - (305, 'Avg Medical Costs Per Patient', 'TREE.AVERAGE_MED_COST_REPORT', 'Report of avg med costs', 282, '/reports/avg_med_costs_per_patient'); + (305, 'Avg Medical Costs Per Patient', 'TREE.AVERAGE_MED_COST_REPORT', 'Report of avg med costs', 282, '/reports/avg_med_costs_per_patient'), + (306, '[SETTINGS] ODK Settings', 'TREE.ODK_SETTINGS', 'ODK Settings', 1, '/admin/odk-settings'); -- Reserved system account type INSERT INTO `account_category` VALUES @@ -234,7 +235,7 @@ INSERT INTO `report` (`report_key`, `title_key`) VALUES ('debtor_summary', 'REPORT.DEBTOR_SUMMARY.TITLE'), ('client_debts', 'REPORT.CLIENT_SUMMARY.TITLE'), ('client_support', 'REPORT.CLIENT_SUPPORT.TITLE'), - ('analysis_auxiliary_cashbox', 'REPORT.ANALYSIS_AUX_CASHBOX.TITLE'), + ('analysis_auxiliary_cashboxes', 'REPORT.ANALYSIS_AUX_CASHBOXES.TITLE'), ('realized_profit', 'REPORT.REALIZED_PROFIT.TITLE'), ('recovery_capacity', 'REPORT.RECOVERY_CAPACITY.TITLE'), ('system_usage_stat', 'REPORT.SYSTEM_USAGE_STAT.TITLE'), @@ -349,7 +350,6 @@ INSERT INTO `flux` VALUES (16, 'STOCK_FLUX.AGGREGATE_CONSUMPTION'); -- Roles Actions - INSERT INTO `actions`(`id`, `description`) VALUES (1, 'FORM.LABELS.CAN_EDIT_ROLES'), (2, 'FORM.LABELS.CAN_UNPOST_TRANSACTIONS'), diff --git a/server/models/migrations/next/migrate.sql b/server/models/migrations/next/migrate.sql index ea1e15e7e8..47340eb2ee 100644 --- a/server/models/migrations/next/migrate.sql +++ b/server/models/migrations/next/migrate.sql @@ -1,11 +1,35 @@ -/** - * @author: lomamech - * @description: update migrate.sql file for - * @date: 2022-01-24 -*/ -UPDATE report SET `report_key` = 'analysis_auxiliary_cashbox', `title_key` = 'REPORT.ANALYSIS_AUX_CASHBOX.TITLE' - WHERE report_key = 'analysis_auxiliary_cashboxes'; - -UPDATE unit SET - `name` = 'Analysis of Cashbox', `key` = 'REPORT.ANALYSIS_AUX_CASHBOX.TITLE', `description` = 'Analysis of auxiliary cashbox', `path` = '/reports/analysis_auxiliary_cashbox' - WHERE path = '/reports/analysis_auxiliary_cashboxes'; +/* migration file for next release */ + +DROP TABLE IF EXISTS `odk_central_integration`; +CREATE TABLE `odk_central_integration` ( + `enterprise_id` SMALLINT(5) UNSIGNED NOT NULL, + `odk_central_url` TEXT NOT NULL, + `odk_admin_user` TEXT NOT NULL, + `odk_admin_password` TEXT NOT NULL, + `odk_project_id` INTEGER UNSIGNED NULL, + KEY `enterprise_id` (`enterprise_id`), + CONSTRAINT `odk_central__enterprise` FOREIGN KEY (`enterprise_id`) REFERENCES `enterprise` (`id`) +) ENGINE=InnoDB DEFAULT CHARACTER SET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; + + +-- @jniles +DROP TABLE IF EXISTS `odk_user`; +CREATE TABLE `odk_user` ( + `odk_user_id` INT UNSIGNED NOT NULL, + `odk_user_password` TEXT NOT NULL, + `bhima_user_id` SMALLINT(5) UNSIGNED NOT NULL, + CONSTRAINT `odk_user__user` FOREIGN KEY (`bhima_user_id`) REFERENCES `user` (`id`) +) ENGINE=InnoDB DEFAULT CHARACTER SET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; + +-- @mbayopanda +DROP TABLE IF EXISTS `odk_app_user`; +CREATE TABLE `odk_app_user` ( + `odk_app_user_id` INT UNSIGNED NOT NULL, + `odk_app_user_token` TEXT NOT NULL, + `display_name` TEXT NOT NULL, + `bhima_user_id` SMALLINT(5) UNSIGNED NOT NULL, + CONSTRAINT `odk_app_user__user` FOREIGN KEY (`bhima_user_id`) REFERENCES `user` (`id`) +) ENGINE=InnoDB DEFAULT CHARACTER SET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; + +INSERT INTO unit VALUES + (306, '[SETTINGS] ODK Settings', 'TREE.ODK_SETTINGS', 'ODK Settings', 1, '/admin/odk-settings'); diff --git a/server/models/schema.sql b/server/models/schema.sql index 2a4dc6dd9b..447d4c331b 100644 --- a/server/models/schema.sql +++ b/server/models/schema.sql @@ -2633,4 +2633,35 @@ CREATE TABLE `cost_center_aggregate` ( CONSTRAINT `cost_center_aggregate__cost_center_id` FOREIGN KEY (`cost_center_id`) REFERENCES `cost_center` (`id`) ) ENGINE=InnoDB DEFAULT CHARACTER SET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; +DROP TABLE IF EXISTS `odk_central_integration`; +CREATE TABLE `odk_central_integration` ( + `enterprise_id` SMALLINT(5) UNSIGNED NOT NULL, + `odk_central_url` TEXT NOT NULL, + `odk_admin_user` TEXT NOT NULL, + `odk_admin_password` TEXT NOT NULL, + `odk_project_id` INTEGER UNSIGNED NULL, + KEY `enterprise_id` (`enterprise_id`), + CONSTRAINT `odk_central__enterprise` FOREIGN KEY (`enterprise_id`) REFERENCES `enterprise` (`id`) +) ENGINE=InnoDB DEFAULT CHARACTER SET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; + + +-- @jniles +DROP TABLE IF EXISTS `odk_user`; +CREATE TABLE `odk_user` ( + `odk_user_id` INT UNSIGNED NOT NULL, + `odk_user_password` TEXT NOT NULL, + `bhima_user_id` SMALLINT(5) UNSIGNED NOT NULL, + CONSTRAINT `odk_user__user` FOREIGN KEY (`bhima_user_id`) REFERENCES `user` (`id`) +) ENGINE=InnoDB DEFAULT CHARACTER SET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; + +-- @mbayopanda +DROP TABLE IF EXISTS `odk_app_user`; +CREATE TABLE `odk_app_user` ( + `odk_app_user_id` INT UNSIGNED NOT NULL, + `odk_app_user_token` TEXT NOT NULL, + `display_name` TEXT NOT NULL, + `bhima_user_id` SMALLINT(5) UNSIGNED NOT NULL, + CONSTRAINT `odk_app_user__user` FOREIGN KEY (`bhima_user_id`) REFERENCES `user` (`id`) +) ENGINE=InnoDB DEFAULT CHARACTER SET = utf8mb4 DEFAULT COLLATE = utf8mb4_unicode_ci; + SET foreign_key_checks = 1;