mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2024-12-05 16:24:21 +01:00
661 lines
19 KiB
Plaintext
661 lines
19 KiB
Plaintext
'use strict'
|
|
|
|
|
|
|
|
Application.Controllers.controller "StatisticsController", ["$scope", "$state", "$rootScope", '$uibModal', "es", "Member", '_t', 'membersPromise', 'statisticsPromise'
|
|
, ($scope, $state, $rootScope, $uibModal, 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
|
|
|
|
## 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 = ($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.customAggs = {}
|
|
$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
|
|
for custom in $scope.type.active.custom_aggregations
|
|
$scope.customAggs[custom.field] = res.aggregations[custom.field].value
|
|
|
|
|
|
|
|
##
|
|
# 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'
|
|
"stat-type": type
|
|
"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)
|
|
, (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', 'Export','dates', 'query', 'index', 'type', 'CSRF', 'growl', '_t'
|
|
, ($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 = ($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 = ->
|
|
statusQry = {category: 'statistics', type: $scope.export.type, query: $scope.query}
|
|
unless $scope.export.type == 'global'
|
|
statusQry['type'] = index.key
|
|
statusQry['key'] = type.key
|
|
|
|
Export.status(statusQry).then (res) ->
|
|
unless (res.data.exists)
|
|
growl.success _t('export_is_running_you_ll_be_notified_when_its_ready')
|
|
|
|
$uibModalInstance.close(statusQry)
|
|
|
|
|
|
|
|
##
|
|
# Callback to cancel the export and close the modal
|
|
##
|
|
$scope.cancel = ->
|
|
$uibModalInstance.dismiss('cancel')
|
|
]
|