'use strict' Application.Controllers.controller "StatisticsController", ["$scope", "$state", "$rootScope", '$uibModal', "Export", "es", "Member", '_t', 'membersPromise', 'statisticsPromise' , ($scope, $state, $rootScope, $uibModal, Export, es, Member, _t, membersPromise, statisticsPromise) -> ### PRIVATE STATIC CONSTANTS ### ## search window size RESULTS_PER_PAGE = 20 ## keep search context for (delay in minutes) ... 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 ## 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 = ($event) -> toggleDatePicker($event, $scope.datePickerStart) ## # Callback to open the datepicker (interval end) # @param $event {Object} jQuery event object ## $scope.toggleEndDatePicker = ($event) -> toggleDatePicker($event, $scope.datePickerEnd) ## # Callback to open the datepicker (custom filter) # @param $event {Object} jQuery event object ## $scope.toggleCustomDatePicker = ($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 ## $scope.setActiveTab = (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() refreshStats() ## # Callback to validate the filters and send a new request to elastic ## $scope.validateFilterChange = -> $scope.agePicker.show = false $scope.customFilter.show = false $scope.type.active = $scope.type.selected buildCustomFiltersList() refreshStats() ## # Callback to validate the dates range and refresh the data from elastic ## $scope.validateDateChange = -> $scope.datePicker.show = false 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 = (date) -> moment(date).format("LL") ## # Parse the sex and return a user-friendly string # @param sex {string} 'male' | 'female' ## $scope.formatSex = (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 = (key) -> label = "" angular.forEach $scope.type.active.subtypes, (subtype) -> if subtype.key == key label = subtype.label 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 = (filter) -> if filter and filter.values if typeof(filter.values[0]) == 'string' return filter.values[0] else if typeof(filter.values[0] == 'object') return 'input_select' else 'input_text' ## # Change the sorting order and refresh the results to match the new order # @param filter {Object} any filter ## $scope.toggleSorting = (filter) -> switch $scope.sorting[filter] when 'none' then $scope.sorting[filter] = 'asc' when 'asc' then $scope.sorting[filter] = 'desc' when 'desc' then $scope.sorting[filter] = 'none' refreshStats() ## # Return the user's name from his given ID # @param id {number} user ID ## $scope.getUserNameFromId = (id) -> name = $scope.members[id] return (if name then name else "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 = -> # 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 refreshStats() else es.scroll "scroll": ES_SCROLL_TIME+'m' "body": {scrollId: $scope.scrollId} , (error, response) -> if (error) console.error "Error: something unexpected occurred during elasticSearch scroll query: "+error else $scope.scrollId = response._scroll_id $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 = -> options = templateUrl: '<%= asset_path "admin/statistics/export.html" %>' size: 'sm' controller: 'ExportStatisticsController' resolve: dates: -> start: $scope.datePickerStart.selected end: $scope.datePickerEnd.selected query: -> custom = buildCustomFilterQuery() buildElasticDataQuery($scope.type.active.key, custom, $scope.agePicker.start, $scope.agePicker.end, moment($scope.datePickerStart.selected), moment($scope.datePickerEnd.selected), $scope.sorting) index: -> key: $scope.selectedIndex.es_type_key type: -> key: $scope.type.active.key $uibModal.open options .result['finally'](null).then (info)-> console.log(info) ### PRIVATE SCOPE ### ## # Kind of constructor: these actions will be realized first when the controller is loaded ## initialize = -> # 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', (event, toState, toParams, fromState, fromParams) -> if fromState.name == 'app.admin.statistics' and Object.keys(fromParams).length == 0 $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 ## toggleDatePicker = ($event, datePicker) -> $event.preventDefault() $event.stopPropagation() datePicker.opened = !datePicker.opened ## # Force update the statistics table, querying elasticSearch according to the current config values ## refreshStats = -> if $scope.selectedIndex and !$scope.preventRefresh $scope.data = [] $scope.sumCA = 0 $scope.averageAge = 0 $scope.sumStat = 0 $scope.totalHits = null $scope.searchDate = new Date() custom = buildCustomFilterQuery() queryElasticStats $scope.selectedIndex.es_type_key, $scope.type.active.key, custom, (res, err)-> if (err) 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 ## # 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) ## queryElasticStats = (index, type, custom, callback) -> # handle invalid callback if typeof(callback) != "function" console.error('[statisticsController::queryElasticStats] Error: invalid callback provided') return # run query es.search "index": "stats" "type": index "size": RESULTS_PER_PAGE "scroll": ES_SCROLL_TIME+'m' "body": buildElasticDataQuery(type, custom, $scope.agePicker.start, $scope.agePicker.end, moment($scope.datePickerStart.selected), moment($scope.datePickerEnd.selected), $scope.sorting) , (error, response) -> if (error) callback({}, "Error: something unexpected occurred during elasticSearch query: "+error) else 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 ## buildElasticDataQuery = (type, custom, ageMin, ageMax, intervalBegin, intervalEnd, sortings) -> q = "query": "bool": "must": [ { "term": "type": type } { "range": "date": "gte": intervalBegin.format() "lte": intervalEnd.format() } ] # optional date range if ageMin && ageMax q.query.bool.must.push "range": "age": "gte": ageMin "lte": ageMax # optional criterion if custom criterion = { "match" : {} } switch $scope.getCustomValueInputType($scope.customFilter.criterion) when 'input_date' then criterion.match[custom.key] = moment(custom.value).format('YYYY-MM-DD') when 'input_select' then criterion.match[custom.key] = custom.value.key when 'input_list' then criterion.match[custom.key+".name"] = custom.value else criterion.match[custom.key] = custom.value if (custom.exclude) q = "query": { "filtered": { "query": q.query, "filter": { "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" } q ## # Parse the provided criteria array and return the corresponding elasticSearch syntax # @param criteria {Array} array of {key_to_sort:order} ## buildElasticSortCriteria = (criteria) -> crits = [] angular.forEach criteria, (value, key) -> if typeof value != 'undefined' and value != null and value != 'none' c = {} c[key] = {'order': value} crits.push(c) 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) ## buildCustomFiltersList = -> $scope.filters = [] 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']} ] $scope.filters = filters if !$scope.type.active.simple f = {key: 'stat', label: $scope.type.active.label, values: ['input_number']} $scope.filters.push(f) angular.forEach $scope.selectedIndex.additional_fields, (field) -> filter = {key: field.key, label: field.label, values:[]} switch field.data_type when 'index' then filter.values.push('input_number') when 'number' then filter.values.push('input_number') when 'date' then filter.values.push('input_date') when 'list' then filter.values.push('input_list') else filter.values.push('input_text') $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} ## buildCustomFilterQuery = -> custom = null if !angular.isUndefinedOrNull($scope.customFilter.criterion) and !angular.isUndefinedOrNull($scope.customFilter.criterion.key) and !angular.isUndefinedOrNull($scope.customFilter.value) custom = {} custom.key = $scope.customFilter.criterion.key custom.value = $scope.customFilter.value custom.exclude = $scope.customFilter.exclude custom # init the controller (call at the end !) initialize() ] Application.Controllers.controller 'ExportStatisticsController', [ '$scope', '$uibModalInstance', 'dates', 'query', 'index', 'type', 'CSRF' , ($scope, $uibModalInstance, dates, query, index, type, CSRF) -> ## 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 = ($event) -> $scope.exportStart.opened = !$scope.exportStart.opened ## # Callback to open the datepicker (interval end) # @param $event {Object} jQuery event object ## $scope.toggleEndDatePicker = ($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 = -> if $scope.export.type == 'global' $scope.actionUrl = '/stats/global/export' $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 = -> info = type: $scope.export.type if info.type == 'global' info.dates = $scope.dates $uibModalInstance.close(info) ## # Callback to cancel the export and close the modal ## $scope.cancel = -> $uibModalInstance.dismiss('cancel') ]