/* 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: 1 } } // France: the week starts on monday } // 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 } } /** * 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('man') } if (sex === 'female') { return _t('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 retreive 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 } /** * Fullfil 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('date'), values: ['input_date'] }, { key: 'userId', label: _t('user_id'), values: ['input_number'] }, { key: 'gender', label: _t('gender'), values: [{ key: 'male', label: _t('man') }, { key: 'female', label: _t('woman') }] }, { key: 'age', label: _t('age'), values: ['input_number'] }, { key: 'subType', label: _t('type'), values: $scope.type.active.subtypes }, { key: 'ca', label: _t('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('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') } } ])