/* eslint-disable no-constant-condition, no-return-assign, no-undef, standard/no-callback-literal, */ // TODO: This file was created by bulk-decaffeinate. // Fix any style issues and re-enable lint. /* * decaffeinate suggestions: * DS101: Remove unnecessary use of Array.from * DS102: Remove unnecessary code created because of implicit returns * DS205: Consider reworking code to avoid use of IIFEs * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ 'use strict'; Application.Controllers.controller('StatisticsController', ['$scope', '$state', '$rootScope', '$uibModal', 'es', 'Member', '_t', 'membersPromise', 'statisticsPromise', function ($scope, $state, $rootScope, $uibModal, es, Member, _t, membersPromise, statisticsPromise) { /* PRIVATE STATIC CONSTANTS */ // search window size const RESULTS_PER_PAGE = 20; // keep search context for (delay in minutes) ... const ES_SCROLL_TIME = 1; /* PUBLIC SCOPE */ // ui-view transitions optimization: if true, the stats will never be refreshed $scope.preventRefresh = false; // statistics structure in elasticSearch $scope.statistics = statisticsPromise; // fablab users list $scope.members = membersPromise; // statistics data recovered from elasticSearch $scope.data = null; // when did the search was triggered $scope.searchDate = null; // id of the elastic search context $scope.scrollId = null; // total number of results for the current query $scope.totalHits = null; // configuration of the widget allowing to pick the ages range $scope.agePicker = { show: false, start: null, end: null }; // total CA for the current view $scope.sumCA = 0; // average users' age for the current view $scope.averageAge = 0; // total of the stat field for non simple types $scope.sumStat = 0; // Results of custom aggregations for the current type $scope.customAggs = {}; // default: results are not sorted $scope.sorting = { ca: 'none', date: 'desc' }; // active tab will be set here $scope.selectedIndex = null; // type filter binding $scope.type = { selected: null, active: null }; // selected custom filter $scope.customFilter = { show: false, criterion: {}, value: null, exclude: false, datePicker: { format: Fablab.uibDateFormat, opened: false, // default: datePicker is not shown minDate: null, maxDate: moment().toDate(), options: { startingDay: Fablab.weekStartingDay } } }; // available custom filters $scope.filters = []; // default: we do not open the datepicker menu $scope.datePicker = { show: false }; // datePicker parameters for interval beginning $scope.datePickerStart = { format: Fablab.uibDateFormat, opened: false, // default: datePicker is not shown minDate: null, maxDate: moment().subtract(1, 'day').toDate(), selected: moment().utc().subtract(1, 'months').subtract(1, 'day').startOf('day').toDate(), options: { startingDay: Fablab.weekStartingDay } }; // datePicker parameters for interval ending $scope.datePickerEnd = { format: Fablab.uibDateFormat, opened: false, // default: datePicker is not shown minDate: null, maxDate: moment().subtract(1, 'day').toDate(), selected: moment().subtract(1, 'day').endOf('day').toDate(), options: { startingDay: Fablab.weekStartingDay } }; /** * Return a localized name for the given field */ $scope.customFieldName = function (field) { return _t(`app.admin.statistics.${field}`); } /** * Callback to open the datepicker (interval start) * @param $event {Object} jQuery event object */ $scope.toggleStartDatePicker = function ($event) { toggleDatePicker($event, $scope.datePickerStart); }; /** * Callback to open the datepicker (interval end) * @param $event {Object} jQuery event object */ $scope.toggleEndDatePicker = function ($event) { toggleDatePicker($event, $scope.datePickerEnd); }; /** * Callback to open the datepicker (custom filter) * @param $event {Object} jQuery event object */ $scope.toggleCustomDatePicker = function ($event) { toggleDatePicker($event, $scope.customFilter.datePicker); }; /** * Callback called when the active tab is changed. * recover the current tab and store its value in $scope.selectedIndex * @param tab {Object} elasticsearch statistic structure (from statistic_indices table) */ $scope.setActiveTab = function (tab) { $scope.selectedIndex = tab; $scope.type.selected = tab.types[0]; $scope.type.active = $scope.type.selected; $scope.customFilter.criterion = {}; $scope.customFilter.value = null; $scope.customFilter.exclude = false; $scope.sorting.ca = 'none'; $scope.sorting.date = 'desc'; buildCustomFiltersList(); return refreshStats(); }; /** * Returns true if the provided tab must be hidden due to some global or local configuration * @param tab {Object} elasticsearch statistic structure (from statistic_indices table) */ $scope.hiddenTab = function (tab) { if (tab.table) { if ((tab.es_type_key === 'subscription') && $rootScope.fablabWithoutPlans) { return true; } else if ((tab.es_type_key === 'space') && $rootScope.fablabWithoutSpaces) { return true; } else { return false; } } else { return true; } }; /** * Callback to validate the filters and send a new request to elastic */ $scope.validateFilterChange = function () { $scope.agePicker.show = false; $scope.customFilter.show = false; $scope.type.active = $scope.type.selected; buildCustomFiltersList(); return refreshStats(); }; /** * Callback to validate the dates range and refresh the data from elastic */ $scope.validateDateChange = function () { $scope.datePicker.show = false; return refreshStats(); }; /** * Parse the given date and return a user-friendly string * @param date {Date} JS date or ant moment.js compatible date string */ $scope.formatDate = function (date) { return moment(date).format('LL'); }; /** * Parse the sex and return a user-friendly string * @param sex {string} 'male' | 'female' */ $scope.formatSex = function (sex) { if (sex === 'male') { return _t('app.admin.statistics.man'); } if (sex === 'female') { return _t('app.admin.statistics.woman'); } }; /** * Retrieve the label for the given subtype in the current type * @param key {string} statistic subtype key */ $scope.formatSubtype = function (key) { let label = ''; angular.forEach($scope.type.active.subtypes, function (subtype) { if (subtype.key === key) { return label = subtype.label; } }); return label; }; /** * Helper usable in ng-switch to determine the input type to display for custom filter value * @param filter {Object} custom filter criterion */ $scope.getCustomValueInputType = function (filter) { if (filter && filter.values) { if (typeof (filter.values[0]) === 'string') { return filter.values[0]; } else if (typeof (filter.values[0] === 'object')) { return 'input_select'; } } else { return 'input_text'; } }; /** * Change the sorting order and refresh the results to match the new order * @param filter {Object} any filter */ $scope.toggleSorting = function (filter) { switch ($scope.sorting[filter]) { case 'none': $scope.sorting[filter] = 'asc'; break; case 'asc': $scope.sorting[filter] = 'desc'; break; case 'desc': $scope.sorting[filter] = 'none'; break; } return refreshStats(); }; /** * Return the user's name from his given ID * @param id {number} user ID */ $scope.getUserNameFromId = function (id) { const name = $scope.members[id]; return (name || `ID ${id}`); }; /** * Run a scroll query to elasticsearch to append the next packet of results to those displayed. * If the ES search context has expired when the user ask for more results, we re-run the whole query. */ $scope.showMoreResults = function () { // if all results were retrieved, do nothing if ($scope.data.length >= $scope.totalHits) { return; } if (moment($scope.searchDate).add(ES_SCROLL_TIME, 'minutes').isBefore(moment())) { // elastic search context has expired, so we run again the whole query return refreshStats(); } else { return es.scroll({ 'scroll': ES_SCROLL_TIME + 'm', 'body': { scrollId: $scope.scrollId } } , function (error, response) { if (error) { return console.error(`Error: something unexpected occurred during elasticSearch scroll query: ${error}`); } else { $scope.scrollId = response._scroll_id; return $scope.data = $scope.data.concat(response.hits.hits); } }); } }; /** * Open a modal dialog asking the user for details about exporting the statistics tables to an excel file */ $scope.exportToExcel = function () { const options = { templateUrl: '<%= asset_path "admin/statistics/export.html" %>', size: 'sm', controller: 'ExportStatisticsController', resolve: { dates () { return { start: $scope.datePickerStart.selected, end: $scope.datePickerEnd.selected }; }, query () { const custom = buildCustomFilterQuery(); return buildElasticDataQuery($scope.type.active.key, custom, $scope.agePicker.start, $scope.agePicker.end, moment($scope.datePickerStart.selected), moment($scope.datePickerEnd.selected), $scope.sorting); }, index () { return { key: $scope.selectedIndex.es_type_key }; }, type () { return { key: $scope.type.active.key }; } } }; return $uibModal.open(options) .result['finally'](null).then(function (info) { console.log(info); }); }; /* PRIVATE SCOPE */ /** * Kind of constructor: these actions will be realized first when the controller is loaded */ const initialize = function () { // workaround for angular-bootstrap::tabs behavior: on tab deletion, another tab will be selected // which will cause every tabs to reload, one by one, when the view is closed $rootScope.$on('$stateChangeStart', function (event, toState, toParams, fromState, fromParams) { if ((fromState.name === 'app.admin.statistics') && (Object.keys(fromParams).length === 0)) { return $scope.preventRefresh = true; } }); }; /** * Generic function to toggle a bootstrap datePicker * @param $event {Object} jQuery event object * @param datePicker {Object} settings object of the concerned datepicker. Must have an 'opened' property */ var toggleDatePicker = function ($event, datePicker) { $event.preventDefault(); $event.stopPropagation(); return datePicker.opened = !datePicker.opened; }; /** * Force update the statistics table, querying elasticSearch according to the current config values */ var refreshStats = function () { if ($scope.selectedIndex && !$scope.preventRefresh) { $scope.data = []; $scope.sumCA = 0; $scope.averageAge = 0; $scope.sumStat = 0; $scope.customAggs = {}; $scope.totalHits = null; $scope.searchDate = new Date(); let custom = buildCustomFilterQuery(); return queryElasticStats($scope.selectedIndex.es_type_key, $scope.type.active.key, custom, function (res, err) { if (err) { return console.error(`[statisticsController::refreshStats] Unable to refresh due to ${err}`); } else { $scope.data = res.hits.hits; $scope.totalHits = res.hits.total; $scope.sumCA = res.aggregations.total_ca.value; $scope.averageAge = Math.round(res.aggregations.average_age.value * 100) / 100; $scope.sumStat = res.aggregations.total_stat.value; $scope.scrollId = res._scroll_id; return (function () { const result = []; for (custom of Array.from($scope.type.active.custom_aggregations)) { result.push($scope.customAggs[custom.field] = res.aggregations[custom.field].value); } return result; })(); } }); } }; /** * Run the elasticSearch query to retrieve the /stats/type aggregations * @param index {String} elasticSearch document type (account|event|machine|project|subscription|training) * @param type {String} statistics type (month|year|booking|hour|user|project) * @param custom {{key:{string}, value:{string}}|null} custom filter property or null to disable this filter * @param callback {function} function be to run after results were retrieved, it will receive * two parameters : results {Object}, error {String} (if any) */ var queryElasticStats = function (index, type, custom, callback) { // handle invalid callback if (typeof (callback) !== 'function') { console.error('[statisticsController::queryElasticStats] Error: invalid callback provided'); return; } // run query return es.search({ 'index': 'stats', 'type': index, 'size': RESULTS_PER_PAGE, 'scroll': ES_SCROLL_TIME + 'm', 'stat-type': type, 'custom-query': custom ? JSON.stringify(Object.assign({ exclude: custom.exclude }, buildElasticCustomCriterion(custom))) : '', 'start-date': moment($scope.datePickerStart.selected).format(), 'end-date': moment($scope.datePickerEnd.selected).format(), 'body': buildElasticDataQuery(type, custom, $scope.agePicker.start, $scope.agePicker.end, moment($scope.datePickerStart.selected), moment($scope.datePickerEnd.selected), $scope.sorting) } , function (error, response) { if (error) { return callback({}, `Error: something unexpected occurred during elasticSearch query: ${error}`); } else { return callback(response); } }); }; /** * Build an object representing the content of the REST-JSON query to elasticSearch, * based on the provided parameters for row data recovering. * @param type {String} statistics type (month|year|booking|hour|user|project) * @param custom {{key:{string}, value:{string}}|null} custom filter property or null to disable this filter * @param ageMin {Number|null} filter by age: range lower value OR null to do not filter * @param ageMax {Number|null} filter by age: range higher value OR null to do not filter * @param intervalBegin {moment} statitics interval beginning (moment.js type) * @param intervalEnd {moment} statitics interval ending (moment.js type) * @param sortings {Array|null} elasticSearch criteria for sorting the results */ var buildElasticDataQuery = function (type, custom, ageMin, ageMax, intervalBegin, intervalEnd, sortings) { const q = { 'query': { 'bool': { 'must': [ { 'term': { 'type': type } }, { 'range': { 'date': { 'gte': intervalBegin.format(), 'lte': intervalEnd.format() } } } ] } } }; // optional date range if ((typeof ageMin === 'number') && (typeof ageMax === 'number')) { q.query.bool.must.push({ 'range': { 'age': { 'gte': ageMin, 'lte': ageMax } } }); } // optional criterion if (custom) { const criterion = buildElasticCustomCriterion(custom); if (custom.exclude) { q.query.bool.must_not = [ { 'term': criterion.match } ]; } else { q.query.bool.must.push(criterion); } } if (sortings) { q['sort'] = buildElasticSortCriteria(sortings); } // aggregations (avg age & CA sum) q['aggs'] = { 'total_ca': { 'sum': { 'field': 'ca' } }, 'average_age': { 'avg': { 'field': 'age' } }, 'total_stat': { 'sum': { 'field': 'stat' } } }; return q; }; /** * Build the elasticSearch query DSL to match the selected cutom filter * @param custom {Object} if custom is empty or undefined, an empty string will be returned * @returns {{match:*}|string} */ var buildElasticCustomCriterion = function (custom) { if (custom) { const criterion = { 'match': {} }; switch ($scope.getCustomValueInputType($scope.customFilter.criterion)) { case 'input_date': criterion.match[custom.key] = moment(custom.value).format('YYYY-MM-DD'); break; case 'input_select': criterion.match[custom.key] = custom.value.key; break; case 'input_list': criterion.match[custom.key + '.name'] = custom.value; break; default: criterion.match[custom.key] = custom.value; } return criterion; } else { return ''; } }; /** * Parse the provided criteria array and return the corresponding elasticSearch syntax * @param criteria {Array} array of {key_to_sort:order} */ var buildElasticSortCriteria = function (criteria) { const crits = []; angular.forEach(criteria, function (value, key) { if ((typeof value !== 'undefined') && (value !== null) && (value !== 'none')) { const c = {}; c[key] = { 'order': value }; return crits.push(c); } }); return crits; }; /** * Fulfill the list of available options in the custom filter panel. The list will be based on common * properties and on index-specific properties (additional_fields) */ var buildCustomFiltersList = function () { $scope.filters = [ { key: 'date', label: _t('app.admin.statistics.date'), values: ['input_date'] }, { key: 'userId', label: _t('app.admin.statistics.user_id'), values: ['input_number'] }, { key: 'gender', label: _t('app.admin.statistics.gender'), values: [{ key: 'male', label: _t('app.admin.statistics.man') }, { key: 'female', label: _t('app.admin.statistics.woman') }] }, { key: 'age', label: _t('app.admin.statistics.age'), values: ['input_number'] }, { key: 'subType', label: _t('app.admin.statistics.type'), values: $scope.type.active.subtypes }, { key: 'ca', label: _t('app.admin.statistics.revenue'), values: ['input_number'] } ]; if (!$scope.type.active.simple) { const f = { key: 'stat', label: $scope.type.active.label, values: ['input_number'] }; $scope.filters.push(f); } return angular.forEach($scope.selectedIndex.additional_fields, function (field) { const filter = { key: field.key, label: field.label, values: [] }; switch (field.data_type) { case 'index': filter.values.push('input_number'); break; case 'number': filter.values.push('input_number'); break; case 'date': filter.values.push('input_date'); break; case 'list': filter.values.push('input_list'); break; default: filter.values.push('input_text'); } return $scope.filters.push(filter); }); }; /** * Build and return an object according to the custom filter set by the user, used to request elasticsearch * @return {Object|null} */ var buildCustomFilterQuery = function () { let custom = null; if (!angular.isUndefinedOrNull($scope.customFilter.criterion) && !angular.isUndefinedOrNull($scope.customFilter.criterion.key) && !angular.isUndefinedOrNull($scope.customFilter.value)) { custom = {}; custom.key = $scope.customFilter.criterion.key; custom.value = $scope.customFilter.value; custom.exclude = $scope.customFilter.exclude; } return custom; }; // init the controller (call at the end !) return initialize(); } ]); Application.Controllers.controller('ExportStatisticsController', [ '$scope', '$uibModalInstance', 'Export', 'dates', 'query', 'index', 'type', 'CSRF', 'growl', '_t', function ($scope, $uibModalInstance, Export, dates, query, index, type, CSRF, growl, _t) { // Retrieve Anti-CSRF tokens from cookies CSRF.setMetaTags(); // Bindings for date range $scope.dates = dates; // Body of the query to export $scope.query = JSON.stringify(query); // API URL where the form will be posted $scope.actionUrl = `/stats/${index.key}/export`; // Key of the current search' statistic type $scope.typeKey = type.key; // Form action on the above URL $scope.method = 'post'; // Anti-CSRF token to inject into the download form $scope.csrfToken = angular.element('meta[name="csrf-token"]')[0].content; // Binding of the export type (global / current) $scope.export = { type: 'current' }; // datePicker parameters for interval beginning $scope.exportStart = { format: Fablab.uibDateFormat, opened: false, // default: datePicker is not shown minDate: null, maxDate: moment().subtract(1, 'day').toDate(), options: { startingDay: Fablab.weekStartingDay } }; // datePicker parameters for interval ending $scope.exportEnd = { format: Fablab.uibDateFormat, opened: false, // default: datePicker is not shown minDate: null, maxDate: moment().subtract(1, 'day').toDate(), options: { startingDay: Fablab.weekStartingDay } }; /** * Callback to open the datepicker (interval start) * @param $event {Object} jQuery event object */ $scope.toggleStartDatePicker = function ($event) { $scope.exportStart.opened = !$scope.exportStart.opened; }; /** * Callback to open the datepicker (interval end) * @param $event {Object} jQuery event object */ $scope.toggleEndDatePicker = function ($event) { $scope.exportEnd.opened = !$scope.exportEnd.opened; }; /** * Callback when exchanging the export type between 'global' and 'current view' * Adjust the query and the requesting url according to this type. */ $scope.setRequest = function () { if ($scope.export.type === 'global') { $scope.actionUrl = '/stats/global/export'; return $scope.query = JSON.stringify({ 'query': { 'bool': { 'must': [ { 'range': { 'date': { 'gte': moment($scope.dates.start).format(), 'lte': moment($scope.dates.end).format() } } } ] } } }); } else { $scope.actionUrl = `/stats/${index.key}/export`; $scope.query = JSON.stringify(query); } }; /** * Callback to close the modal, telling the caller what is exported */ $scope.exportData = function () { const statusQry = { category: 'statistics', type: $scope.export.type, query: $scope.query }; if ($scope.export.type !== 'global') { statusQry['type'] = index.key; statusQry['key'] = type.key; } Export.status(statusQry).then(function (res) { if (!res.data.exists) { return growl.success(_t('app.admin.statistics.export_is_running_you_ll_be_notified_when_its_ready')); } }); return $uibModalInstance.close(statusQry); }; /** * Callback to cancel the export and close the modal */ $scope.cancel = function () { $uibModalInstance.dismiss('cancel'); }; } ]);