From 5bbdf18b18cdff725028f828bda56409a20dc0a9 Mon Sep 17 00:00:00 2001 From: projectsodoo Date: Mon, 25 Jan 2021 12:02:34 +0530 Subject: [PATCH] Report updated --- cor_custom/models/project.py | 4 +- cor_custom/report/project_hours_report.py | 52 +++ .../report/project_hours_report_view.xml | 97 ++++ project_report/__manifest__.py | 3 + .../static/src/js/graph_renderer.js | 428 +++++++++++++++++- project_report/views/assets.xml | 1 + 6 files changed, 579 insertions(+), 6 deletions(-) create mode 100755 cor_custom/report/project_hours_report.py create mode 100755 cor_custom/report/project_hours_report_view.xml diff --git a/cor_custom/models/project.py b/cor_custom/models/project.py index c9d707c..60bcfbe 100755 --- a/cor_custom/models/project.py +++ b/cor_custom/models/project.py @@ -71,7 +71,7 @@ class Project(models.Model): comment = fields.Text(string='Comment') tag_ids = fields.Many2many('custom.project.tags', string='Tags') - @api.onchange('allowed_internal_user_ids') + """@api.onchange('allowed_internal_user_ids') def onchange_add_allowed_internal_users(self): user_list = [] consultant_list = [] @@ -89,7 +89,7 @@ class Project(models.Model): if employee_obj: for employee in employee_obj: self.sale_line_employee_ids.create({'project_id': self._origin.id, - 'employee_id': employee}) + 'employee_id': employee})""" def _onchange_calculate_timesheet_hours(self): diff --git a/cor_custom/report/project_hours_report.py b/cor_custom/report/project_hours_report.py new file mode 100755 index 0000000..0620365 --- /dev/null +++ b/cor_custom/report/project_hours_report.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, tools + + +class BudgetHrsAnalysis(models.Model): + + _name = "project.consultant.hrs.report" + _description = "Project consultant hours analysis report" + #_order = 'project_id desc, hours_type asc, end_date desc, employee_id' + _order = 'project_id desc, 1, 2, hours_type, employee_id, end_date, start_date' + _auto = False + + project_id = fields.Many2one('project.project', string='Project', readonly=True) + employee_id = fields.Many2one('hr.employee', string='Consultant', readonly=True) + start_date = fields.Date(string='Start Date', readonly=True) + end_date = fields.Date(string='End Date', readonly=True) + hours_type = fields.Char(string="Hours Type", readonly=True) + hours = fields.Float("Hours", digits=(16, 2), readonly=True, group_operator="sum") + percentage = fields.Float("Percentage (%)") + #roworder = fields.Integer("Row Order") + #ptime = fields.Char(string="Time", readonly=True) + + """def _generate_order_by(self, order_spec, query): + order_by = super(BudgetHrsAnalysis, self)._generate_order_by(order_spec, query) + print("order by>>>>>>>>>>>>>>", order_by) + return order_by""" + + def init(self): + '''Create the view''' + tools.drop_view_if_exists(self._cr, self._table) + self._cr.execute(""" + CREATE OR REPLACE VIEW %s AS ( + SELECT ROW_NUMBER() OVER() as id, project_id, start_date, end_date, employee_id, hours_type, hours, percentage + from ( + select project_id as project_id, start_date as start_date, end_date as end_date, + employee_id as employee_id, 'Budgeted Hours for period' as hours_type, budgeted_hours as hours, + percentage as percentage + from project_consultant_hrs ph + union + select project_id as project_id, start_date as start_date, end_date as end_date, + employee_id as employee_id, 'Actual Hours for period' as hours_type, actual_hours as hours, + actual_percentage as percentage + from project_consultant_hrs ph + ) as res order by + project_id desc, + hours_type, + employee_id, end_date, start_date + )""" % (self._table,)) + + diff --git a/cor_custom/report/project_hours_report_view.xml b/cor_custom/report/project_hours_report_view.xml new file mode 100755 index 0000000..07026d7 --- /dev/null +++ b/cor_custom/report/project_hours_report_view.xml @@ -0,0 +1,97 @@ + + + + + project.consultant.hrs.report.form + project.consultant.hrs.report + +
+ + + + + + + + + +
+
+
+ + + project.consultant.hrs.report.tree + project.consultant.hrs.report + + + + + + + + + + + + + + + project.consultant.hrs.report.search + project.consultant.hrs.report + + + + + + + + + + + + + + + + + + project.consultant.hrs.report.graph + project.consultant.hrs.report + + + + + + + + + + Consultant Allocation + ir.actions.act_window + project.consultant.hrs.report + tree,graph + + { + 'search_default_project': 1, + 'search_default_consultant': 1, + } + + + + + + Project Consul Report Hours + project.project + + +
+ +
+
+
+ +
diff --git a/project_report/__manifest__.py b/project_report/__manifest__.py index 785de93..dccee08 100755 --- a/project_report/__manifest__.py +++ b/project_report/__manifest__.py @@ -20,6 +20,9 @@ 'report/project_budget_hrs_analysis_views.xml', 'report/project_budget_amt_analysis_views.xml', ], + 'qweb': [ + "static/src/xml/base.xml", + ], 'auto_install': False, 'installable': True, } \ No newline at end of file diff --git a/project_report/static/src/js/graph_renderer.js b/project_report/static/src/js/graph_renderer.js index 6708e77..ff2506f 100755 --- a/project_report/static/src/js/graph_renderer.js +++ b/project_report/static/src/js/graph_renderer.js @@ -1,10 +1,29 @@ odoo.define("project_report.GraphRenderer", function(require) { var GraphRenderer = require("web.GraphRenderer"); -var config = require("web.config"); +var AbstractRenderer = require('web.AbstractRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var dataComparisonUtils = require('web.dataComparisonUtils'); var fieldUtils = require('web.field_utils'); -// Hide top legend when too many items for device size -var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class); +var _t = core._t; +var DateClasses = dataComparisonUtils.DateClasses; +var qweb = core.qweb; + +var CHART_TYPES = ['pie', 'bar', 'horizontalBar', 'line']; + +var COLORS = ["#1f77b4", "#ff7f0e", "#aec7e8", "#ffbb78", "#2ca02c", "#98df8a", "#d62728", + "#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", + "#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"]; +var COLOR_NB = COLORS.length; + +function hexToRGBA(hex, opacity) { + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + var rgb = result.slice(1, 4).map(function (n) { + return parseInt(n, 16); + }).join(','); + return 'rgba(' + rgb + ',' + opacity + ')'; +} // used to format values in tooltips and yAxes. var FORMAT_OPTIONS = { @@ -21,6 +40,13 @@ var FORMAT_OPTIONS = { }, }; +var NO_DATA = [_t('No data')]; +NO_DATA.isNoData = true; + +// Hide top legend when too many items for device size +var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class); + + GraphRenderer.include({ init: function(parent, state, params) { this._super.apply(this, arguments); @@ -32,13 +58,333 @@ var FORMAT_OPTIONS = { }, _formatValue: function (value) { - //this._super.apply(this, arguments); + //this._super.apply(this, arguments); var measureField = this.fields[this.state.measure]; var formatter = fieldUtils.format.float; var formatedValue = formatter(value, measureField, FORMAT_OPTIONS); return formatedValue; }, + /** + * Determines the initial section of the labels array + * over a dataset has to be completed. The section only depends + * on the datasets origins. + * + * @private + * @param {number} originIndex + * @param {number} defaultLength + * @returns {number} + */ + _getDatasetDataLength: function (originIndex, defaultLength) { + if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode) && this.state.comparisonFieldIndex === 0) { + return this.dateClasses.dateSets[originIndex].length; + } + return defaultLength; + }, + + /** + * Determines over which label is the data point + * + * @private + * @param {Object} dataPt + * @returns {Array} + */ + _getLabel: function (dataPt) { + var i = this.state.comparisonFieldIndex; + if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode)) { + if (i === 0) { + return [this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i])]; + } else { + return dataPt.labels.slice(0, 1); + } + } else if (i === 0) { + return Array.prototype.concat.apply([], [ + this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i]), + dataPt.labels.slice(i+1) + ]); + } else { + return dataPt.labels; + } + }, + + /** + * Returns an object used to style chart elements independently from the datasets. + * + * @private + * @returns {Object} + */ + _getElementOptions: function () { + var elementOptions = {}; + if (this.state.mode === 'bar' || this.state.mode === 'horizontalBar') { + elementOptions.rectangle = {borderWidth: 1}; + } else if (this.state.mode === 'line') { + elementOptions.line = { + tension: 0, + fill: false, + }; + } + return elementOptions; + }, + + /** + * Determines to which dataset belong the data point + * + * @private + * @param {Object} dataPt + * @returns {string} + */ + _getDatasetLabel: function (dataPt) { + if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode)) { + // ([origin] + second to last groupBys) or measure + var datasetLabel = dataPt.labels.slice(1).join("/"); + if (this.state.origins.length > 1) { + datasetLabel = this.state.origins[dataPt.originIndex] + + (datasetLabel ? ('/' + datasetLabel) : ''); + } + datasetLabel = datasetLabel || this.fields[this.state.measure].string; + return datasetLabel; + } + return this.state.origins[dataPt.originIndex]; + }, + + + _filterDataPoints: function () { + var dataPoints = []; + if (_.contains(['bar', 'horizontalBar', 'pie'], this.state.mode)) { + dataPoints = this.state.dataPoints.filter(function (dataPt) { + return dataPt.count > 0; + }); + } else if (this.state.mode === 'line') { + var counts = 0; + this.state.dataPoints.forEach(function (dataPt) { + if (dataPt.labels[0] !== _t("Undefined")) { + dataPoints.push(dataPt); + } + counts += dataPt.count; + }); + // data points with zero count might have been created on purpose + // we only remove them if there are no data point with positive count + if (counts === 0) { + dataPoints = []; + } + } + return dataPoints; + }, + + + /** + * Returns the options used to generate the chart legend. + * + * @private + * @param {Number} datasetsCount + * @returns {Object} + */ + _getLegendOptions: function (datasetsCount) { + var legendOptions = { + display: datasetsCount <= MAX_LEGEND_LENGTH, + // position: this.state.mode === 'pie' ? 'right' : 'top', + position: 'top', + onHover: this._onlegendTooltipHover.bind(this), + onLeave: this._onLegendTootipLeave.bind(this), + }; + var self = this; + if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode)) { + var referenceColor; + if (this.state.mode === 'bar') { + referenceColor = 'backgroundColor'; + } else { + referenceColor = 'borderColor'; + } + legendOptions.labels = { + generateLabels: function (chart) { + var data = chart.data; + return data.datasets.map(function (dataset, i) { + return { + text: self._shortenLabel(dataset.label), + fullText: dataset.label, + fillStyle: dataset[referenceColor], + hidden: !chart.isDatasetVisible(i), + lineCap: dataset.borderCapStyle, + lineDash: dataset.borderDash, + lineDashOffset: dataset.borderDashOffset, + lineJoin: dataset.borderJoinStyle, + lineWidth: dataset.borderWidth, + strokeStyle: dataset[referenceColor], + pointStyle: dataset.pointStyle, + datasetIndex: i, + }; + }); + }, + }; + } else { + legendOptions.labels = { + generateLabels: function (chart) { + var data = chart.data; + var metaData = data.datasets.map(function (dataset, index) { + return chart.getDatasetMeta(index).data; + }); + return data.labels.map(function (label, i) { + var hidden = metaData.reduce( + function (hidden, data) { + if (data[i]) { + hidden = hidden || data[i].hidden; + } + return hidden; + }, + false + ); + var fullText = self._relabelling(label); + var text = self._shortenLabel(fullText); + return { + text: text, + fullText: fullText, + fillStyle: label.isNoData ? '#d3d3d3' : self._getColor(i), + hidden: hidden, + index: i, + }; + }); + }, + }; + } + return legendOptions; + }, + + _isRedirectionEnabled: function () { + return !this.disableLinking && + (this.state.mode === 'bar' || this.state.mode === 'horizontalBar' || this.state.mode === 'pie'); + }, + + /** + * Determine how to relabel a label according to a given origin. + * The idea is that the getLabel function is in general not invertible but + * it is when restricted to the set of dataPoints coming from a same origin. + + * @private + * @param {Array} label + * @param {Array} originIndex + * @returns {string} + */ + _relabelling: function (label, originIndex) { + if (label.isNoData) { + return label[0]; + } + var i = this.state.comparisonFieldIndex; + if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode) && i === 0) { + // here label is an array of length 1 and contains a number + return this.dateClasses.representative(label, originIndex) || ''; + } else if (this.state.mode === 'pie' && i === 0) { + // here label is an array of length at least one containing string or numbers + var labelCopy = label.slice(0); + if (originIndex !== undefined) { + labelCopy.splice(i, 1, this.dateClasses.representative(label[i], originIndex)); + } else { + labelCopy.splice(i, 1, this.dateClasses.dateClassMembers(label[i])); + } + return labelCopy.join('/'); + } + // here label is an array containing strings or numbers. + return label.join('/') || _t('Total'); + }, + + async _renderView() { + if (this.chart) { + this.chart.destroy(); + } + this.$el.empty(); + if (!_.contains(CHART_TYPES, this.state.mode)) { + this.trigger_up('warning', { + title: _t('Invalid mode for chart'), + message: _t('Cannot render chart with mode : ') + this.state.mode + }); + } + var dataPoints = this._filterDataPoints(); + dataPoints = this._sortDataPoints(dataPoints); + if (this.isInDOM) { + this._renderTitle(); + + // detect if some pathologies are still present after the filtering + if (this.state.mode === 'pie') { + const someNegative = dataPoints.some(dataPt => dataPt.value < 0); + const somePositive = dataPoints.some(dataPt => dataPt.value > 0); + if (someNegative && somePositive) { + const context = { + title: _t("Invalid data"), + description: [ + _t("Pie chart cannot mix positive and negative numbers. "), + _t("Try to change your domain to only display positive results") + ].join("") + }; + this._renderNoContentHelper(context); + return; + } + } + + if (this.state.isSample && !this.isEmbedded) { + this._renderNoContentHelper(); + } + + // only render the graph if the widget is already in the DOM (this + // happens typically after an update), otherwise, it will be + // rendered when the widget will be attached to the DOM (see + // 'on_attach_callback') + var $canvasContainer = $('
', {class: 'o_graph_canvas_container'}); + var $canvas = $('').attr('id', this.chartId); + $canvasContainer.append($canvas); + this.$el.append($canvasContainer); + + var i = this.state.comparisonFieldIndex; + if (i === 0) { + this.dateClasses = this._getDateClasses(dataPoints); + } + if (this.state.mode === 'bar') { + this._renderBarChart(dataPoints); + } else if (this.state.mode === 'horizontalBar') { + this._renderhorizontalBarChart(dataPoints); + } + else if (this.state.mode === 'line') { + this._renderLineChart(dataPoints); + } else if (this.state.mode === 'pie') { + this._renderPieChart(dataPoints); + } + } + }, + + _getScaleHBarOptions: function () { + var self = this; + if (_.contains(['horizontalBar'], this.state.mode)) { + return { + xAxes: [{ + type: 'linear', + scaleLabel: { + display: !this.isEmbedded, + labelString: this.fields[this.state.measure].string, + }, + ticks: { + callback: this._formatValue.bind(this), + suggestedMax: 0, + suggestedMin: 0, + } + }], + yAxes: [{ + type: 'category', + scaleLabel: { + display: this.state.processedGroupBy.length && !this.isEmbedded, + labelString: this.state.processedGroupBy.length ? + this.fields[this.state.processedGroupBy[0].split(':')[0]].string : '', + }, + ticks: { + // don't use bind: callback is called with 'index' as second parameter + // with value labels.indexOf(label)! + callback: function (label) { + return self._relabelling(label); + }, + }, + }], + }; + } + return {}; + }, + _animationOptions: function () { var animationOptions = {}; var GraphVal = this; @@ -108,5 +454,79 @@ var FORMAT_OPTIONS = { }); }, + _prepare_HBar_Options: function (datasetsCount) { + const options = { + maintainAspectRatio: false, + scales: this._getScaleHBarOptions(), + legend: this._getLegendOptions(datasetsCount), + tooltips: this._getTooltipOptions(), + elements: this._getElementOptions(), + //animation: this._animationOptions(), + }; + if (this._isRedirectionEnabled()) { + options.onClick = this._onGraphClicked.bind(this); + } + return options; + }, + + + _renderhorizontalBarChart: function (dataPoints) { + var self = this; + + // prepare data + var data = this._prepareData(dataPoints); + + data.datasets.forEach(function (dataset, index) { + // used when stacked + dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined; + // set dataset color + var color = self._getColor(index); + dataset.backgroundColor = color; + }); + + // prepare options + var options = this._prepare_HBar_Options(data.datasets.length); + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'horizontalBar', + data: data, + options: options, + }); + }, + /** + * Sort datapoints according to the current order (ASC or DESC). + * + * Note: this should be moved to the model at some point. + * + * @private + * @param {Object[]} dataPoints + * @returns {Object[]} sorted dataPoints if orderby set on state + */ + _sortDataPoints(dataPoints) { + if (!Object.keys(this.state.timeRanges).length && this.state.orderBy && + ['bar', 'horizontalBar', 'line'].includes(this.state.mode) && this.state.groupBy.length) { + // group data by their x-axis value, and then sort datapoints + // based on the sum of values by group in ascending/descending order + const groupByFieldName = this.state.groupBy[0].split(':')[0]; + const groupedByMany2One = this.fields[groupByFieldName].type === 'many2one'; + const groupedDataPoints = {}; + dataPoints.forEach(function (dataPoint) { + const key = groupedByMany2One ? dataPoint.resId : dataPoint.labels[0]; + groupedDataPoints[key] = groupedDataPoints[key] || []; + groupedDataPoints[key].push(dataPoint); + }); + dataPoints = _.sortBy(groupedDataPoints, function (group) { + return group.reduce((sum, dataPoint) => sum + dataPoint.value, 0); + }); + dataPoints = dataPoints.flat(); + if (this.state.orderBy === 'desc') { + dataPoints = dataPoints.reverse('value'); + } + } + return dataPoints; + }, + + }); }); diff --git a/project_report/views/assets.xml b/project_report/views/assets.xml index ac5abf2..6041fa0 100755 --- a/project_report/views/assets.xml +++ b/project_report/views/assets.xml @@ -3,6 +3,7 @@