Initial commit: Project packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:52 +02:00
commit 89613c97b0
753 changed files with 496325 additions and 0 deletions

View 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

View 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()))

View file

@ -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', '&lt;', context_today().strftime('%Y-%m-%d'))]"
groups="project.group_project_milestone"
/>
<filter string="Late Tasks" name="late" domain="[('date_deadline', '&lt;', context_today().strftime('%Y-%m-%d')), ('is_closed', '=', False)]"/>
<filter name="rating_satisfied" string="Satisfied" domain="[('rating_avg', '&gt;=', 3.66)]" groups="project.group_project_rating"/>
<filter name="rating_okay" string="Okay" domain="[('rating_avg', '&lt;', 3.66), ('rating_avg', '&gt;=', 2.33)]" groups="project.group_project_rating"/>
<filter name="dissatisfied" string="Dissatisfied" domain="[('rating_avg', '&lt;', 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>

View file

@ -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

View file

@ -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','&gt;=', (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>