From 76637f55a2d69adf7b2beccad630b6abd93f8192 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Tue, 20 Oct 2015 10:48:43 +0200 Subject: [PATCH] Init version. --- .../app/controllers/report.js.coffee | 488 ++++++++++++++++++ .../app/controllers/report_profile.js.coffee | 27 + .../app/models/report_profile.js.coffee | 14 + .../app/views/report/download_header.jst.eco | 14 + .../app/views/report/download_list.jst.eco | 26 + .../javascripts/app/views/report/main.jst.eco | 29 ++ .../app/views/report/sidebar.jst.eco | 33 ++ .../app/views/report/time_picker.jst.eco | 36 ++ .../views/report/time_range_picker.jst.eco | 7 + app/controllers/application_controller.rb | 2 +- app/controllers/report_profiles_controller.rb | 141 +++++ app/controllers/reports_controller.rb | 152 ++++++ app/models/report.rb | 278 ++++++++++ app/models/report/profile.rb | 10 + config/routes/report.rb | 16 + db/migrate/20151019000001_create_report.rb | 63 +++ lib/search_index_backend.rb | 126 ++++- 17 files changed, 1457 insertions(+), 5 deletions(-) create mode 100644 app/assets/javascripts/app/controllers/report.js.coffee create mode 100644 app/assets/javascripts/app/controllers/report_profile.js.coffee create mode 100644 app/assets/javascripts/app/models/report_profile.js.coffee create mode 100644 app/assets/javascripts/app/views/report/download_header.jst.eco create mode 100644 app/assets/javascripts/app/views/report/download_list.jst.eco create mode 100644 app/assets/javascripts/app/views/report/main.jst.eco create mode 100644 app/assets/javascripts/app/views/report/sidebar.jst.eco create mode 100644 app/assets/javascripts/app/views/report/time_picker.jst.eco create mode 100644 app/assets/javascripts/app/views/report/time_range_picker.jst.eco create mode 100644 app/controllers/report_profiles_controller.rb create mode 100644 app/controllers/reports_controller.rb create mode 100644 app/models/report.rb create mode 100644 app/models/report/profile.rb create mode 100644 config/routes/report.rb create mode 100644 db/migrate/20151019000001_create_report.rb diff --git a/app/assets/javascripts/app/controllers/report.js.coffee b/app/assets/javascripts/app/controllers/report.js.coffee new file mode 100644 index 000000000..a38475642 --- /dev/null +++ b/app/assets/javascripts/app/controllers/report.js.coffee @@ -0,0 +1,488 @@ +class Index extends App.ControllerContent + constructor: -> + super + + # check authentication + return if !@authenticate() + + @title 'Reporting' + @navupdate '#report' + @startLoading() + @ajax( + type: 'GET', + url: @apiPath + '/reports/config', + processData: true, + success: (data) => + @stopLoading() + @config = data.config + App.Collection.load( type: 'ReportProfile', data: data.profiles ) + @render() + ) + + getParams: => + return @params if @params + + @params = {} + @params.timeRange = 'year' + current = new Date() + currentDay = current.getDate() + currentMonth = current.getMonth() + 1 + currentYear = current.getFullYear() + currentWeek = current.getWeek() + @params.day = currentDay + @params.month = currentMonth + @params.week = currentWeek + @params.year = currentYear + if !@params.metric + for key, config of @config.metric + if config.default + @params.metric = config.name + if !@params.backendSelected + @params.backendSelected = {} + for key, config of @config.metric + for backend in config.backend + if backend.selected + @params.backendSelected[backend.name] = true + if !@params.profileSelected + @params.profileSelected = {} + for profile in App.ReportProfile.all() + if _.isEmpty( @params.profileSelected ) + @params.profileSelected[ profile.id ] = true + @params + + render: (data = {}) => + + @params = @getParams() + + @html App.view('report/main')( + params: @params + ) + + new TimeRangePicker( + el: @el.find('.js-timeRangePicker') + params: @params + ui: @ + ) + + new TimePicker( + el: @el.find('.js-timePicker') + params: @params + ui: @ + ) + + new Sidebar( + el: @el.find('.sidebar') + config: @config + params: @params + ) + + new Graph( + el: @el + config: @config + params: @params + ui: @ + ) + +class Graph extends App.ControllerContent + constructor: -> + super + + # rerender view + @bind 'ui:report:rerender', => + @render() + + @render() + + render: => + + update = (data) => + @draw(data.data) + t = new Date + @el.find('#download-chart').html(t.toString()) + new Download( + el: @el.find('.js-dataDownload') + config: @config + params: @params + ui: @ui + ) + + url = @apiPath + '/reports/generate' + interval = 60000 + if @params.timeRange is 'year' + interval = 30000 + if @params.timeRange is 'month' + interval = 20000 + if @params.timeRange is 'week' + interval = 20000 + if @params.timeRange is 'day' + interval = 20000 + if @params.timeRange is 'realtime' + interval = 10000 + + @ajax( + type: 'POST' + url: url + data: JSON.stringify( + metric: @params.metric + year: @params.year + month: @params.month + week: @params.week + day: @params.day + timeRange: @params.timeRange + profiles: @params.profileSelected + backends: @params.backendSelected + ) + processData: true + success: (data) => + update(data) + @delay( @render, interval, 'report-update', 'page' ) + ) + + draw: (data) => + @log('draw', data) + $('#placeholder').empty() + + # create xaxis + xaxis = [] + if @params.timeRange is 'realtime' + for minute in [0..59] + xaxis.push [minute, ''] + else if @params.timeRange is 'day' + for hour in [0..23] + xaxis.push [hour, hour] + else if @params.timeRange is 'month' + for day in [1..31] + xaxis.push [day, day] + else if @params.timeRange is 'week' + xaxis = [[1, 'Mon'], [2, 'Tue'], [3, 'Wed'], [4, 'Thr'], [5, 'Fri'], [6, 'Sat'], [7, 'Sun'] ] + else + xaxis = [[1, 'Jan'], [2, 'Feb'], [3, 'Mar'], [4, 'Apr'], [5, 'Mai'], [6, 'Jun'], [7, 'Jul'], [8, 'Aug'], [9, 'Sep'], [10, 'Oct'], [11, 'Nov'], [12, 'Dec']] + + dataPlot = [] + for key, value of data + dataPlot.push { + data: value + label: key + } + # plot + $.plot( $('#placeholder'), dataPlot, { + yaxis: { min: 0 }, + xaxis: { ticks: xaxis } + } ) + + +class Download extends App.Controller + events: + 'click .js-dataDownloadBackendSelector': 'tableUpdate' + + constructor: (data) -> + + # unbind existing click binds + data.el.unbind('click .js-dataDownloadBackendSelector') + + super + @render() + + render: -> + + reports = [] + $('.js-backendSelector:checked').each( (index, element) -> + if $(element).hasClass('download') + value = $(element).val() + reports.push value + ) + + profiles = [] + for key, value of @params.profileSelected + profiles.push App.ReportProfile.find(key) + console.log('reports', reports, 'profiles', profiles, @config.metric, @params.metric, @config.metric[@params.metric]) + @html App.view('report/download_header')( + reports: reports + profiles: profiles + metric: @config.metric[@params.metric] + ) + + @profileSelected = '' + @backendSelected = '' + active = false + @el.find('.js-dataDownloadBackendSelector').each( (index, element) -> + if $(element).parent().hasClass('active') + active = true + ) + if !active + @el.find('.js-dataDownloadBackendSelector').first().parent().addClass('active') + + @el.find('.js-dataDownloadBackendSelector').each( (index, element) => + if $(element).parent().hasClass('active') + @profileSelected = $(element).data('profile') + @backendSelected = $(element).data('report') + ) + + @tableUpdate() + + tableUpdate: (e) => + if e + e.preventDefault() + @el.find('.js-dataDownloadBackendSelector').parent().removeClass('active') + $(e.target).parent().addClass('active') + @profileSelected = $(e.target).data('profile') + @backendSelected = $(e.target).data('backend') + + table = (tickets, count) => + url = '#ticket/zoom/' + if App.Config.get('import_mode') + url = App.Config.get('import_otrs_endpoint') + '/index.pl?Action=AgentTicketZoom;TicketID=' + if _.isEmpty(tickets) + @el.find('js-dataDownloadTable').html('') + else + html = App.view('report/download_list')( + tickets: tickets + count: count + url: url + download: @apiPath + '/reports/csvforset/' + name + ) + @el.find('js-dataDownloadTable').html(html) + + @startLoading() + @ajax( + type: 'POST' + url: @apiPath + '/reports/sets' + data: JSON.stringify( + metric: @params.metric + year: @params.year + month: @params.month + week: @params.week + day: @params.day + timeRange: @params.timeRange + profiles: @params.profileSelected + + ) + processData: true + success: (data) => + @stopLoading() + + # load ticket collection / do not save in localStorage + App.Collection.load( type: 'TicketReport', data: data.tickets, localStorage: true ) + ticket_collection = [] + + if data.tickets + for record in data.tickets + ticket = App.TicketReport.fullLocal( record.id ) + ticket_collection.push ticket + + table( ticket_collection, data.count ) + ) + +class TimeRangePicker extends App.Controller + events: + 'click .js-timeRange': 'select' + + constructor: -> + super + + # rerender view + @bind 'ui:report:rerender', => + @render() + + @render() + + render: => + @html App.view('report/time_range_picker')() + + # select time slot + @el.find('.js-timeRange').removeClass('active') + @el.find('.js-timeRange[data-type="' + @ui.params.timeRange + '"]').addClass('active') + + select: (e) => + console.log('TS click') + e.preventDefault() + @ui.params.timeRange = $(e.target).data('type') + console.log 'SLOT', @ui.params.timeRange + App.Event.trigger( 'ui:report:rerender' ) + + +class TimePicker extends App.Controller + events: + 'click .js-timePickerDay': 'selectTimeDay' + 'click .js-timePickerYear': 'selectTimeYear' + 'click .js-timePickerMonth': 'selectTimeMonth' + 'click .js-timePickerWeek': 'selectTimeWeek' + + constructor: -> + super + + @_timeSlotPicker() + + # rerender view + @bind 'ui:report:rerender', => + @render() + + @render() + + render: => + @html App.view('report/time_picker')( + params: @ui.params + timeRangeDay: @timeRangeDay + timeRangeMonth: @timeRangeMonth + timeRangeWeek: @timeRangeWeek + timeRangeYear: @timeRangeYear + ) + + # select time slot + @el.find('.time-slot').removeClass('active') + @el.find('.time-slot[data-type="' + @ui.params.timeRange + '"]').addClass('active') + + selectTimeDay: (e) => + e.preventDefault() + @ui.params.day = $(e.target).data('type') + $(e.target).parent().parent().find('li').removeClass('active') + $(e.target).parent().addClass('active') + App.Event.trigger( 'ui:report:rerender' ) + + selectTimeMonth: (e) => + e.preventDefault() + @ui.params.month = $(e.target).data('type') + $(e.target).parent().parent().find('li').removeClass('active') + $(e.target).parent().addClass('active') + App.Event.trigger( 'ui:report:rerender' ) + + selectTimeWeek: (e) => + e.preventDefault() + @ui.params.week = $(e.target).data('type') + $(e.target).parent().parent().find('li').removeClass('active') + $(e.target).parent().addClass('active') + App.Event.trigger( 'ui:report:rerender' ) + + selectTimeYear: (e) => + e.preventDefault() + @ui.params.year = $(e.target).data('type') + $(e.target).parent().parent().find('li').removeClass('active') + $(e.target).parent().addClass('active') + App.Event.trigger( 'ui:report:rerender' ) + + _timeSlotPicker: -> + @timeRangeYear = [] + year = new Date().getFullYear() + for item in [year-2..year] + record = { + display: item + value: item + } + @timeRangeYear.push record + + @timeRangeMonth = [ + { + display: 'Jan' + value: 1 + }, + { + display: 'Feb' + value: 2 + }, + { + display: 'Mar' + value: 3 + }, + { + display: 'Apr' + value: 4, + }, + { + display: 'Mai' + value: 5, + }, + { + display: 'Jun' + value: 6, + }, + { + display: 'Jul' + value: 7, + }, + { + display: 'Aug' + value: 8, + }, + { + display: 'Sep' + value: 9, + }, + { + display: 'Oct' + value: 10, + }, + { + display: 'Nov' + value: 11, + }, + { + display: 'Dec' + value: 12, + }, + ] + + @timeRangeWeek = [] + for item in [1..52] + record = { + display: item + value: item + } + @timeRangeWeek.push record + + @timeRangeDay = [] + for item in [1..31] + record = { + display: item + value: item + } + @timeRangeDay.push record + + +class Sidebar extends App.Controller + events: + 'click .js-profileSelector': 'selectProfile' + 'click .js-backendSelector': 'selectBackend' + 'click .panel-title': 'selectMetric' + + constructor: -> + super + @render() + + render: => + + metrics = @config.metric + profiles = App.ReportProfile.all() + console.log('Si', @params) + @html App.view('report/sidebar')( + metrics: metrics + params: @params + profiles: profiles + ) + + selectMetric: (e) => + return if $(e.target).closest('.panel').find('.collapse.in').get(0) + metric = $(e.target).closest('.panel').data('metric') + return if @params.metric is metric + @params.metric = metric + App.Event.trigger( 'ui:report:rerender' ) + + selectProfile: (e) => + profile_id = $(e.target).val() + active = $(e.target).prop('checked') + if active + @params.profileSelected[profile_id] = true + else + delete @params.profileSelected[profile_id] + App.Event.trigger( 'ui:report:rerender' ) + + selectBackend: (e) => + backend = $(e.target).val() + active = $(e.target).prop('checked') + if active + @params.backendSelected[backend] = true + else + delete @params.backendSelected[backend] + App.Event.trigger( 'ui:report:rerender' ) + +App.Config.set( 'report', Index, 'Routes' ) +App.Config.set( 'Reporting', { prio: 8000, parent: '', name: 'Reporing', translate: true, target: '#report', icon: 'report', role: ['Admin'] }, 'NavBarRight' ) \ No newline at end of file diff --git a/app/assets/javascripts/app/controllers/report_profile.js.coffee b/app/assets/javascripts/app/controllers/report_profile.js.coffee new file mode 100644 index 000000000..aa00ec31f --- /dev/null +++ b/app/assets/javascripts/app/controllers/report_profile.js.coffee @@ -0,0 +1,27 @@ +class Index extends App.ControllerContent + constructor: -> + super + + # check authentication + return if !@authenticate() + + new App.ControllerGenericIndex( + el: @el + id: @id + genericObject: 'ReportProfile' + pageData: + title: 'Report Profile' + home: 'report_profiles' + object: 'Report Profile' + objects: 'Report Profiles' + navupdate: '#report_profiles' + notes: [ +# 'Report Profile are ...' + ] + buttons: [ + { name: 'New Profile', 'data-type': 'new', class: 'primary' } + ] + container: @el.closest('.content') + ) + +App.Config.set( 'ReportProfile', { prio: 8000, name: 'Report Profiles', parent: '#manage', target: '#manage/report_profiles', controller: Index, role: ['Admin'] }, 'NavBarAdmin' ) diff --git a/app/assets/javascripts/app/models/report_profile.js.coffee b/app/assets/javascripts/app/models/report_profile.js.coffee new file mode 100644 index 000000000..a21fa0458 --- /dev/null +++ b/app/assets/javascripts/app/models/report_profile.js.coffee @@ -0,0 +1,14 @@ +class App.ReportProfile extends App.Model + @configure 'ReportProfile', 'name', 'condition', 'active' + @extend Spine.Model.Ajax + @url: @apiPath + '/report_profiles' + @configure_attributes = [ + { name: 'name', display: 'Name', tag: 'input', type: 'text', limit: 100, null: false }, + { name: 'condition', display: 'Filter', tag: 'ticket_selector', null: true }, + { name: 'updated_at', display: 'Updated', tag: 'datetime', readonly: 1 }, + { name: 'active', display: 'Active', tag: 'active', default: true }, + ] + @configure_delete = true + @configure_overview = [ + 'name', + ] diff --git a/app/assets/javascripts/app/views/report/download_header.jst.eco b/app/assets/javascripts/app/views/report/download_header.jst.eco new file mode 100644 index 000000000..2c06034c9 --- /dev/null +++ b/app/assets/javascripts/app/views/report/download_header.jst.eco @@ -0,0 +1,14 @@ +
+ + + +
+
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/report/download_list.jst.eco b/app/assets/javascripts/app/views/report/download_list.jst.eco new file mode 100644 index 000000000..8fcbda5c7 --- /dev/null +++ b/app/assets/javascripts/app/views/report/download_list.jst.eco @@ -0,0 +1,26 @@ +<%- @T('%s records', @count) %> + + + + + + + + + + + + + + +<% for ticket in @tickets: %> + + + + + + + +<% end %> + +
<%- @T('Number') %><%- @T('Title') %><%- @T('State') %><%- @T('Queue') %><%- @T('Created') %>
<%- @P(ticket, 'number') %><%- @P(ticket, 'title') %><%- @P(ticket, 'state') %><%- @P(ticket, 'group') %><%- @P(ticket, 'created_at') %>
diff --git a/app/assets/javascripts/app/views/report/main.jst.eco b/app/assets/javascripts/app/views/report/main.jst.eco new file mode 100644 index 000000000..d1c5bc958 --- /dev/null +++ b/app/assets/javascripts/app/views/report/main.jst.eco @@ -0,0 +1,29 @@ + + +
+ + + +
+ +
+ +
+ + + + +
+
+
+
+ diff --git a/app/assets/javascripts/app/views/report/sidebar.jst.eco b/app/assets/javascripts/app/views/report/sidebar.jst.eco new file mode 100644 index 000000000..fed8f34bb --- /dev/null +++ b/app/assets/javascripts/app/views/report/sidebar.jst.eco @@ -0,0 +1,33 @@ +
+ <% for key, metric of @metrics: %> +
+ +
+
+
    + <% for backend in metric.backend: %> +
  • checked<% end %>/> + <%- @T(backend.display) %> +
  • + <% end %> +
+
+
+
+ <% end %> +
+ +

<%- @T('Profiles') %>

+ diff --git a/app/assets/javascripts/app/views/report/time_picker.jst.eco b/app/assets/javascripts/app/views/report/time_picker.jst.eco new file mode 100644 index 000000000..707fc5220 --- /dev/null +++ b/app/assets/javascripts/app/views/report/time_picker.jst.eco @@ -0,0 +1,36 @@ +
+ <% if @params.timeRange is 'day': %> +
+ <% for item in @timeRangeDay: %> + + <% end %> +
+
+ <% end %> + + <% if @params.timeRange is 'day' || @params.timeRange is 'month': %> +
+ <% for item in @timeRangeMonth: %> + + <% end %> +
+
+ <% end %> + + <% if @params.timeRange is 'week': %> +
+ <% for item in @timeRangeWeek: %> + + <% end %> +
+
+ <% end %> + + <% if @params.timeRange isnt 'realtime': %> +
+ <% for item in @timeRangeYear: %> + + <% end %> +
+ <% end %> +
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/report/time_range_picker.jst.eco b/app/assets/javascripts/app/views/report/time_range_picker.jst.eco new file mode 100644 index 000000000..6102afc5c --- /dev/null +++ b/app/assets/javascripts/app/views/report/time_range_picker.jst.eco @@ -0,0 +1,7 @@ +
+ + + + + +
\ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index eebf8fbe7..a1aa3874e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -151,7 +151,7 @@ class ApplicationController < ActionController::Base def authentication_check_only(auth_param) - logger.debug 'authentication_check' + #logger.debug 'authentication_check' #logger.debug params.inspect #logger.debug session.inspect #logger.debug cookies.inspect diff --git a/app/controllers/report_profiles_controller.rb b/app/controllers/report_profiles_controller.rb new file mode 100644 index 000000000..58f574e18 --- /dev/null +++ b/app/controllers/report_profiles_controller.rb @@ -0,0 +1,141 @@ +class ReportProfilesController < ApplicationController + before_action :authentication_check + +=begin + +Format: +JSON + +Example: +{ + "id":1, + "name":"some report_profile", + "condition":{"c_a":1,"c_b":2}, + "updated_at":"2012-09-14T17:51:53Z", + "created_at":"2012-09-14T17:51:53Z", + "updated_by_id":2. + "created_by_id":2, +} + +=end + +=begin + +Resource: +GET /api/report_profiles.json + +Response: +[ + { + "id": 1, + "name": "some_name1", + ... + }, + { + "id": 2, + "name": "some_name2", + ... + } +] + +Test: +curl http://localhost/api/report_profiles.json -v -u #{login}:#{password} + +=end + + def index + model_index_render(Report::Profile, params) + end + +=begin + +Resource: +GET /api/report_profiles/#{id}.json + +Response: +{ + "id": 1, + "name": "name_1", + ... +} + +Test: +curl http://localhost/api/report_profiles/#{id}.json -v -u #{login}:#{password} + +=end + + def show + model_show_render(Report::Profile, params) + end + +=begin + +Resource: +POST /api/report_profiles.json + +Payload: +{ + "name":"some report_profile", + "condition":{"c_a":1,"c_b":2}, +} + +Response: +{ + "id": 1, + "name": "some_name", + ... +} + +Test: +curl http://localhost/api/report_profiles.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X POST -d '{"name": "some_name","active": true, "note": "some note"}' + +=end + + def create + model_create_render(Report::Profile, params) + end + +=begin + +Resource: +PUT /api/report_profiles/{id}.json + +Payload: +{ + "name":"some report_profile", + "condition":{"c_a":1,"c_b":2}, +} + +Response: +{ + "id": 1, + "name": "some_name", + ... +} + +Test: +curl http://localhost/api/report_profiles.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X PUT -d '{"name": "some_name","active": true, "note": "some note"}' + +=end + + def update + model_update_render(Report::Profile, params) + end + +=begin + +Resource: +DELETE /api/report_profiles/{id}.json + +Response: +{} + +Test: +curl http://localhost/api/report_profiles.json -v -u #{login}:#{password} -H "Content-Type: application/json" -X DELETE + +=end + + def destroy + model_destory_render(Report::Profile, params) + end +end diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb new file mode 100644 index 000000000..52eaa5e72 --- /dev/null +++ b/app/controllers/reports_controller.rb @@ -0,0 +1,152 @@ +# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/ + +class ReportsController < ApplicationController + before_action :authentication_check + + # GET /api/reports/config + def config + return if deny_if_not_role('Report') + render json: { + config: Report.config, + profiles: Report::Profile.list, + } + end + + # GET /api/reports/generate + def generate + return if deny_if_not_role('Report') + + #{"metric"=>"count", "year"=>2015, "month"=>10, "week"=>43, "day"=>20, "timeSlot"=>"year", "report"=>{"metric"=>"count", "year"=>2015, "month"=>10, "week"=>43, "day"=>20, "timeSlot"=>"year"}} + if params[:timeRange] == 'realtime' + start = (Time.zone.now - 60.minutes).iso8601 + stop = Time.zone.now.iso8601 + created = aggs(start, stop, 'minute', 'created_at') + closed = aggs(start, stop, 'minute', 'close_time') + elsif params[:timeRange] == 'day' + start = Date.parse("#{params[:year]}-#{params[:month]}-#{params[:day]}").iso8601 + start = "#{start}T00:00:00Z" + stop = "#{start}T23:59:59Z" + created = aggs(start, stop, 'hour', 'created_at') + closed = aggs(start, stop, 'hour', 'close_time') + elsif params[:timeRange] == 'week' + start = Date.commercial(params[:year], params[:week]).iso8601 + stop = Date.parse(start).end_of_week + created = aggs(start, stop, 'week', 'created_at') + closed = aggs(start, stop, 'week', 'close_time') + elsif params[:timeRange] == 'month' + start = Date.parse("#{params[:year]}-#{params[:month]}-01}").iso8601 + stop = Date.parse(start).end_of_month + created = aggs(start, stop, 'day', 'created_at') + closed = aggs(start, stop, 'day', 'close_time') + else + start = "#{params[:year]}-01-01" + stop = "#{params[:year]}-12-31" + created = aggs(start, stop, 'month', 'created_at') + closed = aggs(start, stop, 'month', 'close_time') + end + render json: { + data: { + created: created, + closed: closed, + } + } + end + + # GET /api/reports/sets + def sets + return if deny_if_not_role('Report') + + #{"metric"=>"count", "year"=>2015, "month"=>10, "week"=>43, "day"=>20, "timeSlot"=>"year", "report"=>{"metric"=>"count", "year"=>2015, "month"=>10, "week"=>43, "day"=>20, "timeSlot"=>"year"}} + if params[:timeRange] == 'realtime' + start = (Time.zone.now - 60.minutes).iso8601 + stop = Time.zone.now.iso8601 + elsif params[:timeRange] == 'day' + start = Date.parse("#{params[:year]}-#{params[:month]}-#{params[:day]}").iso8601 + start = "#{start}T00:00:00Z" + stop = "#{start}T23:59:59Z" + elsif params[:timeRange] == 'week' + start = Date.commercial(params[:year], params[:week]).iso8601 + stop = Date.parse(start).end_of_week + elsif params[:timeRange] == 'month' + start = Date.parse("#{params[:year]}-#{params[:month]}-01}").iso8601 + stop = Date.parse(start).end_of_month + else + start = "#{params[:year]}-01-01" + stop = "#{params[:year]}-12-31" + end + + # get data + + render json: { + data: { + start: start, + stop: stop, + } + } + end + + def aggs(range_start, range_end, interval, field) + result = SearchIndexBackend.aggs( + { + }, + [range_start, range_end, field, interval], + ['Ticket'], + ) + data = [] + if interval == 'month' + start = Date.parse(range_start) + stop_interval = 12 + elsif interval == 'week' + start = Date.parse(range_start) + stop_interval = 7 + elsif interval == 'day' + start = Date.parse(range_start) + stop_interval = 31 + elsif interval == 'hour' + start = Time.zone.parse(range_start) + stop_interval = 24 + elsif interval == 'minute' + start = Time.zone.parse(range_start) + stop_interval = 60 + end + (1..stop_interval).each {|counter| + match = false + result['aggregations']['time_buckets']['buckets'].each {|item| + if interval == 'minute' + start_string = start.iso8601.sub(/:\d\d.+?$/, '') + else + start_string = start.iso8601.sub(/:\d\d:\d\d.+?$/, '') + end + next if !item['doc_count'] + next if item['key_as_string'] !~ /#{start_string}/ + match = true + data.push [counter, item['doc_count']] + if interval == 'month' + start = start.next_month + elsif interval == 'week' + start = start.next_week + elsif interval == 'day' + start = start.next_day + elsif interval == 'hour' + start = start + 1.hour + elsif interval == 'minute' + start = start + 1.minute + end + } + next if match + data.push [counter, 0] + if interval == 'month' + start = start.next_month + elsif interval == 'week' + start = start.next_week + elsif interval == 'day' + start = start + 1.day + elsif interval == 'hour' + start = start + 1.hour + elsif interval == 'minute' + start = start + 1.minute + end + } + data + end +end diff --git a/app/models/report.rb b/app/models/report.rb new file mode 100644 index 000000000..22cb72ff2 --- /dev/null +++ b/app/models/report.rb @@ -0,0 +1,278 @@ +class Report + + def self.config + config = {} + config[:metric] = {} + + config[:metric][:count] = { + name: 'count', + display: 'Ticket Count', + default: true, + prio: 10_000, + } + backend = [ + { + name: 'created', + display: 'Created', + selected: true, + dataDownload: true, + }, + { + name: 'closed', + display: 'Closed', + selected: true, + dataDownload: true, + }, + { + name: 'backlog', + display: 'Backlog', + selected: true, + dataDownload: false, + }, + { + name: 'first_solution', + display: 'First Solution', + selected: true, + dataDownload: true, + }, + { + name: 'reopen', + display: 'Re-Open', + selected: false, + dataDownload: true, + }, + { + name: 'movedin', + display: 'Moved in', + selected: false, + dataDownload: true, + }, + { + name: 'movedout', + display: 'Moved out', + selected: false, + dataDownload: true, + }, + { + name: 'sla_in', + display: 'SLA in', + selected: false, + dataDownload: true, + }, + { + name: 'sla_out', + display: 'SLA out', + selected: false, + dataDownload: true, + }, + ] + config[:metric][:count][:backend] = backend + + config[:metric][:create_channels] = { + name: 'create_channels', + display: 'Create Channels', + prio: 9000, + } + backend = [ + { + name: 'phone_in', + display: 'Phone (in)', + selected: true, + dataDownload: true, + }, + { + name: 'phone_out', + display: 'Phone (out)', + selected: true, + dataDownload: true, + }, + { + name: 'email_in', + display: 'Email (in)', + selected: true, + dataDownload: true, + }, + { + name: 'email_out', + display: 'Email (out)', + selected: true, + dataDownload: true, + }, + { + name: 'web_in', + display: 'Web (in)', + selected: true, + dataDownload: true, + }, + { + name: 'twitter_in', + display: 'Twitter (in)', + selected: true, + dataDownload: true, + }, + ] + config[:metric][:create_channels][:backend] = backend + + config[:metric][:times] = { + name: 'times', + display: 'Times', + prio: 8000, + } + backend = [ + { + name: 'first_response_average', + display: 'First Response average', + selected: true, + dataDownload: true, + }, + { + name: 'first_response_max', + display: 'First Response max', + selected: true, + dataDownload: true, + }, + { + name: 'first_response_min', + display: 'First Response min', + selected: true, + dataDownload: true, + }, + { + name: 'solution_time_average', + display: 'Solution Time average', + selected: true, + dataDownload: true, + }, + { + name: 'solution_time_max', + display: 'Solution Time max', + selected: true, + dataDownload: true, + }, + { + name: 'solution_time_min', + display: 'Solution Time min', + selected: true, + dataDownload: true, + }, + ] + config[:metric][:times][:backend] = backend + + config[:metric][:communication] = { + name: 'communication', + display: 'Communication', + prio: 7000, + } + backend = [ + { + name: 'phone_in', + display: 'Phone (in)', + selected: true, + dataDownload: true, + }, + { + name: 'phone_out', + display: 'Phone (out)', + selected: true, + dataDownload: true, + }, + { + name: 'email_in', + display: 'Email (in)', + selected: true, + dataDownload: true, + }, + { + name: 'email_out', + display: 'Email (out)', + selected: true, + dataDownload: true, + }, + { + name: 'web_in', + display: 'Web (in)', + selected: true, + dataDownload: true, + }, + { + name: 'twitter_in', + display: 'Twitter (in)', + selected: true, + dataDownload: true, + }, + { + name: 'twitter_out', + display: 'Twitter (out)', + selected: true, + dataDownload: true, + }, + ] + config[:metric][:communication][:backend] = backend + + config[:metric][:sla] = { + name: 'sla', + display: 'SLAs', + prio: 6000, + } + backend = [ + { + name: 'sla_out_1', + display: 'SLA (out) - <1h', + selected: true, + dataDownload: true, + }, + { + name: 'sla_out_2', + display: 'SLA (out) - <2h', + selected: true, + dataDownload: true, + }, + { + name: 'sla_out_4', + display: 'SLA (out) - <4h', + selected: true, + dataDownload: true, + }, + { + name: 'sla_out_8', + display: 'SLA (out) - <8h', + selected: true, + dataDownload: true, + }, + { + name: 'sla_in_1', + display: 'SLA (in) - <1h', + selected: true, + dataDownload: true, + }, + { + name: 'sla_in_2', + display: 'SLA (in) - <2h', + selected: true, + dataDownload: true, + }, + { + name: 'sla_in_4', + display: 'SLA (in) - <4h', + selected: true, + dataDownload: true, + }, + { + name: 'sla_in_8', + display: 'SLA (in) - <8h', + selected: true, + dataDownload: true, + }, + ] + config[:metric][:sla][:backend] = backend + + config[:metric].each {|metric_key, metric_value| + metric_value[:backend].each {|metric_backend| + metric_backend[:name] = "#{metric_key}::#{metric_backend[:name]}" + } + } + + config + end + +end diff --git a/app/models/report/profile.rb b/app/models/report/profile.rb new file mode 100644 index 000000000..a5770ccf7 --- /dev/null +++ b/app/models/report/profile.rb @@ -0,0 +1,10 @@ +class Report::Profile < ApplicationModel + self.table_name = 'report_profiles' + validates :name, presence: true + store :condition + + def self.list + where(active: true) + end + +end diff --git a/config/routes/report.rb b/config/routes/report.rb new file mode 100644 index 000000000..0dfa453bf --- /dev/null +++ b/config/routes/report.rb @@ -0,0 +1,16 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + # reports + match api_path + '/reports/config', to: 'reports#config', via: :get + match api_path + '/reports/generate', to: 'reports#generate', via: :post + match api_path + '/reports/sets', to: 'reports#sets', via: :post + + # report_profiles + match api_path + '/report_profiles', to: 'report_profiles#index', via: :get + match api_path + '/report_profiles/:id', to: 'report_profiles#show', via: :get + match api_path + '/report_profiles', to: 'report_profiles#create', via: :post + match api_path + '/report_profiles/:id', to: 'report_profiles#update', via: :put + match api_path + '/report_profiles/:id', to: 'report_profiles#destroy', via: :delete + +end diff --git a/db/migrate/20151019000001_create_report.rb b/db/migrate/20151019000001_create_report.rb new file mode 100644 index 000000000..a72935f60 --- /dev/null +++ b/db/migrate/20151019000001_create_report.rb @@ -0,0 +1,63 @@ +class CreateReport < ActiveRecord::Migration + def up + create_table :report_profiles do |t| + t.column :name, :string, limit: 150, null: true + t.column :condition, :string, limit: 6000, null: true + t.column :active, :boolean, null: false, default: true + t.column :updated_by_id, :integer, null: false + t.column :created_by_id, :integer, null: false + t.timestamps null: false + end + add_index :report_profiles, [:name], unique: true + + Report::Profile.create_if_not_exists( + name: '-all-', + condition: {}, + active: true, + updated_by_id: 1, + created_by_id: 1, + ) + + Role.create_if_not_exists( name: 'Report', created_by_id: 1, updated_by_id: 1 ) + Translation.create_if_not_exists( + locale: 'de-de', + source: 'Ticket Count', + target: 'Ticket Anzahl', + updated_by_id: 1, + created_by_id: 1, + ) + Translation.create_if_not_exists( + locale: 'de-de', + source: 'Ticket Count', + target: 'Ticket Anzahl', + updated_by_id: 1, + created_by_id: 1, + ) + Translation.create_if_not_exists( + locale: 'de-de', + source: 'Create Channels', + target: 'Erstellkanäle', + updated_by_id: 1, + created_by_id: 1, + ) + Translation.create_if_not_exists( + locale: 'de-de', + source: 'Times', + target: 'Zeiten', + updated_by_id: 1, + created_by_id: 1, + ) + Translation.create_if_not_exists( + locale: 'de-de', + source: 'Communication', + target: 'Kommunikation', + updated_by_id: 1, + created_by_id: 1, + ) + + end + + def down + drop_table :report_profiles + end +end diff --git a/lib/search_index_backend.rb b/lib/search_index_backend.rb index b7c84bdd7..ef9a31551 100644 --- a/lib/search_index_backend.rb +++ b/lib/search_index_backend.rb @@ -44,7 +44,7 @@ create/update/delete index end Rails.logger.info "# curl -X PUT \"#{url}\" \\" - #Rails.logger.info "-d '#{data[:data].to_json}'" + Rails.logger.debug "-d '#{data[:data].to_json}'" response = UserAgent.put( url, @@ -76,7 +76,7 @@ add new object to search index return if !url Rails.logger.info "# curl -X POST \"#{url}\" \\" - #Rails.logger.info "-d '#{data.to_json}'" + Rails.logger.debug "-d '#{data.to_json}'" response = UserAgent.post( url, @@ -119,7 +119,7 @@ remove whole data from index password: Setting.get('es_password'), } ) - #Rails.logger.info "# #{response.code.to_s}" + Rails.logger.info "# #{response.code}" return true if response.success? #Rails.logger.info "NOTICE: can't drop index: " + response.inspect false @@ -194,7 +194,7 @@ return search result data['query']['bool']['must'].push condition Rails.logger.info "# curl -X POST \"#{url}\" \\" - #Rails.logger.info " -d'#{data.to_json}'" + Rails.logger.debug " -d'#{data.to_json}'" response = UserAgent.get( url, @@ -232,6 +232,124 @@ return search result =begin +return aggregation result + + result = SearchIndexBackend.aggs( + { + title: 'test', + state_id: 4, + }, + ['2014-10-19', '2015-10-19', 'created_at', 'month'], + ['Ticket'], + ) + + # year, quarter, month, week, day, hour, minute, second + + result = { + hits:{ + total:4819, + }, + aggregations:{ + time_buckets:{ + buckets:[ + { + key_as_string:"2014-10-01T00:00:00.000Z", + key:1412121600000, + doc_count:420 + }, + { + key_as_string:"2014-11-01T00:00:00.000Z", + key:1414800000000, + doc_count:561 + }, + ... + ] + } + } + } + +=end + + def self.aggs(query, range, index = nil) + + url = build_url() + return if !url + if index + if index.class == Array + url += "/#{index.join(',')}/_search" + else + url += "/#{index}/_search" + end + else + url += '/_search' + end + + and_data = [] + if query && !query.empty? + bool = { + bool: { + must: { + term: query, + }, + }, + } + and_data.push bool + end + range_data = {} + range_data[range[2]] = { + from: range[0], + to: range[1], + } + range_data_and = { + range: range_data, + } + and_data.push range_data_and + + data = { + query: { + filtered: { + filter: { + and: and_data, + } + } + }, + size: 0, + aggs: { + time_buckets: { + date_histogram: { + field: range[2], + interval: range[3], + } + } + } + } + + Rails.logger.info "# curl -X POST \"#{url}\" \\" + Rails.logger.debug " -d'#{data.to_json}'" + + response = UserAgent.get( + url, + data, + { + json: true, + open_timeout: 5, + read_timeout: 14, + user: Setting.get('es_user'), + password: Setting.get('es_password'), + } + ) + + Rails.logger.info "# #{response.code}" + if !response.success? + Rails.logger.error "ERROR: #{response.inspect}" + return [] + end + Rails.logger.debug response.data.to_json + response.data + end + +=begin + return true if backend is configured result = SearchIndexBackend.enabled?