diff --git a/cloud/endagaweb/static/css/dashboard.css b/cloud/endagaweb/static/css/dashboard.css index b79e59bc..bd99a031 100644 --- a/cloud/endagaweb/static/css/dashboard.css +++ b/cloud/endagaweb/static/css/dashboard.css @@ -504,3 +504,22 @@ div.message { opacity: 1; } } + +/* Reports chart */ +.nv-label, .nv-legend-text { + text-transform: capitalize; +} +.nvtooltip { + text-transform: capitalize; +} +.display td { + text-align: center; +} +.display td:nth-child(1) { + background-color: #eee; + font-weight: bold; +} +.display td:nth-child(2) { + background-color: #eee; + font-weight: bold; +} \ No newline at end of file diff --git a/cloud/endagaweb/static/js/dashboard/report-chart-components.js b/cloud/endagaweb/static/js/dashboard/report-chart-components.js new file mode 100644 index 00000000..ae74b644 --- /dev/null +++ b/cloud/endagaweb/static/js/dashboard/report-chart-components.js @@ -0,0 +1,1021 @@ +/* + * Copyright (c) 2016-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +// React chart components. + + +var TimeseriesChartWithButtonsAndDatePickers = React.createClass({ + + getInitialState: function() { + // We expect many of these values to be overridden before the chart is + // first rendered -- see componentDidMount. + return { + startTimeEpoch: 0, + endTimeEpoch: -1, + isLoading: true, + chartData: {}, + activeButtonText: '', + xAxisFormatter: '%x', + yAxisFormatter: '', + activeView:'', + tablesColumnValueName:'' + } + }, + getDefaultProps: function() { + // Load the current time with the user's clock if nothing is specified. We + // also specify the user's computer's timezone offset and use that to + // adjust the graph data. + var currentTime = Math.round(new Date().getTime() / 1000); + return { + title: 'title (set me!)', + chartID: 'one', + buttons: ['hour', 'day', 'week', 'month', 'year'], + icons: ['graph', 'list'], + defaultView: 'graph', + defaultButtonText: 'week', + endpoint: '/api/v1/stats/', + statTypes: 'sms', + level: 'network', + levelID: 0, + aggregation: 'count', + yAxisLabel: 'an axis label (set me!)', + currentTimeEpoch: currentTime, + timezoneOffset: 0, + tooltipUnits: '', + frontTooltip: '', + chartType: 'line-chart', + reportView: 'list', + info: 'info of report type' + } + }, + + // On-mount, build the charts with the default data. + // Note that we have to load the current time here. + componentDidMount: function() { + this.setState({ + activeButtonText: this.props.defaultButtonText, + activeView: this.props.defaultView, + startTimeEpoch: this.props.currentTimeEpoch - secondsMap[this.props.defaultButtonText], + endTimeEpoch: this.props.currentTimeEpoch, + // When the request params in the state have been set, go get more data. + }, function() { + this.updateChartData(); + }); + }, + componentDidUpdate(prevProps, prevState) { + // Update if we toggled a load + if (!prevState.isLoading && this.state.isLoading) { + this.updateChartData(); + } + }, + + // This handler takes the text of the date range buttons + // and ouputs figures out the corresponding number of seconds. + handleButtonClick: function(text) { + // Update only if the startTime has actually changed. + var newStartTimeEpoch = this.props.currentTimeEpoch - secondsMap[text]; + if (this.state.startTimeEpoch != newStartTimeEpoch) { + this.setState({ + startTimeEpoch: newStartTimeEpoch, + endTimeEpoch: this.props.currentTimeEpoch, + isLoading: true, + activeButtonText: text, + }); + } + }, + + // This handler takes the text of the view mode buttons + handleViewClick: function(text) { + // Update only if the startTime has actually changed. + if (this.state.activeView != text) { + this.setState({ + startTimeEpoch: this.state.startTimeEpoch, + endTimeEpoch: this.props.currentTimeEpoch, + isLoading: true, + activeView: text, + }); + } + }, + + handleDownloadClick: function(text) { + var queryParams = { + 'start-time-epoch': this.state.startTimeEpoch, + 'end-time-epoch': this.state.endTimeEpoch, + 'stat-types': this.props.statTypes, + 'level':this.props.level, + 'level_id': this.props.levelID, + 'report-type':this.props.title, + }; + $.get('/report/downloadcsv', queryParams, function(data,response) { + var todayTime = new Date(); var month = (todayTime .getMonth() + 1); var day = (todayTime .getDate()); var year = (todayTime .getFullYear()); + var convertdate = year+ "-" + month + "-" +day; + this.setState({ + isLoading: false, + data: data, + title:this.props.title + }); + var filename = this.state.title + var csvData = new Blob([data], {type: 'text/csv;charset=utf-8;'}); + var csvURL = null; + if (navigator.msSaveBlob) { + csvURL = navigator.msSaveBlob(csvData, filename+"-"+convertdate+'.csv'); + } else { + csvURL = window.URL.createObjectURL(csvData); + } + var tempLink = document.createElement('a'); + document.body.appendChild(tempLink); + tempLink.href = csvURL; + tempLink.setAttribute('download', filename+"-"+convertdate+'.csv'); + tempLink.target="_self" + tempLink.click(); + }.bind(this)); + }, + + // Datepicker handlers, one each for changing the start and end times. + startTimeChange: function(newTime) { + if (newTime < this.state.endTimeEpoch && !this.state.isLoading) { + this.setState({ + startTimeEpoch: newTime, + isLoading: true, + }); + } + }, + endTimeChange: function(newTime) { + var now = moment().unix(); + if (newTime > this.state.startTimeEpoch && newTime < now && !this.state.isLoading) { + this.setState({ + endTimeEpoch: newTime, + isLoading: true, + }); + } + }, + + // Gets new chart data from the backend. + // Recall that this must be called explicitly..it's a bit different + // than the normal react component connectivity. + // First figure out how to set the interval (or, more aptly, the bin width) + // and the x-axis formatter (see the d3 wiki on time formatting). + updateChartData: function() { + var interval, newXAxisFormatter, newYAxisFormatter, tablesColumnValueName; + var delta = this.state.endTimeEpoch - this.state.startTimeEpoch; + var tower_monitoring_stats = ['channel-load-stats-chart','noise-stats-chart','system-utilization-stats-chart','network-utilization-stats-chart'] + if (delta <= 60) { + interval = 'seconds'; + newXAxisFormatter = '%-H:%M:%S'; + } else if (delta <= (60 * 60)) { + interval = 'minutes'; + newXAxisFormatter = '%-H:%M'; + } else if (delta <= (24 * 60 * 60)) { + interval = 'hours'; + newXAxisFormatter = '%-H:%M'; + } else if (delta <= (7 * 24 * 60 * 60)) { + interval = 'hours'; + newXAxisFormatter = '%b %d, %-H:%M'; + } else if (delta <= (30 * 24 * 60 * 60)) { + interval = 'days'; + newXAxisFormatter = '%b %d'; + } else if (delta <= (365 * 24 * 60 * 60)) { + interval = 'days'; + newXAxisFormatter = '%b %d'; + } else { + interval = 'months'; + newXAxisFormatter = '%x'; + } + if (this.props.statTypes == 'total_data,uploaded_data,downloaded_data') { + newYAxisFormatter = '.1f'; + } else { + newYAxisFormatter = ''; + } + if (this.props.chartID == 'minutes-chart') { + newYAxisFormatter = '.2f'; + tablesColumnValueName = [{ + title: "Type" + }, { + title: "Minutes" + }] + + } else if (this.props.chartID == 'call-chart' | this.props.chartID == 'sms-chart') { + tablesColumnValueName = [{ + title: "Type" + }, { + title: "Count" + }] + } else if (this.props.chartID == 'duration-chart') { + + tablesColumnValueName = [{ + title: "Type" + }, { + title: "Seconds" + }] + } else if (this.props.chartID == 'topupSubscriber-chart') { + newYAxisFormatter = '.2f'; + tablesColumnValueName = [{ + title: "Denomination Bracket" + }, { + title: "Amount in" + }] + + } else if (this.props.chartID == 'topup-chart') { + newYAxisFormatter = '.2f'; + tablesColumnValueName = [{ + title: "Denomination Bracket" + }, { + title: "Count" + }] + } else if (this.props.chartID == 'data-chart') { + newYAxisFormatter = '.2f'; + tablesColumnValueName = [{ + title: "Type" + }, { + title: "MB" + }] + + } else if (this.props.chartID == 'load-transfer-chart') { + newYAxisFormatter = '.2f'; + } + else if (tower_monitoring_stats.indexOf(this.props.chartID) >=0) { + tablesColumnValueName = [{ + title: "Type" + }, { + title: "Average Value" + }] + if (this.props.chartID == 'noise-stats-chart'){ + newYAxisFormatter = '.5f'; + } + } + else { + tablesColumnValueName = [{ + title: "Type" + }, { + title: "Count" + }] + } + var queryParams = { + 'start-time-epoch': this.state.startTimeEpoch, + 'end-time-epoch': this.state.endTimeEpoch, + 'interval': interval, + 'stat-types': this.props.statTypes, + 'level-id': this.props.levelID, + 'aggregation': this.props.aggregation, + 'extras': this.props.extras, + 'dynamic-stat': this.props.dynamicStat, + 'topup-percent': this.props.topupPercent, + 'report-view': this.props.reportView + }; + var endpoint = this.props.endpoint + this.props.level; + $.get(endpoint, queryParams, function(data) { + this.setState({ + isLoading: false, + chartData: data, + xAxisFormatter: newXAxisFormatter, + yAxisFormatter: newYAxisFormatter, + tablesColumnValueName: tablesColumnValueName, + }); + }.bind(this)); + }, + + render: function() { + var fromDatepickerID = 'from-datepicker-' + this.props.chartID; + var toDatepickerID = 'to-datepicker-' + this.props.chartID; + return ( +
+

+ {this.props.title} +   +

+ past    + {this.props.buttons.map(function(buttonText, index) { + return ( + + ); + }, this)} + + + + +     + {this.props.icons.map(function(buttonText, index) { + return ( + + ); + }, this)} + + + + + +
+ ); + }, +}); + + +var secondsMap = { + 'hour': 60 * 60, + 'day': 24 * 60 * 60, + 'week': 7 * 24 * 60 * 60, + 'month': 30 * 24 * 60 * 60, + 'year': 365 * 24 * 60 * 60, +}; + +var add = function (a, b){ + return a + b ; +} + +// Builds the target chart from scratch. NVD3 surprisingly handles this well. +// domTarget is the SVG element's parent and data is the info that will be graphed. +var updateChart = function(domTarget, data, xAxisFormatter, yAxisFormatter, yAxisLabel, timezoneOffset, tooltipUnits, frontTooltip, chartType, domTargetId, tablesColumnValueName) { + // We pass in the timezone offset and calculate a locale offset. The former + // is based on the UserProfile's specified timezone and the latter is the user's + // computer's timezone offset. We manually shift the data to work around + // d3's conversion into the user's computer's timezone. Note that for my laptop + // in PST, the locale offset is positive (7hrs) while the UserProfile offset + // is negative (-7hrs). + var localeOffset = 60 * (new Date()).getTimezoneOffset(); + var shiftedData = []; + var changeAmount = []; + var tableData = []; + var tower_monitoring_chart = ['channel-load-stats-chart' , 'system-utilization-stats-chart' ,'network-utilization-stats-chart' ,'minutes-chart'] + + for (var index in data) { + var newSeries = { + 'key': data[index]['key'] + }; + var retailer = data[index]['retailer_table_data'] + + var newValues = []; + if (typeof(data[index]['values']) === 'object') { + for (var series_index in data[index]['values']) { + + var newValue = [ + moment(data[index]['values'][series_index][0] ), + data[index]['values'][series_index][1] + ]; + newValues.push(newValue); + changeAmount.push(newValue[1]) + } + // Get sum of the total charges + var sumAmount = changeAmount.reduce(add, 0); + var avg = sumAmount/changeAmount.length; + changeAmount = [] + // sum can be of all negative values + if (sumAmount < 0) { + newSeries['total'] = (sumAmount * -1); + } else { + if (tower_monitoring_chart.indexOf(domTargetId) >= 0) { + newSeries['total'] = avg + }else { + newSeries['total'] = (sumAmount); + } + } + newSeries['values'] = newValues; + } else { + newSeries['total'] = data[index]['values']; + } + // Get sum of the total charges + + + if ( domTargetId == 'call-sms-chart' || domTargetId == 'sms-chart' || domTargetId == 'call-chart' ) { + if (newSeries['total'] != undefined) { + newSeries['total'] = newSeries['total'] + tableData.push([newSeries['key'], newSeries['total']]); + }} + else if (domTargetId == 'data-chart' || domTargetId == 'topupSubscriber-chart' || domTargetId == 'call-billing-chart' || domTargetId == 'sms-billing-chart' || domTargetId == 'call-sms-billing-chart' || + tower_monitoring_chart.indexOf(domTargetId) >= 0){ + + if (newSeries['total'] != undefined) { + newSeries['total'] = newSeries['total'].toFixed(2); + tableData.push([newSeries['key'], newSeries['total']]); + } + } + else if (domTargetId == 'noise-stats-chart'){ + if (newSeries['total'] != undefined) { + newSeries['total'] = newSeries['total'].toFixed(4); + tableData.push([newSeries['key'], newSeries['total']]); + } + } + else if (domTargetId == 'load-transfer-chart' || domTargetId == 'add-money-chart') { + if (typeof(retailer !== 'undefined') && Object.keys(retailer).length >= 1) { + if (Object.keys(retailer).length >= 1) { + for (var series_index in data[index]['retailer_table_data']) { + var elements = data[index]['retailer_table_data'][series_index]; + tableData.push([series_index, elements.toFixed(2)]) + } + } + } else { + tableData.push(['No data', 'No data']) + } + } else { + newSeries['total'] = newSeries['total'] + tableData.push([newSeries['key'], newSeries['total']]); + } + + shiftedData.push(newSeries); + if (frontTooltip != "" && domTargetId == 'topupSubscriber-chart') { + tablesColumnValueName = [{ + title: "Denomination Bracket" + }, { + title: "Amount in (" + frontTooltip + ")" + }] + } else if (domTargetId == 'call-billing-chart' || domTargetId == 'sms-billing-chart' || domTargetId == 'call-sms-billing-chart') { + tablesColumnValueName = [{ + title: "Type" + }, { + title: "Amount in (" + frontTooltip + ")" + }] + } + else if (frontTooltip != "" && domTargetId == 'add-money-chart' || domTargetId == 'load-transfer-chart') { + tablesColumnValueName = [{ + title: "IMSI" + }, { + title: "Amount in (" + frontTooltip + ")" + }] + } else { + tablesColumnValueName = tablesColumnValueName + } + } + + $('.' + domTargetId).DataTable({ + data: tableData, + paging: false, + ordering: false, + info: false, + searching: false, + autoWidth: true, + scrollY: 320, + destroy: true, + columns: tablesColumnValueName + }); + + nv.addGraph(function() { + if (chartType == 'pie-chart') { + var chart = nv.models.pieChart() + .x(function(d) { + return d.key.replace(/[_]/g, ' '); + }) + .y(function(d) { + return d.total; + }) + .showLabels(true) + .labelType("percent"); + + chart.tooltipContent(function(key, x, y) { + chart.valueFormat(d3.format(yAxisFormatter)); + return '

' + key + '

' + '
' + '' + '

' + frontTooltip + x + '

' + '' + '

' + }); + + d3.select(domTarget) + .datum(shiftedData) + .transition().duration(350) + .call(chart); + } else if (chartType == 'bar-chart') { + + var chart = nv.models.multiBarChart() + .x(function(d) { + return d[0] + }) + .y(function(d) { + return d[1] + }) + .color(d3.scale.category10().range()) + .options({ + interpolate: 'monotone', + grid:true //<== this line here + }) + .showYAxis(true) + .stacked(false).showControls(false) + .reduceXTicks(true) + ; + chart.xAxis + .scale( d3.time.scale.utc()) + .showMaxMin(true); + chart.xAxis + .tickFormat(function(d){return d3.time.format(xAxisFormatter)(new Date(d));}) + chart.yAxis + .axisLabel(yAxisLabel) + .axisLabelDistance(25) + .tickFormat(d3.format(yAxisFormatter)); + // Fixes the axis-labels being rendered out of the SVG element. + chart.margin({ + right: 90 + }); + chart.tooltipContent(function(key, x, y) { + if (key == 'add_money') { + return '

' + frontTooltip + y + tooltipUnits + ' ' + '

' + '

' + x + '

'; + } else { + return '

' + frontTooltip + y + tooltipUnits + ' ' + key + '

' + '

' + x + '

'; + } + }); + d3.select(domTarget) + .datum(shiftedData) + .transition().duration(350) + .call(chart); + + } else { + var chart = nv.models.lineChart() + .x(function(d) { + return d[0] + }) + .y(function(d) { + if(domTarget =='#health-chart'){ + if (d[1] > 0){ + return 1 + } else { + return 0 + } + } else { + + return d[1] + } + + }) + .color(d3.scale.category10().range()) + .interpolate('monotone') + .showYAxis(true) + ; + chart.xAxis + .tickFormat(function(d) { + return d3.time.format(xAxisFormatter)(new Date(d)); + }); + // Fixes x-axis time alignment. + chart.xScale(d3.time.scale.utc()); + // For Health Graph + if(domTarget == '#call-chart') { + chart.yAxis + .axisLabel(yAxisLabel) + .axisLabelDistance(25) + .tickFormat(function(d) { + if (d == 1){ + return "UP"; + } else if (d > 0) { + return ; + } else { + return "DOWN"; + } + }); + } else { + chart.yAxis + .axisLabel(yAxisLabel) + .axisLabelDistance(25) + .tickFormat(d3.format(yAxisFormatter)); + } + + // Fixes the axis-labels being rendered out of the SVG element. + chart.margin({ + right: 80 + }); + chart.tooltipContent(function(key, x, y) { + if (key == 'health_state') { + key = '' + if (y =='UP'){ + y = 'UP' + } else { + y = 'DOWN' + } + } + return '

' + frontTooltip + y + tooltipUnits + ' ' + key + '

' + '

' + x + '

'; + }); + + d3.selectAll(".nv-axis path").style({ 'fill':'none', 'stroke': '#000' }); + d3.selectAll(".nv-chart path").style({ 'fill':'none'}); + d3.selectAll(".nv-line").style({ 'fill':'none'}); + + d3.select(domTarget) + .datum(shiftedData) + .transition().duration(350) + .call(chart); + } + // Resize the chart on window resize. + nv.utils.windowResize(chart.update); + return chart; + }); +}; + + +var TimeseriesChart = React.createClass({ + + getDefaultProps: function() { + return { + chartID: 'some-chart-id', + chartHeight: 380, + data: {}, + xAxisFormatter: '%x', + yAxisFormatter: '', + yAxisLabel: 'the y axis!', + timezoneOffset: 0, + frontTooltip: '', + tooltipUnits: '', + chartType:'', + activeView:'', + tablesColumnValueName:'' + } + }, + + chartIsFlat(results) { + return results.every(function(series) { + if(typeof(series['values']) === 'object'){ + return series['values'].every(function(pair) { + return pair[1] === 0; + }); + } else { + return series['values'] === 0; + } + }); + }, + + render: function() { + var results = this.props.data['results']; + var isFlatChart = !results || this.chartIsFlat(results); + var className = ['time-series-chart-container']; + var flatLineOverlay = null; + if (isFlatChart) { + flatLineOverlay = ( +
+
+ No data available for this range. +
+
+ ); + className.push('flat'); + } + if(this.props.activeView == 'list') { + $('#'+this.props.chartID + "-download").hide(); + return ( +
+ {flatLineOverlay} + + + ); + } else { + $('#'+this.props.chartID + "-download").show(); + return ( +
+ {flatLineOverlay} + +
+ ); + } + } +}); + + +var TimeSeriesChartElement = React.createClass({ + // When the request params have changed, get new data and rebuild the graph. + // We circumvent react's typical re-render cycle for this component by returning false. + shouldComponentUpdate: function(nextProps) { + this.props.activeView = nextProps.activeView; + var nextData = JSON.stringify(nextProps.data); + var prevData = JSON.stringify(this.props.data); + this.forceUpdate(); + if (nextData !== prevData) { + updateChart( + '#' + this.props.chartID, + nextProps.data['results'], + nextProps.xAxisFormatter, + nextProps.yAxisFormatter, + nextProps.yAxisLabel, + this.props.timezoneOffset, + this.props.tooltipUnits, + this.props.frontTooltip, + this.props.chartType, + this.props.chartID, + nextProps.tablesColumnValueName + ); + } + return false; + }, + componentDidUpdate(prevProps, prevState) { + var nextProps = prevProps; + // Update if we toggled a load + updateChart( + '#' + this.props.chartID, + nextProps.data['results'], + nextProps.xAxisFormatter, + nextProps.yAxisFormatter, + nextProps.yAxisLabel, + this.props.timezoneOffset, + this.props.tooltipUnits, + this.props.frontTooltip, + this.props.chartType, + this.props.chartID, + nextProps.tablesColumnValueName + ); + }, + render: function() { + var inlineStyles = { + height: this.props.chartHeight + }; + return ( + + + ); + } +}); + +var RangeButton = React.createClass({ + propTypes: { + buttonText: React.PropTypes.string.isRequired + }, + + getDefaultProps: function() { + return { + buttonText: 'day', + activeButtonText: '', + onButtonClick: null, + } + }, + + render: function() { + // Determine whether this particular button is active by checking + // this button's text vs the owner's knowledge of the active button. + // Then change styles accordingly. + var inlineStyles = { + marginRight: 20 + }; + if (this.props.buttonText == this.props.activeButtonText) { + inlineStyles.cursor = 'inherit'; + inlineStyles.color = 'black'; + inlineStyles.textDecoration = 'none'; + } else { + inlineStyles.cursor = 'pointer'; + } + return ( + + {this.props.buttonText} + + ); + }, + onThisClick: function(text) { + this.props.onButtonClick(text); + }, +}); + +var LoadingText = React.createClass({ + getDefaultProps: function() { + return { + visible: false, + } + }, + render: function() { + var inlineStyles = { + display: this.props.visible ? 'inline' : 'none', marginRight: 20 + }; + return ( + + (loading..) + + ); + }, +}); + +var DatePicker = React.createClass({ + getDefaultProps: function() { + return { + label: 'date', + pickerID: 'some-datetimepicker-id', + epochTime: 0, + onDatePickerChange: null, + datePickerOptions: { + icons: { + time: 'fa fa-clock-o', + date: 'fa fa-calendar', + up: 'fa fa-arrow-up', + down: 'fa fa-arrow-down', + previous: 'fa fa-arrow-left', + next: 'fa fa-arrow-right', + today: 'fa fa-circle-o', + }, + showTodayButton: true, + format: 'YYYY-MM-DD [at] h:mmA', + }, + dateFormat: 'YYYY-MM-DD [at] h:mmA', + } + }, + + componentDidMount: function() { + var formattedDate = moment.unix(this.props.epochTime).format(this.props.dateFormat); + var domTarget = '#' + this.props.pickerID; + $(domTarget).keydown(function(e) { + e.preventDefault(); + return false; + }); + $(domTarget) + .datetimepicker(this.props.datePickerOptions) + .data('DateTimePicker') + .date(formattedDate); + var dateFormat = this.props.dateFormat; + var handler = this.props.onDatePickerChange; + $(domTarget).on('dp.change', function(event) { + var newEpochTime = moment(event.target.value, dateFormat).unix(); + handler(newEpochTime); + }); + }, + + shouldComponentUpdate: function(nextProps) { + var formattedDate = moment.unix(nextProps.epochTime).format(nextProps.dateFormat); + var domTarget = '#' + nextProps.pickerID; + $(domTarget).data('DateTimePicker').date(formattedDate); + return false + }, + + render: function() { + return ( + + + + + ); + }, +}); + +var DownloadButton = React.createClass({ + getDefaultProps: function() { + return { + visible: false, + startimeepoch:'', + endTimeEpoch:'', + defaultButtonText: 'week', + statsType:'', + onButtonClick: null, + activeView:'graph' + } + }, + componentWillMount() { + this.id = this.props.chartID + "-download"; + }, + componentDidMount: function() { + var domTargetId = this.props.chartID; + var btn = document.getElementById(this.id); + var filename = this.props.reporttype+".png"; + + btn.addEventListener('click', function () { + saveSvgAsPng(document.getElementById(domTargetId), filename, {backgroundColor:"#FFF"}); + }); + }, + render: function() { + return ( + + + +    + + + + + ); + }, + onThisClick: function(text) { + this.props.onButtonClick(); + } +}); + +var ViewButton = React.createClass({ + propTypes: { + buttonText: React.PropTypes.string.isRequired + }, + + getDefaultProps: function() { + return { + buttonText: 'graph', + activeView: '', + onButtonClick: null, + } + }, + + render: function() { + // Determine whether this particular button is active by checking + // this button's text vs the owner's knowledge of the active button. + // Then change styles accordingly. + var inlineStyles = { + marginRight: 20 + }; + if (this.props.buttonText == this.props.activeView) { + inlineStyles.cursor = 'inherit'; + inlineStyles.color = 'black'; + inlineStyles.textDecoration = 'none'; + } else { + inlineStyles.cursor = 'pointer'; + } + if(this.props.buttonText == 'graph') { + return ( + + + + ); + } else { + return ( + + + + ); + } + }, + + onThisClick: function(text) { + this.props.onButtonClick(text); + }, +}); + +var Table = React.createClass({ + shouldComponentUpdate: function(nextProps) { + this.props.activeView = nextProps.activeView; + + var nextData = JSON.stringify(nextProps.data); + var prevData = JSON.stringify(this.props.data); + this.forceUpdate(); + if (nextData !== prevData) { + updateChart( + '#' + this.props.chartID, + nextProps.data['results'], + nextProps.xAxisFormatter, + nextProps.yAxisFormatter, + nextProps.yAxisLabel, + this.props.timezoneOffset, + this.props.tooltipUnits, + this.props.frontTooltip, + this.props.chartType, + this.props.chartID, + nextProps.tablesColumnValueName + ); + } + return false; + }, + componentDidUpdate(prevProps, prevState) { + var nextProps = prevProps; + // Update if we toggled a load + updateChart( + '#' + this.props.chartID, + nextProps.data['results'], + nextProps.xAxisFormatter, + nextProps.yAxisFormatter, + nextProps.yAxisLabel, + this.props.timezoneOffset, + this.props.tooltipUnits, + this.props.frontTooltip, + this.props.chartType, + this.props.chartID, + nextProps.tablesColumnValueName + ); + }, + + render: function() { + var inlineStyles = { + 'min-height': '360px', + 'margin-top': '20px' + }; + return ( +
+
+
+ ); + } +}); diff --git a/cloud/endagaweb/stats_app/stats_client.py b/cloud/endagaweb/stats_app/stats_client.py index 5ebe043f..d59ab6a4 100644 --- a/cloud/endagaweb/stats_app/stats_client.py +++ b/cloud/endagaweb/stats_app/stats_client.py @@ -8,16 +8,19 @@ of patent rights can be found in the PATENTS file in the same directory. """ -from datetime import datetime import time +from datetime import datetime, timedelta +from operator import itemgetter +import calendar -from django.db.models import aggregates -from django.db.models import Q import pytz import qsstats - +from dateutil.rrule import rrule, MONTHLY +from django.db.models import Q +from django.db.models import aggregates from endagaweb import models - +from decimal import * +from pytz import timezone CALL_KINDS = [ 'local_call', 'local_recv_call', 'outside_call', 'incoming_call', @@ -25,9 +28,21 @@ SMS_KINDS = [ 'local_sms', 'local_recv_sms', 'outside_sms', 'incoming_sms', 'free_sms', 'error_sms'] -USAGE_EVENT_KINDS = CALL_KINDS + SMS_KINDS + ['gprs'] +SUBSCRIBER_KINDS = ['Provisioned', 'deactivate_number'] +ZERO_BALANCE_SUBSCRIBER = ['zero_balance_subscriber'] +INACTIVE_SUBSCRIBER = ['expired', 'first_expired', 'blocked'] +BTS_STATUS = ['health_state'] +TRANSFER_KINDS = ['transfer', 'add_money'] +WATERFALL_KINDS = ['loader', 'reload_rate', 'reload_amount', + 'reload_transaction', 'average_load', 'average_frequency'] +DENOMINATION_KINDS = ['start_amount', 'end_amount'] +BTS_KINDS = ['bts up', 'bts down'] +USAGE_EVENT_KINDS = CALL_KINDS + SMS_KINDS + ['gprs'] + SUBSCRIBER_KINDS + \ + TRANSFER_KINDS + WATERFALL_KINDS TIMESERIES_STAT_KEYS = [ - 'ccch_sdcch4_load', 'tch_f_max', 'tch_f_load', 'sdcch8_max', 'tch_f_pdch_load', 'tch_f_pdch_max', 'tch_h_load', 'tch_h_max', 'sdcch8_load', 'ccch_sdcch4_max', + 'ccch_sdcch4_load', 'tch_f_max', 'tch_f_load', 'sdcch8_max', + 'tch_f_pdch_load', 'tch_f_pdch_max', 'tch_h_load', 'tch_h_max', + 'sdcch8_load', 'ccch_sdcch4_max', 'sdcch_load', 'sdcch_available', 'tchf_load', 'tchf_available', 'pch_active', 'pch_total', 'agch_active', 'agch_pending', 'gprs_current_pdchs', 'gprs_utilization_percentage', 'noise_rssi_db', @@ -99,6 +114,9 @@ def aggregate_timeseries(self, param, **kwargs): end_time_epoch = kwargs.pop('end_time_epoch', -1) interval = kwargs.pop('interval', 'months') aggregation = kwargs.pop('aggregation', 'count') + report_view = kwargs.pop('report_view', 'list') + imsi_dict = {} + imsi_list = [] # Turn the start and end epoch timestamps into datetimes. start = datetime.fromtimestamp(start_time_epoch, pytz.utc) if end_time_epoch != -1: @@ -107,24 +125,47 @@ def aggregate_timeseries(self, param, **kwargs): end = datetime.fromtimestamp(time.time(), pytz.utc) # Build the queryset -- first determine if we're dealing with # UsageEvents or TimeseriesStats. - filters = None if param in USAGE_EVENT_KINDS: objects = models.UsageEvent.objects filters = Q(kind=param) + elif param in ZERO_BALANCE_SUBSCRIBER: + objects = models.UsageEvent.objects + filters = Q(oldamt__gt=0, newamt__lte=0) + elif param in INACTIVE_SUBSCRIBER: + aggregation = 'valid_through' + objects = models.Subscriber.objects + if param =='blocked': + filters = Q(is_blocked='t') + else: + filters = Q(state=param, is_blocked='f') elif param in TIMESERIES_STAT_KEYS: objects = models.TimeseriesStat.objects filters = Q(key=param) + elif param in BTS_KINDS: + objects = models.SystemEvent.objects + filters = Q(type=param, bts_id=self.level_id) + else: + # For Dynamic Kinds coming from Database currently for Top Up + objects = models.UsageEvent.objects + filters = Q(kind='transfer') # Filter by infrastructure level. if self.level == 'tower': filters = filters & Q(bts__id=self.level_id) - elif self.level == 'network': + elif self.level == 'network' and param not in BTS_KINDS: filters = filters & Q(network__id=self.level_id) elif self.level == 'global': pass - # Create the queryset itself. + if kwargs.has_key('query'): + filters = filters & kwargs.pop('query') + if report_view == 'value': + filters = filters & Q(date__lte=end) & Q(date__gte=start) + result = models.UsageEvent.objects.filter(filters).values_list( + 'subscriber_id', flat=True).distinct() + return list(result) queryset = objects.filter(filters) + # Use qsstats to aggregate the queryset data on an interval. - if aggregation == 'duration': + if aggregation in ['duration', 'duration_minute']: queryset_stats = qsstats.QuerySetStats( queryset, 'date', aggregate=aggregates.Sum('billsec')) elif aggregation == 'up_byte_count': @@ -136,24 +177,122 @@ def aggregate_timeseries(self, param, **kwargs): elif aggregation == 'average_value': queryset_stats = qsstats.QuerySetStats( queryset, 'date', aggregate=aggregates.Avg('value')) + elif aggregation == 'valid_through': + queryset_stats = qsstats.QuerySetStats(queryset, 'valid_through') + elif aggregation == 'reload_transcation_count': + queryset_stats = qsstats.QuerySetStats( + queryset, 'date', aggregate=(aggregates.Count('to_number'))) + elif aggregation == 'reload_transcation_sum': + queryset_stats = qsstats.QuerySetStats( + queryset, 'date', + aggregate=(aggregates.Sum('change') * 0.00001)) + # Sum of change in amounts for SMS/CALL + elif aggregation in ['transaction_sum', 'transcation_count']: + # Change is negative value, set positive for charts + if report_view == 'summary': + adjust = -10 + elif param == 'transfer': + adjust = -0.00001 + else: + adjust = 1 + queryset_stats = qsstats.QuerySetStats( + queryset, 'date', aggregate=(aggregates.Sum('change') * adjust)) + + if report_view =='table_view': + imsi = {} + for qs in queryset_stats.qs.filter( + date__range=(str(start), str(end))): + if qs.subscriber.imsi in imsi: + imsi[qs.subscriber.imsi] += round(qs.change * 0.00001, 2) + else: + imsi[qs.subscriber.imsi] = round(qs.change * 0.00001, 2) + return imsi + + # if percentage is set for top top-up + percentage = kwargs['topup_percent'] + top_numbers = 1 + if percentage is not None: + numbers = {} + percentage = float(percentage) / 100 + # Create subscribers dict + for query in queryset: + numbers[query.to_number] = 0 + for query in queryset_stats.qs: + numbers[query.to_number] += (query.change * -1) + top_numbers = int(len(numbers) * percentage) + top_numbers = top_numbers if top_numbers > 1 else top_numbers + top_subscribers = list(dict( + sorted(numbers.iteritems(), key=itemgetter(1), + reverse=True)[:top_numbers]).keys()) + queryset = queryset_stats.qs.filter( + Q(to_number__in=top_subscribers)) + # Count the numbers + if aggregation == 'transcation_count': + queryset_stats = qsstats.QuerySetStats( + queryset, 'date', aggregate=( + aggregates.Count('to_number'))) + else: + # Sum of change + queryset_stats = qsstats.QuerySetStats( + queryset, 'date', aggregate=( + aggregates.Sum('change'))) + elif aggregation == 'loader': + queryset_stats = qsstats.QuerySetStats( + queryset, 'date', aggregate=aggregates.Count('subscriber_id')) else: queryset_stats = qsstats.QuerySetStats(queryset, 'date') - timeseries = queryset_stats.time_series(start, end, interval=interval) - # The timeseries results is a list of (datetime, value) pairs. We need + + # The timeseries results is a list of (datetime, value) pairs. We need # to convert the datetimes to timestamps with millisecond precision and # then zip the pairs back together. + timeseries = queryset_stats.time_series(start, end, + interval=interval) + if param in BTS_KINDS: + timeseries = queryset_stats.time_series(start, end, + date_field='date', + interval='minutes') + for idx, val in enumerate(timeseries): + # remove unwanted objects count + if val[1] == 0: + timeseries.pop(idx) + else: + timeseries = queryset_stats.time_series(start, end, + interval=interval) + datetimes, values = zip(*timeseries) + if report_view == 'summary': + # Return sum count for pie-chart and table view + if aggregation == 'transaction_sum': + # When kind is change + return sum(values) * 0.000001 + elif aggregation == 'duration_minute': + return (sum(values) / 60.00) or 0 + else: + return sum(values) + timestamps = [ int(time.mktime(dt.timetuple()) * 1e3 + dt.microsecond / 1e3) for dt in datetimes ] # Round the stats values when necessary. rounded_values = [] - for value in values: - if round(value) != round(value, 2): - rounded_values.append(round(value, 2)) - else: - rounded_values.append(value) + if param in BTS_KINDS: + for value in values: + if param == 'bts down': + val = -1 if value == 1 else 0 + elif param == 'bts up': + val = 1 if value == 1 else 0 + rounded_values.append(val) + else: + for value in values: + if value < 0: + rounded_values.append(value * -0.00001) + elif round(value) != round(value, 2): + rounded_values.append(round(value, 2)) + elif param =='add_money': + rounded_values.append(round(value*0.0001, 2)) + else: + rounded_values.append(value) return zip(timestamps, rounded_values) diff --git a/cloud/endagaweb/stats_app/views.py b/cloud/endagaweb/stats_app/views.py index df6c88df..ce911ff5 100644 --- a/cloud/endagaweb/stats_app/views.py +++ b/cloud/endagaweb/stats_app/views.py @@ -14,10 +14,17 @@ from rest_framework import response from rest_framework import status from rest_framework import views +import pytz +from rest_framework.authtoken.models import Token +import stripe +from guardian.shortcuts import get_objects_for_user +from ccm.common.currency import parse_credits, humanize_credits, \ + CURRENCIES, Money from endagaweb.stats_app import stats_client - +from endagaweb.models import (UserProfile, Ledger, Subscriber, UsageEvent, + Network, PendingCreditUpdate, Number) # Set the stat types that we can query for. Note that some of these kinds are # 'faux kinds' in that the stats client aggregates the true UsageEvent kinds # for these other categories: sms, call, uploaded_data, downloaded_data, @@ -26,13 +33,25 @@ CALL_KINDS = stats_client.CALL_KINDS + ['call'] GPRS_KINDS = ['total_data', 'uploaded_data', 'downloaded_data'] TIMESERIES_STAT_KEYS = stats_client.TIMESERIES_STAT_KEYS -VALID_STATS = SMS_KINDS + CALL_KINDS + GPRS_KINDS + TIMESERIES_STAT_KEYS +SUBSCRIBER_KINDS = stats_client.SUBSCRIBER_KINDS + \ + stats_client.ZERO_BALANCE_SUBSCRIBER + \ + stats_client.INACTIVE_SUBSCRIBER +BTS_STATUS = stats_client.BTS_STATUS +WATERFALL_KINDS = ['loader', 'reload_rate', 'reload_amount', + 'reload_transaction', 'average_load', 'average_frequency'] +NON_LOADER_KINDS = ['non_loader_base', 'cumulative_base'] +DENOMINATION_KINDS = stats_client.DENOMINATION_KINDS # Set valid intervals. INTERVALS = ['years', 'months', 'weeks', 'days', 'hours', 'minutes'] +TRANSFER_KINDS = stats_client.TRANSFER_KINDS +VALID_STATS = SMS_KINDS + CALL_KINDS + GPRS_KINDS + TIMESERIES_STAT_KEYS + \ + TRANSFER_KINDS + SUBSCRIBER_KINDS + WATERFALL_KINDS + \ + BTS_STATUS + DENOMINATION_KINDS + NON_LOADER_KINDS # Set valid aggregation types. AGGREGATIONS = ['count', 'duration', 'up_byte_count', 'down_byte_count', - 'average_value'] - + 'average_value', 'transaction_sum', 'transcation_count', + 'duration_minute'] +REPORT_VIEWS = ['summary', 'list'] # Any requested start time earlier than this date will be set to this date. # This comes from a bug where UEs were generated at an astounding rate in @@ -52,6 +71,9 @@ def parse_query_params(params): 'stat-types': ['sms'], 'level-id': -1, 'aggregation': 'count', + 'report-view': 'list', + 'extras': [], + 'topup-percent': None, } # Override defaults with any query params that have been set, if the # query params are valid. @@ -74,10 +96,20 @@ def parse_query_params(params): # If nothing validated, we just leave the stat-types as the default. if validated_types: parsed_params['stat-types'] = validated_types + # Check if stat-type is dynamic currently for denominations + if params.has_key('dynamic-stat') and bool(params['dynamic-stat']): + parsed_params['stat-types'] = stat_types + # For filtering top topups as per percentage + if params.has_key('topup-percent'): + parsed_params['topup-percent'] = params['topup-percent'] if 'level-id' in params: parsed_params['level-id'] = int(params['level-id']) if 'aggregation' in params and params['aggregation'] in AGGREGATIONS: parsed_params['aggregation'] = params['aggregation'] + if 'extras' in params: + parsed_params['extras'] = params['extras'].split(',') + if 'report-view' in params and params['report-view'] in REPORT_VIEWS: + parsed_params['report-view'] = params['report-view'] return parsed_params @@ -125,7 +157,7 @@ def get(self, request, infrastructure_level): data = { 'results': [], } - for stat_type in params['stat-types']: + for index, stat_type in enumerate(params['stat-types']): # Setup the appropriate stats client, SMS, call or GPRS. if stat_type in SMS_KINDS: client_type = stats_client.SMSStatsClient @@ -135,6 +167,18 @@ def get(self, request, infrastructure_level): client_type = stats_client.GPRSStatsClient elif stat_type in TIMESERIES_STAT_KEYS: client_type = stats_client.TimeseriesStatsClient + elif stat_type in SUBSCRIBER_KINDS: + client_type = stats_client.SubscriberStatsClient + elif stat_type in TRANSFER_KINDS: + client_type = stats_client.TransferStatsClient + elif stat_type in BTS_STATUS: + client_type = stats_client.BTSStatsClient + elif stat_type in WATERFALL_KINDS: + client_type = stats_client.WaterfallStatsClient + elif stat_type in NON_LOADER_KINDS: + client_type = stats_client.NonLoaderStatsClient + else: + client_type = stats_client.TopUpStatsClient # Instantiate the client at an infrastructure level. if infrastructure_level == 'global': client = client_type('global') @@ -142,6 +186,10 @@ def get(self, request, infrastructure_level): client = client_type('network', params['level-id']) elif infrastructure_level == 'tower': client = client_type('tower', params['level-id']) + try: + extra_param = params['extras'][index] + except IndexError: + extra_param = None # Get timeseries results and append it to data. results = client.timeseries( stat_type, @@ -149,10 +197,24 @@ def get(self, request, infrastructure_level): start_time_epoch=params['start-time-epoch'], end_time_epoch=params['end-time-epoch'], aggregation=params['aggregation'], + report_view=params['report-view'], + extras=extra_param, + topup_percent=params['topup-percent'] ) + if stat_type in TRANSFER_KINDS: + table_view = client.timeseries( + stat_type, + start_time_epoch=params['start-time-epoch'], + end_time_epoch=params['end-time-epoch'], + aggregation=params['aggregation'], + report_view ="table_view" + ) + else: + table_view = {} data['results'].append({ "key": stat_type, - "values": results + "values": results, + "retailer_table_data":table_view }) # Convert params.stat_types back to CSV and echo back the request. diff --git a/cloud/endagaweb/templates/dashboard/layout.html b/cloud/endagaweb/templates/dashboard/layout.html index c4567a7f..37395689 100644 --- a/cloud/endagaweb/templates/dashboard/layout.html +++ b/cloud/endagaweb/templates/dashboard/layout.html @@ -8,6 +8,7 @@ {% endcomment %} {% load apptags %} +{% load guardian_tags %} @@ -23,23 +24,19 @@ - + {% block pagestyle %} {% endblock %} - - {% if user_profile.user.is_staff %} + {% get_obj_perms request.user for network as 'user_permission' %} + {% if user_profile.user.is_superuser %}