diff --git a/CHANGELOG.md b/CHANGELOG.md index 44759d180..702ebf791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,12 @@ ## next release - Mask new notifications alerts when more than 3 - Added an asterisk on group select in admin's member form +- Statistics custom aggregations can handle custom filtering +- Statistics about hours available for machine reservations and tickets available for training reservations, now handle custom filtering on date and type - Fix a bug: display more than 15 unread notifications (number on the bell icon & full list) - Fix a bug: in invoice configuration panel, VAT amount and total excl. taxes are inverted - Fix a bug: unable to compute user's age when they were born on february 29th and current year is not a leap year -- Fix a bug: wrong statistics about hours available for reservation. Fix requires user action (1) +- Fix a bug: wrong statistics about hours available for machines reservation. Fix requires user action (1) - [TODO DEPLOY] remove possible value `application/` in `ALLOWED_MIME_TYPES` list, in environment variable - [TODO DEPLOY] `rails runner StatisticCustomAggregation.destroy_all`, then `rake db:seed`, then `rake fablab:es_build_availabilities_index` (1) diff --git a/app/assets/javascripts/controllers/admin/graphs.coffee b/app/assets/javascripts/controllers/admin/graphs.coffee index 8c23e69c7..6100e20e8 100644 --- a/app/assets/javascripts/controllers/admin/graphs.coffee +++ b/app/assets/javascripts/controllers/admin/graphs.coffee @@ -363,6 +363,7 @@ Application.Controllers.controller "GraphsController", ["$scope", "$state", "$ro "type": esType "searchType": "count" "stat-type": statType + "custom-query": '' "start-date": moment($scope.datePickerStart.selected).format() "end-date": moment($scope.datePickerEnd.selected).format() "body": buildElasticAggregationsQuery(statType, $scope.display.interval, moment($scope.datePickerStart.selected), moment($scope.datePickerEnd.selected)) diff --git a/app/assets/javascripts/controllers/admin/statistics.coffee.erb b/app/assets/javascripts/controllers/admin/statistics.coffee.erb index 53d289217..b99297b21 100644 --- a/app/assets/javascripts/controllers/admin/statistics.coffee.erb +++ b/app/assets/javascripts/controllers/admin/statistics.coffee.erb @@ -380,6 +380,7 @@ Application.Controllers.controller "StatisticsController", ["$scope", "$state", "size": RESULTS_PER_PAGE "scroll": ES_SCROLL_TIME+'m' "stat-type": type + "custom-query": if custom then JSON.stringify(Object.assign({exclude: custom.exclude}, buildElasticCustomCriterion(custom))) else '' "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) @@ -427,15 +428,7 @@ Application.Controllers.controller "StatisticsController", ["$scope", "$state", "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 - + criterion = buildElasticCustomCriterion(custom) if (custom.exclude) q = "query": { "filtered": { @@ -470,6 +463,27 @@ Application.Controllers.controller "StatisticsController", ["$scope", "$state", + ## + # 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} + ## + buildElasticCustomCriterion = (custom) -> + 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 + criterion + else + '' + + + ## # Parse the provided criteria array and return the corresponding elasticSearch syntax # @param criteria {Array} array of {key_to_sort:order} diff --git a/app/controllers/api/statistics_controller.rb b/app/controllers/api/statistics_controller.rb index 356ab07ae..e82603202 100644 --- a/app/controllers/api/statistics_controller.rb +++ b/app/controllers/api/statistics_controller.rb @@ -13,6 +13,7 @@ class API::StatisticsController < API::ApiController # remove additional parameters statistic_type = request.query_parameters.delete('stat-type') + custom_query = request.query_parameters.delete('custom-query') start_date = request.query_parameters.delete('start-date') end_date = request.query_parameters.delete('end-date') @@ -21,15 +22,7 @@ class API::StatisticsController < API::ApiController results = Stats::#{path.classify}.search(query, request.query_parameters.symbolize_keys).response # run additional custom aggregations, if any - if statistic_type and start_date and end_date - stat_index = StatisticIndex.find_by(es_type_key: "#{path}") - stat_type = StatisticType.where(statistic_index_id: stat_index.id, key: statistic_type).first - client = Elasticsearch::Model.client - stat_type.statistic_custom_aggregations.each do |custom| - c_res = client.search index: custom.es_index, type:custom.es_type, body:sprintf(custom.query, {aggs_name: custom.field, start_date: start_date, end_date: end_date}) - results['aggregations'][custom.field] = c_res['aggregations'][custom.field] - end - end + CustomAggregationService.new.("#{path}", statistic_type, start_date, end_date, custom_query, results) # return result render json: results diff --git a/app/models/availability.rb b/app/models/availability.rb index a232dddc3..2b8b29c90 100644 --- a/app/models/availability.rb +++ b/app/models/availability.rb @@ -38,6 +38,7 @@ class Availability < ActiveRecord::Base settings do mappings dynamic: 'true' do indexes 'available_type', analyzer: 'simple' + indexes 'subType', index: 'not_analyzed' end end @@ -97,8 +98,15 @@ class Availability < ActiveRecord::Base def as_indexed_json json = JSON.parse(to_json) json['hours_duration'] = (end_at - start_at) / (60 * 60) - json['machines'] = machines_availabilities.map{|ma| ma.machine.friendly_id} - json['bookable_hours'] = json['hours_duration'] * json['machines'].length + if available_type == 'machines' + json['subType'] = machines_availabilities.map{|ma| ma.machine.friendly_id} + elsif available_type == 'training' + json['subType'] = trainings_availabilities.map{|ta| ta.training.friendly_id} + elsif available_type == 'event' + json['subType'] = [event.category.friendly_id] + end + json['bookable_hours'] = json['hours_duration'] * json['subType'].length + json['date'] = start_at.to_date json.to_json end diff --git a/app/services/custom_aggregation_service.rb b/app/services/custom_aggregation_service.rb new file mode 100644 index 000000000..034e237b8 --- /dev/null +++ b/app/services/custom_aggregation_service.rb @@ -0,0 +1,45 @@ +require 'json' + +class CustomAggregationService + + ## + # Run any additional custom aggregations related to the given statistic type, if any + ## + def call(statistic_index, statistic_type, start_date, end_date, custom_query, results) + if statistic_type and start_date and end_date + stat_index = StatisticIndex.find_by(es_type_key: statistic_index) + stat_type = StatisticType.find_by(statistic_index_id: stat_index.id, key: statistic_type) + client = Elasticsearch::Model.client + stat_type.statistic_custom_aggregations.each do |custom| + + query = sprintf(custom.query, {aggs_name: custom.field, start_date: start_date, end_date: end_date}) + + if custom_query and !custom_query.empty? + # Here, a custom query was provided with the original request (eg: filter by subtype) + # so we try to apply this custom filter to the current custom aggregation. + # + # The requested model mapping (ie. found in StatisticCustomAggregation.es_index > es_type) must have defined + # these fields in the indexed json, otherwise the returned value will probably not be what is expected. + # + # As an implementation exemple, you can take a look at Availability (indexed as fablab/availabilities) + # and witch will run custom filters on the fields 'date' and 'subType'. Other custom filters will return 0 + # as they are not relevant with this kind of custom aggregation. + query = JSON.parse(query) + custom_query = JSON.parse(custom_query) + + exclude = custom_query.delete('exclude') + if exclude + query = {query: { filtered: { query: query['query'], filter: { not: { term: custom_query['match'] } } } }, aggregations: query['aggregations'], size: query['size']} + else + query['query']['bool']['must'].push(custom_query) + end + query = query.to_json + end + + c_res = client.search(index: custom.es_index, type: custom.es_type, body: query) + results['aggregations'][custom.field] = c_res['aggregations'][custom.field] + end + end + results + end +end \ No newline at end of file