mirror of
https://github.com/bringout/oca-ocb-project.git
synced 2026-04-20 15:22:00 +02:00
Initial commit: Project packages
This commit is contained in:
commit
89613c97b0
753 changed files with 496325 additions and 0 deletions
5
odoo-bringout-oca-ocb-project/project/report/__init__.py
Normal file
5
odoo-bringout-oca-ocb-project/project/report/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import project_report
|
||||
from . import project_task_burndown_chart_report
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
154
odoo-bringout-oca-ocb-project/project/report/project_report.py
Normal file
154
odoo-bringout-oca-ocb-project/project/report/project_report.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, tools
|
||||
|
||||
from odoo.addons.rating.models.rating_data import RATING_LIMIT_MIN, RATING_TEXT
|
||||
|
||||
class ReportProjectTaskUser(models.Model):
|
||||
_name = "report.project.task.user"
|
||||
_description = "Tasks Analysis"
|
||||
_order = 'name desc, project_id'
|
||||
_auto = False
|
||||
|
||||
name = fields.Char(string='Task', readonly=True)
|
||||
user_ids = fields.Many2many('res.users', relation='project_task_user_rel', column1='task_id', column2='user_id',
|
||||
string='Assignees', readonly=True)
|
||||
create_date = fields.Datetime("Create Date", readonly=True)
|
||||
date_assign = fields.Datetime(string='Assignment Date', readonly=True)
|
||||
date_end = fields.Datetime(string='Ending Date', readonly=True)
|
||||
date_deadline = fields.Date(string='Deadline', readonly=True)
|
||||
date_last_stage_update = fields.Datetime(string='Last Stage Update', readonly=True)
|
||||
project_id = fields.Many2one('project.project', string='Project', readonly=True)
|
||||
working_days_close = fields.Float(string='Working Days to Close',
|
||||
digits=(16, 2), readonly=True, group_operator="avg")
|
||||
working_days_open = fields.Float(string='Working Days to Assign',
|
||||
digits=(16, 2), readonly=True, group_operator="avg")
|
||||
delay_endings_days = fields.Float(string='Days to Deadline', digits=(16, 2), group_operator="avg", readonly=True)
|
||||
nbr = fields.Integer('# of Tasks', readonly=True) # TDE FIXME master: rename into nbr_tasks
|
||||
working_hours_open = fields.Float(string='Working Hours to Assign', digits=(16, 2), readonly=True, group_operator="avg")
|
||||
working_hours_close = fields.Float(string='Working Hours to Close', digits=(16, 2), readonly=True, group_operator="avg")
|
||||
rating_last_value = fields.Float('Rating Value (/5)', group_operator="avg", readonly=True, groups="project.group_project_rating")
|
||||
rating_avg = fields.Float('Average Rating', readonly=True, group_operator='avg', groups="project.group_project_rating")
|
||||
priority = fields.Selection([
|
||||
('0', 'Low'),
|
||||
('1', 'High')
|
||||
], readonly=True, string="Priority")
|
||||
state = fields.Selection([
|
||||
('normal', 'In Progress'),
|
||||
('blocked', 'Blocked'),
|
||||
('done', 'Ready for Next Stage')
|
||||
], string='Kanban State', readonly=True)
|
||||
company_id = fields.Many2one('res.company', string='Company', readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Customer', readonly=True)
|
||||
stage_id = fields.Many2one('project.task.type', string='Stage', readonly=True)
|
||||
is_closed = fields.Boolean("Closing Stage", readonly=True, help="Folded in Kanban stages are closing stages.")
|
||||
task_id = fields.Many2one('project.task', string='Tasks', readonly=True)
|
||||
active = fields.Boolean(readonly=True)
|
||||
tag_ids = fields.Many2many('project.tags', relation='project_tags_project_task_rel',
|
||||
column1='project_task_id', column2='project_tags_id',
|
||||
string='Tags', readonly=True)
|
||||
parent_id = fields.Many2one('project.task', string='Parent Task', readonly=True)
|
||||
ancestor_id = fields.Many2one('project.task', string="Ancestor Task", readonly=True)
|
||||
# We are explicitly not using a related field in order to prevent the recomputing caused by the depends as the model is a report.
|
||||
rating_last_text = fields.Selection(RATING_TEXT, string="Rating Last Text", compute="_compute_rating_last_text", search="_search_rating_last_text")
|
||||
personal_stage_type_ids = fields.Many2many('project.task.type', relation='project_task_user_rel',
|
||||
column1='task_id', column2='stage_id',
|
||||
string="Personal Stage", readonly=True)
|
||||
milestone_id = fields.Many2one('project.milestone', readonly=True)
|
||||
milestone_reached = fields.Boolean('Is Milestone Reached', readonly=True)
|
||||
milestone_deadline = fields.Date('Milestone Deadline', readonly=True)
|
||||
|
||||
def _compute_rating_last_text(self):
|
||||
for task_analysis in self:
|
||||
task_analysis.rating_last_text = task_analysis.task_id.rating_last_text
|
||||
|
||||
def _search_rating_last_text(self, operator, value):
|
||||
return [('task_id.rating_last_text', operator, value)]
|
||||
|
||||
def _select(self):
|
||||
return """
|
||||
(select 1) AS nbr,
|
||||
t.id as id,
|
||||
t.id as task_id,
|
||||
t.active,
|
||||
t.create_date as create_date,
|
||||
t.date_assign as date_assign,
|
||||
t.date_end as date_end,
|
||||
t.date_last_stage_update as date_last_stage_update,
|
||||
t.date_deadline as date_deadline,
|
||||
t.project_id,
|
||||
t.priority,
|
||||
t.name as name,
|
||||
t.company_id,
|
||||
t.partner_id,
|
||||
t.parent_id as parent_id,
|
||||
t.ancestor_id as ancestor_id,
|
||||
t.stage_id as stage_id,
|
||||
t.is_closed as is_closed,
|
||||
t.kanban_state as state,
|
||||
t.milestone_id,
|
||||
pm.is_reached as milestone_reached,
|
||||
pm.deadline as milestone_deadline,
|
||||
NULLIF(t.rating_last_value, 0) as rating_last_value,
|
||||
AVG(rt.rating) as rating_avg,
|
||||
t.working_days_close as working_days_close,
|
||||
t.working_days_open as working_days_open,
|
||||
t.working_hours_open as working_hours_open,
|
||||
t.working_hours_close as working_hours_close,
|
||||
(extract('epoch' from (t.date_deadline-(now() at time zone 'UTC'))))/(3600*24) as delay_endings_days
|
||||
"""
|
||||
|
||||
def _group_by(self):
|
||||
return """
|
||||
t.id,
|
||||
t.active,
|
||||
t.create_date,
|
||||
t.date_assign,
|
||||
t.date_end,
|
||||
t.date_last_stage_update,
|
||||
t.date_deadline,
|
||||
t.project_id,
|
||||
t.ancestor_id,
|
||||
t.priority,
|
||||
t.name,
|
||||
t.company_id,
|
||||
t.partner_id,
|
||||
t.parent_id,
|
||||
t.stage_id,
|
||||
t.is_closed,
|
||||
t.kanban_state,
|
||||
t.rating_last_value,
|
||||
t.working_days_close,
|
||||
t.working_days_open,
|
||||
t.working_hours_open,
|
||||
t.working_hours_close,
|
||||
t.milestone_id,
|
||||
pm.is_reached,
|
||||
pm.deadline
|
||||
"""
|
||||
|
||||
def _from(self):
|
||||
return f"""
|
||||
project_task t
|
||||
LEFT JOIN rating_rating rt ON rt.res_id = t.id
|
||||
AND rt.res_model = 'project.task'
|
||||
AND rt.consumed = True
|
||||
AND rt.rating >= {RATING_LIMIT_MIN}
|
||||
LEFT JOIN project_milestone pm ON pm.id = t.milestone_id
|
||||
"""
|
||||
|
||||
def _where(self):
|
||||
return """
|
||||
t.project_id IS NOT NULL
|
||||
"""
|
||||
|
||||
def init(self):
|
||||
tools.drop_view_if_exists(self._cr, self._table)
|
||||
self._cr.execute("""
|
||||
CREATE view %s as
|
||||
SELECT %s
|
||||
FROM %s
|
||||
WHERE %s
|
||||
GROUP BY %s
|
||||
""" % (self._table, self._select(), self._from(), self._where(), self._group_by()))
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_task_project_user_pivot" model="ir.ui.view">
|
||||
<field name="name">report.project.task.user.pivot</field>
|
||||
<field name="model">report.project.task.user</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Tasks Analysis" display_quantity="1" sample="1">
|
||||
<field name="project_id" type="row"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_task_project_user_graph" model="ir.ui.view">
|
||||
<field name="name">report.project.task.user.graph</field>
|
||||
<field name="model">report.project.task.user</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Tasks Analysis" sample="1" disable_linking="1">
|
||||
<field name="project_id"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="nbr" invisible="1"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="report_project_task_user_view_tree" model="ir.ui.view">
|
||||
<field name="name">report.project.task.user.view.tree</field>
|
||||
<field name="model">report.project.task.user</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Tasks Analysis" create="false" editable="top" delete="false" edit="false">
|
||||
<field name="name"/>
|
||||
<field name="partner_id" optional="hide"/>
|
||||
<field name="project_id" options="{'no_open': True}" optional="show"/>
|
||||
<field name="user_ids" optional="show" widget="many2many_avatar_user"/>
|
||||
<field name="stage_id" optional="show"/>
|
||||
<field name="company_id" optional="show" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_task_project_user_search" model="ir.ui.view">
|
||||
<field name="name">report.project.task.user.search</field>
|
||||
<field name="model">report.project.task.user</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Tasks Analysis">
|
||||
<field name="name" string="Task"/>
|
||||
<field name="tag_ids"/>
|
||||
<field name="user_ids" context="{'active_test': False}"/>
|
||||
<field name="project_id"/>
|
||||
<field name="milestone_id" groups="project.group_project_milestone"/>
|
||||
<field name="ancestor_id" groups="project.group_subtask_project"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="partner_id" operator="child_of"/>
|
||||
<field name="active"/>
|
||||
<field name="rating_last_text"/>
|
||||
<field name="date_assign"/>
|
||||
<field name="date_end"/>
|
||||
<field name="date_deadline"/>
|
||||
<field name="date_last_stage_update"/>
|
||||
<filter string="My Tasks" name="my_tasks" domain="[('user_ids', 'in', uid)]"/>
|
||||
<filter string="Followed Tasks" name="followed_by_me" domain="[('task_id.message_is_follower', '=', True)]"/>
|
||||
<filter string="Unassigned" name="unassigned" domain="[('user_ids', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter string="My Projects" name="own_projects" domain="[('project_id.user_id', '=', uid)]"/>
|
||||
<filter string="My Favorite Projects" name="my_favorite_projects" domain="[('project_id.favorite_user_ids', 'in', [uid])]"/>
|
||||
<separator/>
|
||||
<filter string="High Priority" name="high_priority" domain="[('priority', '=', 1)]"/>
|
||||
<filter string="Low Priority" name="low_priority" domain="[('priority', '=', 0)]"/>
|
||||
<separator/>
|
||||
<filter string="Open" name="open_tasks" domain="[('is_closed', '=', False)]"/>
|
||||
<filter string="Closed" name="closed_tasks" domain="[('is_closed', '=', True)]"/>
|
||||
<separator/>
|
||||
<filter string="Late Milestones" name="late_milestone"
|
||||
domain="[('project_id.allow_milestones', '=', True), ('is_closed', '=', False), ('milestone_reached', '=', False), ('milestone_deadline', '<', context_today().strftime('%Y-%m-%d'))]"
|
||||
groups="project.group_project_milestone"
|
||||
/>
|
||||
<filter string="Late Tasks" name="late" domain="[('date_deadline', '<', context_today().strftime('%Y-%m-%d')), ('is_closed', '=', False)]"/>
|
||||
<filter name="rating_satisfied" string="Satisfied" domain="[('rating_avg', '>=', 3.66)]" groups="project.group_project_rating"/>
|
||||
<filter name="rating_okay" string="Okay" domain="[('rating_avg', '<', 3.66), ('rating_avg', '>=', 2.33)]" groups="project.group_project_rating"/>
|
||||
<filter name="dissatisfied" string="Dissatisfied" domain="[('rating_avg', '<', 2.33), ('rating_last_value', '!=', 0)]" groups="project.group_project_rating"/>
|
||||
<filter name="no_rating" string="No Rating" domain="[('rating_last_value', '=', 0)]" groups="project.group_project_rating"/>
|
||||
<separator/>
|
||||
<filter name="filter_date_deadline" date="date_deadline"/>
|
||||
<filter name="filter_date_assign" date="date_assign"/>
|
||||
<filter name="filter_date_last_stage_update" date="date_last_stage_update"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Extended Filters">
|
||||
<field name="priority"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group expand="1" string="Group By">
|
||||
<filter string="Stage" name="Stage" context="{'group_by': 'stage_id'}"/>
|
||||
<filter string="Personal Stage" name="personal_stage" context="{'group_by': 'personal_stage_type_ids'}"/>
|
||||
<filter string="Assignees" name="User" context="{'group_by': 'user_ids'}"/>
|
||||
<filter string="Ancestor Task" name="groupby_ancestor_task" context="{'group_by': 'ancestor_id'}" groups="project.group_subtask_project"/>
|
||||
<filter string="Milestone" name="milestone" context="{'group_by': 'milestone_id'}" groups="project.group_project_milestone"/>
|
||||
<filter string="Customer" name="Customer" context="{'group_by': 'partner_id'}"/>
|
||||
<filter string="Kanban State" name="kanban_state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Deadline" name="deadline" context="{'group_by': 'date_deadline'}"/>
|
||||
<filter string="Creation Date" name="group_create_date" context="{'group_by': 'create_date'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_project_task_user_tree" model="ir.actions.act_window">
|
||||
<field name="name">Tasks Analysis</field>
|
||||
<field name="res_model">report.project.task.user</field>
|
||||
<field name="view_mode">graph,pivot</field>
|
||||
<field name="search_view_id" ref="view_task_project_user_search"/>
|
||||
<field name="context">{'group_by_no_leaf':1, 'group_by':[], 'graph_measure': '__count__'}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_empty_folder">
|
||||
No data yet!
|
||||
</p><p>
|
||||
Analyze the progress of your projects and the performance of your employees.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,450 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.models import regex_field_agg, VALID_AGGREGATE_FUNCTIONS
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.osv.expression import AND_OPERATOR, OR_OPERATOR, NOT_OPERATOR, DOMAIN_OPERATORS, FALSE_LEAF, TRUE_LEAF, normalize_domain
|
||||
from odoo.tools import OrderedSet
|
||||
|
||||
|
||||
def remove_domain_leaf(domain, fields_to_remove):
|
||||
""" Make the provided domain insensitive to the fields provided in fields_to_remove. Fields that are part of
|
||||
`fields_to_remove` are replaced by either a `FALSE_LEAF` or a `TRUE_LEAF` in order to ensure the evaluation of the
|
||||
complete domain.
|
||||
|
||||
:param domain: The domain to process.
|
||||
:param fields_to_remove: List of fields the domain has to be insensitive to.
|
||||
:return: The insensitive domain.
|
||||
"""
|
||||
def _process_leaf(elements, index, operator, new_domain):
|
||||
leaf = elements[index]
|
||||
if len(leaf) == 3:
|
||||
if leaf[0] in fields_to_remove:
|
||||
if operator == AND_OPERATOR:
|
||||
new_domain.append(TRUE_LEAF)
|
||||
elif operator == OR_OPERATOR:
|
||||
new_domain.append(FALSE_LEAF)
|
||||
else:
|
||||
new_domain.append(leaf)
|
||||
return 1
|
||||
elif len(leaf) == 1 and leaf in DOMAIN_OPERATORS:
|
||||
# Special case to avoid OR ('|') that can never resolve to true
|
||||
if leaf == OR_OPERATOR \
|
||||
and len(elements[index + 1]) == 3 and len(elements[index + 2]) == 3 \
|
||||
and elements[index + 1][0] in fields_to_remove and elements[index + 2][0] in fields_to_remove:
|
||||
new_domain.append(TRUE_LEAF)
|
||||
return 3
|
||||
new_domain.append(leaf)
|
||||
if leaf[0] == NOT_OPERATOR:
|
||||
return 1 + _process_leaf(elements, index + 1, '&', new_domain)
|
||||
first_leaf_skip = _process_leaf(elements, index + 1, leaf, new_domain)
|
||||
second_leaf_skip = _process_leaf(elements, index + 1 + first_leaf_skip, leaf, new_domain)
|
||||
return 1 + first_leaf_skip + second_leaf_skip
|
||||
return 0
|
||||
|
||||
if len(domain) == 0:
|
||||
return domain
|
||||
new_domain = []
|
||||
_process_leaf(normalize_domain(domain), 0, AND_OPERATOR, new_domain)
|
||||
return new_domain
|
||||
|
||||
|
||||
class ReportProjectTaskBurndownChart(models.AbstractModel):
|
||||
_name = 'project.task.burndown.chart.report'
|
||||
_description = 'Burndown Chart'
|
||||
_auto = False
|
||||
_order = 'date'
|
||||
|
||||
planned_hours = fields.Float(string='Allocated Hours', readonly=True)
|
||||
date = fields.Datetime('Date', readonly=True)
|
||||
date_assign = fields.Datetime(string='Assignment Date', readonly=True)
|
||||
date_deadline = fields.Date(string='Deadline', readonly=True)
|
||||
display_project_id = fields.Many2one('project.project', readonly=True)
|
||||
is_closed = fields.Boolean("Closing Stage", readonly=True)
|
||||
milestone_id = fields.Many2one('project.milestone', readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', string='Customer', readonly=True)
|
||||
project_id = fields.Many2one('project.project', readonly=True)
|
||||
stage_id = fields.Many2one('project.task.type', readonly=True)
|
||||
user_ids = fields.Many2many('res.users', relation='project_task_user_rel', column1='task_id', column2='user_id',
|
||||
string='Assignees', readonly=True)
|
||||
|
||||
# Fake field required as used in the filters. It will however be managed through the `project.task` model.
|
||||
has_late_and_unreached_milestone = fields.Boolean(readonly=True)
|
||||
|
||||
# This variable is used in order to distinguish conditions that can be set on `project.task` and thus being used
|
||||
# at a lower level than the "usual" query made by the `read_group_raw`. Indeed, the domain applied on those fields
|
||||
# will be performed on a `CTE` that will be later use in the `SQL` in order to limit the subset of data that is used
|
||||
# in the successive `GROUP BY` statements.
|
||||
task_specific_fields = [
|
||||
'date_assign',
|
||||
'date_deadline',
|
||||
'display_project_id',
|
||||
'has_late_and_unreached_milestone',
|
||||
'is_closed',
|
||||
'milestone_id',
|
||||
'partner_id',
|
||||
'project_id',
|
||||
'stage_id',
|
||||
'user_ids',
|
||||
]
|
||||
|
||||
def _get_group_by_SQL(self, task_specific_domain, count_field, select_terms, from_clause, where_clause,
|
||||
where_clause_params, groupby_terms, orderby_terms, limit, offset, groupby, annotated_groupbys,
|
||||
prefix_term, prefix_terms):
|
||||
""" Prepare and return the SQL to be used for the read_group. """
|
||||
|
||||
# Build the query on `project.task` with the domain fields that are linked to that model. This is done in order
|
||||
# to be able to reduce the number of treated records in the query by limiting them to the one corresponding to
|
||||
# the ids that are returned from this sub query.
|
||||
project_task_query = self.env['project.task']._where_calc(task_specific_domain)
|
||||
project_task_from_clause, project_task_where_clause, project_task_where_clause_params = project_task_query.get_sql()
|
||||
|
||||
# Get the stage_id `ir.model.fields`'s id in order to inject it directly in the query and avoid having to join
|
||||
# on `ir_model_fields` table.
|
||||
IrModelFieldsSudo = self.env['ir.model.fields'].sudo()
|
||||
field_id = IrModelFieldsSudo.search([('name', '=', 'stage_id'), ('model', '=', 'project.task')]).id
|
||||
|
||||
# Get the date aggregation SQL statement in order to be able to inject it in the SQL.
|
||||
date_group_by_field = next(filter(lambda gb: gb.startswith('date'), groupby))
|
||||
date_annotated_groupby = [
|
||||
annotated_groupby for annotated_groupby in annotated_groupbys
|
||||
if annotated_groupby['groupby'] == date_group_by_field
|
||||
][0]
|
||||
date_begin, date_end = (
|
||||
date_annotated_groupby['qualified_field'].replace(
|
||||
'"%s"."%s"' % (self._table, date_annotated_groupby['field']), '"%s_%s"' % (date_annotated_groupby['field'], field)
|
||||
)
|
||||
for field in ['begin', 'end']
|
||||
)
|
||||
|
||||
# Insert `WHERE` clause parameter that apply on `project_task` prior to the one that apply on
|
||||
# `project_task_burndown_chart_report` as the `project_task` CTE is placed at the beginning of the `SQL`.
|
||||
for param in reversed(project_task_where_clause_params):
|
||||
where_clause_params.insert(0, param)
|
||||
|
||||
# Computes the interval which needs to be used in the `SQL` depending on the date group by interval.
|
||||
if date_annotated_groupby['groupby'].split(':')[1] != 'quarter':
|
||||
interval = '1 %s' % date_annotated_groupby['groupby'].split(':')[1]
|
||||
else:
|
||||
interval = '3 month'
|
||||
|
||||
burndown_chart_query = """
|
||||
WITH task_ids AS (
|
||||
SELECT id
|
||||
FROM %(task_query_from)s
|
||||
%(task_query_where)s
|
||||
),
|
||||
all_stage_task_moves AS (
|
||||
SELECT count(*) as %(count_field)s,
|
||||
sum(planned_hours) as planned_hours,
|
||||
project_id,
|
||||
display_project_id,
|
||||
%(date_begin)s as date_begin,
|
||||
%(date_end)s as date_end,
|
||||
stage_id
|
||||
FROM (
|
||||
-- Gathers the stage_ids history per task_id. This query gets:
|
||||
-- * All changes except the last one for those for which we have at least a mail
|
||||
-- message and a mail tracking value on project.task stage_id.
|
||||
-- * The stage at creation for those for which we do not have any mail message and a
|
||||
-- mail tracking value on project.task stage_id.
|
||||
SELECT DISTINCT task_id,
|
||||
planned_hours,
|
||||
project_id,
|
||||
display_project_id,
|
||||
%(date_begin)s as date_begin,
|
||||
%(date_end)s as date_end,
|
||||
first_value(stage_id) OVER task_date_begin_window AS stage_id
|
||||
FROM (
|
||||
SELECT pt.id as task_id,
|
||||
pt.planned_hours,
|
||||
pt.project_id,
|
||||
pt.display_project_id,
|
||||
COALESCE(LAG(mm.date) OVER (PARTITION BY mm.res_id ORDER BY mm.id), pt.create_date) as date_begin,
|
||||
CASE WHEN mtv.id IS NOT NULL THEN mm.date
|
||||
ELSE (now() at time zone 'utc')::date + INTERVAL '%(interval)s'
|
||||
END as date_end,
|
||||
CASE WHEN mtv.id IS NOT NULL THEN mtv.old_value_integer
|
||||
ELSE pt.stage_id
|
||||
END as stage_id
|
||||
FROM project_task pt
|
||||
LEFT JOIN (
|
||||
mail_message mm
|
||||
JOIN mail_tracking_value mtv ON mm.id = mtv.mail_message_id
|
||||
AND mtv.field = %(field_id)s
|
||||
AND mm.model='project.task'
|
||||
AND mm.message_type = 'notification'
|
||||
JOIN project_task_type ptt ON ptt.id = mtv.old_value_integer
|
||||
) ON mm.res_id = pt.id
|
||||
WHERE pt.active=true AND pt.id IN (SELECT id from task_ids)
|
||||
) task_stage_id_history
|
||||
GROUP BY task_id,
|
||||
planned_hours,
|
||||
project_id,
|
||||
display_project_id,
|
||||
%(date_begin)s,
|
||||
%(date_end)s,
|
||||
stage_id
|
||||
WINDOW task_date_begin_window AS (PARTITION BY task_id, %(date_begin)s)
|
||||
UNION ALL
|
||||
-- Gathers the current stage_ids per task_id for those which values changed at least
|
||||
-- once (=those for which we have at least a mail message and a mail tracking value
|
||||
-- on project.task stage_id).
|
||||
SELECT pt.id as task_id,
|
||||
pt.planned_hours,
|
||||
pt.project_id,
|
||||
pt.display_project_id,
|
||||
last_stage_id_change_mail_message.date as date_begin,
|
||||
(now() at time zone 'utc')::date + INTERVAL '%(interval)s' as date_end,
|
||||
pt.stage_id as old_value_integer
|
||||
FROM project_task pt
|
||||
JOIN project_task_type ptt ON ptt.id = pt.stage_id
|
||||
JOIN LATERAL (
|
||||
SELECT mm.date
|
||||
FROM mail_message mm
|
||||
JOIN mail_tracking_value mtv ON mm.id = mtv.mail_message_id
|
||||
AND mtv.field = %(field_id)s
|
||||
AND mm.model='project.task'
|
||||
AND mm.message_type = 'notification'
|
||||
AND mm.res_id = pt.id
|
||||
ORDER BY mm.id DESC
|
||||
FETCH FIRST ROW ONLY
|
||||
) AS last_stage_id_change_mail_message ON TRUE
|
||||
WHERE pt.active=true AND pt.id IN (SELECT id from task_ids)
|
||||
) AS project_task_burndown_chart
|
||||
GROUP BY planned_hours,
|
||||
project_id,
|
||||
display_project_id,
|
||||
%(date_begin)s,
|
||||
%(date_end)s,
|
||||
stage_id
|
||||
)
|
||||
SELECT (project_id*10^13 + stage_id*10^7 + to_char(date, 'YYMMDD')::integer)::bigint as id,
|
||||
planned_hours,
|
||||
project_id,
|
||||
display_project_id,
|
||||
stage_id,
|
||||
date,
|
||||
%(count_field)s
|
||||
FROM all_stage_task_moves t
|
||||
JOIN LATERAL generate_series(t.date_begin, t.date_end-INTERVAL '1 day', '%(interval)s')
|
||||
AS date ON TRUE
|
||||
""" % {
|
||||
'task_query_from': project_task_from_clause,
|
||||
'task_query_where': prefix_term('WHERE', project_task_where_clause),
|
||||
'count_field': count_field,
|
||||
'date_begin': date_begin,
|
||||
'date_end': date_end,
|
||||
'interval': interval,
|
||||
'field_id': field_id,
|
||||
}
|
||||
|
||||
# Replace, in the `FROM` clause generated on `project_task_burndown_chart_report`, the
|
||||
# `project_task_burndown_chart_report` table name by the burndown_chart_query `SQL` aliased as
|
||||
# `project_task_burndown_chart_report`.
|
||||
from_clause = from_clause.replace('"project_task_burndown_chart_report"', '(%s) AS "project_task_burndown_chart_report"' % burndown_chart_query, 1)
|
||||
|
||||
return """
|
||||
SELECT min("%(table)s".id) AS id, sum(%(table)s.%(count_field)s) AS "%(count_field)s" %(extra_fields)s
|
||||
FROM %(from)s
|
||||
%(where)s
|
||||
%(groupby)s
|
||||
%(orderby)s
|
||||
%(limit)s
|
||||
%(offset)s
|
||||
""" % {
|
||||
'table': self._table,
|
||||
'count_field': count_field,
|
||||
'extra_fields': prefix_terms(',', select_terms),
|
||||
'from': from_clause,
|
||||
'where': prefix_term('WHERE', where_clause),
|
||||
'groupby': prefix_terms('GROUP BY', groupby_terms),
|
||||
'orderby': prefix_terms('ORDER BY', orderby_terms),
|
||||
'limit': prefix_term('LIMIT', int(limit) if limit else None),
|
||||
'offset': prefix_term('OFFSET', int(offset) if limit else None),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _validate_group_by(self, groupby):
|
||||
""" Check that the both `date` and `stage_id` are part of `group_by`, otherwise raise a `UserError`.
|
||||
|
||||
:param groupby: List of group by fields.
|
||||
"""
|
||||
stage_id_in_groupby = False
|
||||
date_in_groupby = False
|
||||
|
||||
for gb in groupby:
|
||||
if gb.startswith('date'):
|
||||
date_in_groupby = True
|
||||
else:
|
||||
if gb == 'stage_id':
|
||||
stage_id_in_groupby = True
|
||||
|
||||
if not date_in_groupby or not stage_id_in_groupby:
|
||||
raise UserError(_('The view must be grouped by date and by stage_id'))
|
||||
|
||||
@api.model
|
||||
def _determine_domains(self, domain):
|
||||
""" Compute two separated domain from the provided one:
|
||||
* A domain that only contains fields that are specific to `project.task.burndown.chart.report`
|
||||
* A domain that only contains fields that are specific to `project.task`
|
||||
|
||||
Fields that are not part of the constraint are replaced by either a `FALSE_LEAF` or a `TRUE_LEAF` in order
|
||||
to ensure the complete domain evaluation. See `remove_domain_leaf` for more details.
|
||||
|
||||
:param domain: The domain that has been passed to the read_group.
|
||||
:return: A tuple containing the non `project.task` specific domain and the `project.task` specific domain.
|
||||
"""
|
||||
burndown_chart_specific_fields = list(set(self._fields) - set(self.task_specific_fields))
|
||||
task_specific_domain = remove_domain_leaf(domain, burndown_chart_specific_fields)
|
||||
non_task_specific_domain = remove_domain_leaf(domain, self.task_specific_fields)
|
||||
return non_task_specific_domain, task_specific_domain
|
||||
|
||||
@api.model
|
||||
def _read_group_raw(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
|
||||
""" Although not being a good practice, this code is, for a big part, duplicated from `read_group_raw` from
|
||||
`models.py`. In order to be able to use the report on big databases, it is necessary to inject `WHERE`
|
||||
statements at the lowest levels in the report `SQL`. As a result, using a view was no more an option as
|
||||
`Postgres` could not optimise the `SQL`.
|
||||
The code of `fill_temporal` has been removed from what's available in `models.py` as it is not relevant in the
|
||||
context of the Burndown Chart. Indeed, series are generated so no empty are returned by the `SQL`, except if
|
||||
explicitly specified in the domain through the `date` field, which is then expected.
|
||||
"""
|
||||
|
||||
# --- Below code is custom
|
||||
|
||||
self._validate_group_by(groupby)
|
||||
burndown_specific_domain, task_specific_domain = self._determine_domains(domain)
|
||||
|
||||
# --- Below code is from models.py read_group_raw
|
||||
|
||||
self.check_access_rights('read')
|
||||
query = self._where_calc(burndown_specific_domain)
|
||||
fields = fields or [f.name for f in self._fields.values() if f.store]
|
||||
|
||||
groupby = [groupby] if isinstance(groupby, str) else list(OrderedSet(groupby))
|
||||
groupby_list = groupby[:1] if lazy else groupby
|
||||
annotated_groupbys = [self._read_group_process_groupby(gb, query) for gb in groupby_list]
|
||||
groupby_fields = [g['field'] for g in annotated_groupbys]
|
||||
order = orderby or ','.join([g for g in groupby_list])
|
||||
groupby_dict = {gb['groupby']: gb for gb in annotated_groupbys}
|
||||
|
||||
self._apply_ir_rules(query, 'read')
|
||||
for gb in groupby_fields:
|
||||
if gb not in self._fields:
|
||||
raise UserError(_("Unknown field %r in 'groupby'", gb))
|
||||
if not self._fields[gb].base_field.groupable:
|
||||
raise UserError(_(
|
||||
"Field %s is not a stored field, only stored fields (regular or "
|
||||
"many2many) are valid for the 'groupby' parameter", self._fields[gb],
|
||||
))
|
||||
|
||||
aggregated_fields = []
|
||||
select_terms = []
|
||||
fnames = [] # list of fields to flush
|
||||
|
||||
for fspec in fields:
|
||||
if fspec == 'sequence':
|
||||
continue
|
||||
if fspec == '__count':
|
||||
# the web client sometimes adds this pseudo-field in the list
|
||||
continue
|
||||
|
||||
match = regex_field_agg.match(fspec)
|
||||
if not match:
|
||||
raise UserError(_("Invalid field specification %r.", fspec))
|
||||
|
||||
name, func, fname = match.groups()
|
||||
if func:
|
||||
# we have either 'name:func' or 'name:func(fname)'
|
||||
fname = fname or name
|
||||
field = self._fields.get(fname)
|
||||
if not field:
|
||||
raise ValueError(_("Invalid field %r on model %r", (fname, self._name)))
|
||||
if not (field.base_field.store and field.base_field.column_type):
|
||||
raise UserError(_("Cannot aggregate field %r.", fname))
|
||||
if func not in VALID_AGGREGATE_FUNCTIONS:
|
||||
raise UserError(_("Invalid aggregation function %r.", func))
|
||||
else:
|
||||
# we have 'name', retrieve the aggregator on the field
|
||||
field = self._fields.get(name)
|
||||
if not field:
|
||||
raise ValueError(_("Invalid field %r on model %r", (name, self._name)))
|
||||
if not (field.base_field.store and
|
||||
field.base_field.column_type and field.group_operator):
|
||||
continue
|
||||
func, fname = field.group_operator, name
|
||||
|
||||
fnames.append(fname)
|
||||
|
||||
if fname in groupby_fields:
|
||||
continue
|
||||
if name in aggregated_fields:
|
||||
raise UserError(_("Output name %r is used twice.", name))
|
||||
aggregated_fields.append(name)
|
||||
|
||||
expr = self._inherits_join_calc(self._table, fname, query)
|
||||
if func.lower() == 'count_distinct':
|
||||
term = 'COUNT(DISTINCT %s) AS "%s"' % (expr, name)
|
||||
else:
|
||||
term = '%s(%s) AS "%s"' % (func, expr, name)
|
||||
select_terms.append(term)
|
||||
|
||||
for gb in annotated_groupbys:
|
||||
select_terms.append('%s as "%s" ' % (gb['qualified_field'], gb['groupby']))
|
||||
|
||||
# --- Below code is custom
|
||||
# --- As the report is base on `project.task` we flush that specific model
|
||||
|
||||
# self._flush_search(domain, fields=fnames + groupby_fields)
|
||||
self.env['project.task']._flush_search(task_specific_domain, fields=self.task_specific_fields)
|
||||
|
||||
# --- Below code is from models.py read_group_raw
|
||||
|
||||
groupby_terms, orderby_terms = self._read_group_prepare(order, aggregated_fields, annotated_groupbys, query)
|
||||
from_clause, where_clause, where_clause_params = query.get_sql()
|
||||
if lazy and (len(groupby_fields) >= 2 or not self._context.get('group_by_no_leaf')):
|
||||
count_field = groupby_fields[0] if len(groupby_fields) >= 1 else '_'
|
||||
else:
|
||||
count_field = '_'
|
||||
count_field += '_count'
|
||||
|
||||
prefix_terms = lambda prefix, terms: (prefix + " " + ",".join(terms)) if terms else ''
|
||||
prefix_term = lambda prefix, term: ('%s %s' % (prefix, term)) if term else ''
|
||||
|
||||
# --- Below code is custom
|
||||
|
||||
query = self._get_group_by_SQL(task_specific_domain, count_field, select_terms, from_clause, where_clause,
|
||||
where_clause_params, groupby_terms, orderby_terms, limit, offset, groupby,
|
||||
annotated_groupbys, prefix_term, prefix_terms)
|
||||
|
||||
# --- Below code is from models.py read_group_raw
|
||||
|
||||
self._cr.execute(query, where_clause_params)
|
||||
fetched_data = self._cr.dictfetchall()
|
||||
|
||||
if not groupby_fields:
|
||||
return fetched_data
|
||||
|
||||
self._read_group_resolve_many2x_fields(fetched_data, annotated_groupbys)
|
||||
|
||||
data = [{k: self._read_group_prepare_data(k, v, groupby_dict) for k, v in r.items()} for r in fetched_data]
|
||||
|
||||
result = [self._read_group_format_result(d, annotated_groupbys, groupby, domain) for d in data]
|
||||
|
||||
# --- Below code is custom
|
||||
# --- We removed fill_temporal handling as not relevant in the context of the Burndown Chart
|
||||
|
||||
# --- Below code is from models.py read_group_raw
|
||||
|
||||
if lazy:
|
||||
# Right now, read_group only fill results in lazy mode (by default).
|
||||
# If you need to have the empty groups in 'eager' mode, then the
|
||||
# method _read_group_fill_results need to be completely reimplemented
|
||||
# in a sane way
|
||||
result = self._read_group_fill_results(
|
||||
domain, groupby_fields[0], groupby[len(annotated_groupbys):],
|
||||
aggregated_fields, count_field, result, read_group_order=order,
|
||||
)
|
||||
return result
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="project_task_burndown_chart_report_view_search" model="ir.ui.view">
|
||||
<field name="name">project.task.burndown.chart.report.view.search</field>
|
||||
<field name="model">project.task.burndown.chart.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Burndown Chart">
|
||||
<field name="stage_id" />
|
||||
<field name="project_id" />
|
||||
<field name="user_ids" />
|
||||
<field name="milestone_id" groups="project.group_project_milestone"/>
|
||||
<field name="date_assign"/>
|
||||
<field name="date_deadline"/>
|
||||
<field name="partner_id" filter_domain="[('partner_id', 'child_of', self)]"/>
|
||||
<separator/>
|
||||
<filter name="filter_date" date="date" string="Date" default_period="this_year,last_year" />
|
||||
<filter name="filter_date_deadline" date="date_deadline"/>
|
||||
<filter name="filter_date_assign" date="date_assign"/>
|
||||
<filter string="Last Month" invisible="1" name="last_month" domain="[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Open tasks" name="open_tasks" domain="[('is_closed', '=', False)]"/>
|
||||
<filter string="Late Milestones" name="late_milestone" domain="[('is_closed', '=', False), ('has_late_and_unreached_milestone', '=', True)]" groups="project.group_project_milestone"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Date" name="date" context="{'group_by': 'date'}" />
|
||||
<filter string="Stage" name="stage" context="{'group_by': 'stage_id'}" invisible="1"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_task_burndown_chart_report_view_graph" model="ir.ui.view">
|
||||
<field name="name">project.task.burndown.chart.report.view.graph</field>
|
||||
<field name="model">project.task.burndown.chart.report</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Burndown Chart" type="line" sample="1" disable_linking="1" js_class="burndown_chart">
|
||||
<field name="date" string="Date" interval="month"/>
|
||||
<field name="stage_id"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_project_task_burndown_chart_report" model="ir.actions.act_window">
|
||||
<field name="name">Burndown Chart</field>
|
||||
<field name="res_model">project.task.burndown.chart.report</field>
|
||||
<field name="view_mode">graph</field>
|
||||
<field name="search_view_id" ref="project_task_burndown_chart_report_view_search"/>
|
||||
<field name="context">{'search_default_project_id': active_id, 'search_default_date': 1, 'search_default_stage': 1, 'search_default_filter_date': 1, 'search_default_open_tasks': 1}</field>
|
||||
<field name="domain">[('display_project_id', '!=', False)]</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_empty_folder">
|
||||
No data yet!
|
||||
</p>
|
||||
<p>Analyze how quickly your team is completing your project's tasks and check if everything is progressing according to plan.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Loading…
Add table
Add a link
Reference in a new issue