[IMP] project: add project updates and milestones
This commit adds project updates in project to take in stock the current status of the project : which task or milestone changed in the past 30 days. The project manager is able to add a status on the project update, and edit the description of the update. There is also a chatter in which he/she can discuss some points with other project users. The description is build with informations retrieved from mail tracking values or from project.* models. Those informations are collected in a dict and rendered in a template to ease the inheritence in further modules needs. Following commits will add behaviour to handle those updates and milestones in the various views. See PR for further information. PR : #68899 task-2393768
This commit is contained in:
@@ -29,6 +29,8 @@
|
||||
'views/analytic_views.xml',
|
||||
'views/digest_views.xml',
|
||||
'views/rating_views.xml',
|
||||
'views/project_update_views.xml',
|
||||
'views/project_update_templates.xml',
|
||||
'views/project_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
@@ -60,6 +62,7 @@
|
||||
'project/static/src/js/widgets/*',
|
||||
'project/static/src/scss/project_dashboard.scss',
|
||||
'project/static/src/scss/project_form.scss',
|
||||
'project/static/src/scss/project_widgets.scss',
|
||||
],
|
||||
'web.assets_frontend': [
|
||||
'project/static/src/scss/portal_rating.scss',
|
||||
|
||||
@@ -315,5 +315,38 @@ Send it ASAP, its urgent.</field>
|
||||
<function model="project.task" name="rating_apply" eval="([ref('project.project_task_3')], 5)"/>
|
||||
<function model="project.task" name="rating_apply" eval="([ref('project.project_task_24')], 5)"/>
|
||||
<function model="project.task" name="rating_apply" eval="([ref('project.project_task_8')], 1)"/>
|
||||
|
||||
<record id="project_milestone_1" model="project.milestone">
|
||||
<field name="is_reached" eval="True"/>
|
||||
<field name="deadline" eval="time.strftime('%Y-%m-10')"/>
|
||||
<field name="name">First Phase</field>
|
||||
<field name="reached_date" eval="time.strftime('%Y-%m-10')"/>
|
||||
<field name="project_id" ref="project.project_project_1"/>
|
||||
</record>
|
||||
<record id="project_milestone_2" model="project.milestone">
|
||||
<field name="is_reached" eval="False"/>
|
||||
<field name="deadline" eval="(DateTime.now() + relativedelta(years=1)).strftime('%Y-%m-15')"/>
|
||||
<field name="name">Second Phase</field>
|
||||
<field name="project_id" ref="project.project_project_1"/>
|
||||
</record>
|
||||
<record id="project_milestone_3" model="project.milestone">
|
||||
<field name="is_reached" eval="False"/>
|
||||
<field name="deadline" eval="(DateTime.now() + relativedelta(years=2)).strftime('%Y-%m-%d')"/>
|
||||
<field name="name">Final Phase</field>
|
||||
<field name="project_id" ref="project.project_project_1"/>
|
||||
</record>
|
||||
|
||||
<record id="project_update_1" model="project.update" context="{'default_project_id': ref('project.project_project_1')}">
|
||||
<field name="name">Review of the situation</field>
|
||||
<field name="user_id" eval="ref('base.user_demo')"/>
|
||||
<field name="progress" eval="15"/>
|
||||
<field name="status">at_risk</field>
|
||||
</record>
|
||||
<record id="project_update_2" model="project.update" context="{'default_project_id': ref('project.project_project_2')}">
|
||||
<field name="name">Weekly review</field>
|
||||
<field name="user_id" eval="ref('base.user_admin')"/>
|
||||
<field name="progress" eval="35"/>
|
||||
<field name="status">at_risk</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import analytic_account
|
||||
from . import project_milestone
|
||||
from . import project_task_recurrence
|
||||
from . import project
|
||||
from . import project_update
|
||||
from . import res_config_settings
|
||||
from . import res_partner
|
||||
from . import digest
|
||||
|
||||
@@ -12,6 +12,7 @@ from odoo.tools.misc import format_date, get_lang
|
||||
from odoo.osv.expression import OR
|
||||
|
||||
from .project_task_recurrence import DAYS, WEEKS
|
||||
from .project_update import STATUS_COLOR
|
||||
|
||||
class ProjectTaskType(models.Model):
|
||||
_name = 'project.task.type'
|
||||
@@ -248,6 +249,18 @@ class Project(models.Model):
|
||||
('quarterly', 'Quarterly'),
|
||||
('yearly', 'Yearly')], 'Rating Frequency', required=True, default='monthly')
|
||||
|
||||
update_ids = fields.One2many('project.update', 'project_id')
|
||||
last_update_id = fields.Many2one('project.update', string='Last Update')
|
||||
last_update_status = fields.Selection(selection=[
|
||||
('on_track', 'On Track'),
|
||||
('at_risk', 'At Risk'),
|
||||
('off_track', 'Off Track'),
|
||||
('on_hold', 'On Hold')
|
||||
], default='on_track', compute='_compute_last_update_status', store=True)
|
||||
last_update_color = fields.Integer(compute='_compute_last_update_color')
|
||||
milestone_ids = fields.One2many('project.milestone', 'project_id')
|
||||
milestone_count = fields.Integer(compute='_compute_milestone_count')
|
||||
|
||||
_sql_constraints = [
|
||||
('project_date_greater', 'check(date >= date_start)', 'Error! Project start date must be before project end date.')
|
||||
]
|
||||
@@ -300,6 +313,23 @@ class Project(models.Model):
|
||||
for project in self:
|
||||
project.rating_request_deadline = fields.datetime.now() + timedelta(days=periods.get(project.rating_status_period, 0))
|
||||
|
||||
@api.depends('last_update_id.status')
|
||||
def _compute_last_update_status(self):
|
||||
for project in self:
|
||||
project.last_update_status = project.last_update_id.status or 'on_track'
|
||||
|
||||
@api.depends('last_update_status')
|
||||
def _compute_last_update_color(self):
|
||||
for project in self:
|
||||
project.last_update_color = STATUS_COLOR[project.last_update_status]
|
||||
|
||||
@api.depends('milestone_ids')
|
||||
def _compute_milestone_count(self):
|
||||
read_group = self.env['project.milestone'].read_group([('project_id', 'in', self.ids)], ['project_id'], ['project_id'])
|
||||
mapped_count = {group['project_id'][0]: group['project_id_count'] for group in read_group}
|
||||
for project in self:
|
||||
project.milestone_count = mapped_count.get(project.id, 0)
|
||||
|
||||
@api.model
|
||||
def _map_tasks_default_valeus(self, task, project):
|
||||
""" get the default value for the copied task on project duplication """
|
||||
@@ -468,6 +498,24 @@ class Project(models.Model):
|
||||
action_context['search_default_project_id'] = self.id
|
||||
return dict(action, context=action_context)
|
||||
|
||||
def _get_tasks_analysis_counts(self, created=False, updated=False):
|
||||
tasks = self.env['project.task'].search([('display_project_id', '=', self.id)])
|
||||
open_tasks_count = created_tasks_count = updated_tasks_count = 0
|
||||
tasks_count = len(tasks)
|
||||
thirty_days_ago = datetime.combine(fields.Date.context_today(self) + timedelta(days=-30), datetime.min.time())
|
||||
for t in tasks:
|
||||
if not t.stage_id.fold and not t.stage_id.is_closed:
|
||||
open_tasks_count += 1
|
||||
if created and t.create_date > thirty_days_ago:
|
||||
created_tasks_count += 1
|
||||
if updated and t.write_date > thirty_days_ago:
|
||||
updated_tasks_count += 1
|
||||
return dict(
|
||||
open_tasks_count=open_tasks_count,
|
||||
created_tasks_count=created_tasks_count,
|
||||
updated_tasks_count=updated_tasks_count,
|
||||
tasks_count=tasks_count
|
||||
)
|
||||
# ---------------------------------------------------
|
||||
# Business Methods
|
||||
# ---------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
class ProjectMilestone(models.Model):
|
||||
_name = 'project.milestone'
|
||||
_description = "Project Milestone"
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'deadline, is_reached desc, name'
|
||||
|
||||
def _get_default_project_id(self):
|
||||
return self.env.context.get('default_project_id') or self.env.context.get('active_id')
|
||||
|
||||
name = fields.Char(required=True)
|
||||
project_id = fields.Many2one('project.project', required=True, default=_get_default_project_id)
|
||||
deadline = fields.Date(tracking=True)
|
||||
is_reached = fields.Boolean(string="Reached", default=False)
|
||||
reached_date = fields.Date(compute='_compute_reached_date', store=True)
|
||||
|
||||
# computed non-stored fields
|
||||
is_deadline_exceeded = fields.Boolean(compute="_compute_is_deadline_exceeded")
|
||||
is_deadline_future = fields.Boolean(compute="_compute_is_deadline_future")
|
||||
|
||||
@api.depends('is_reached')
|
||||
def _compute_reached_date(self):
|
||||
for ms in self:
|
||||
ms.reached_date = ms.is_reached and fields.Date.context_today(self)
|
||||
|
||||
@api.depends('is_reached', 'deadline')
|
||||
def _compute_is_deadline_exceeded(self):
|
||||
today = fields.Date.context_today(self)
|
||||
for ms in self:
|
||||
ms.is_deadline_exceeded = not ms.is_reached and ms.deadline and ms.deadline < today
|
||||
|
||||
@api.depends('deadline')
|
||||
def _compute_is_deadline_future(self):
|
||||
for ms in self:
|
||||
ms.is_deadline_future = ms.deadline and ms.deadline > fields.Date.context_today(self)
|
||||
|
||||
@api.model
|
||||
def _get_fields_to_export(self):
|
||||
return ['id', 'name', 'deadline', 'is_reached', 'reached_date', 'is_deadline_exceeded', 'is_deadline_future']
|
||||
|
||||
def _get_data(self):
|
||||
self.ensure_one()
|
||||
return {field: self[field] for field in self._get_fields_to_export()}
|
||||
|
||||
def _get_data_list(self):
|
||||
return [ms._get_data() for ms in self]
|
||||
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from werkzeug.urls import url_encode
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
STATUS_COLOR = {
|
||||
'on_track': 10, # green
|
||||
'at_risk': 2, # orange
|
||||
'off_track': 1, # red
|
||||
'on_hold': 4, # light blue
|
||||
}
|
||||
|
||||
class ProjectUpdate(models.Model):
|
||||
_name = 'project.update'
|
||||
_description = 'Project Update'
|
||||
_order = 'create_date desc'
|
||||
_inherit = ['mail.thread.cc', 'mail.activity.mixin']
|
||||
|
||||
def default_get(self, fields):
|
||||
result = super().default_get(fields)
|
||||
if 'project_id' in fields and not result.get('project_id'):
|
||||
result['project_id'] = self.env.context.get('active_id')
|
||||
if result.get('project_id'):
|
||||
project = self.env['project.project'].browse(result['project_id'])
|
||||
if 'progress' in fields and not result.get('progress'):
|
||||
result['progress'] = project.last_update_id.progress
|
||||
if 'description' in fields and not result.get('description'):
|
||||
result['description'] = self._build_description(project)
|
||||
if 'status' in fields and not result.get('status'):
|
||||
result['status'] = project.last_update_status
|
||||
return result
|
||||
|
||||
name = fields.Char("Title", required=True, tracking=True)
|
||||
status = fields.Selection(selection=[
|
||||
('on_track', 'On Track'),
|
||||
('at_risk', 'At Risk'),
|
||||
('off_track', 'Off Track'),
|
||||
('on_hold', 'On Hold')
|
||||
], required=True, tracking=True)
|
||||
color = fields.Integer(compute='_compute_color')
|
||||
progress = fields.Integer(tracking=True)
|
||||
progress_percentage = fields.Float(compute='_compute_progress_percentage')
|
||||
user_id = fields.Many2one('res.users', string='Author', required=True, default=lambda self: self.env.user)
|
||||
description = fields.Html()
|
||||
date = fields.Date(default=fields.Date.context_today, tracking=True)
|
||||
project_id = fields.Many2one('project.project', required=True)
|
||||
|
||||
@api.depends('status')
|
||||
def _compute_color(self):
|
||||
for update in self:
|
||||
update.color = STATUS_COLOR[update.status]
|
||||
|
||||
@api.depends('progress')
|
||||
def _compute_progress_percentage(self):
|
||||
for u in self:
|
||||
u.progress_percentage = u.progress / 100
|
||||
|
||||
# ---------------------------------
|
||||
# ORM Override
|
||||
# ---------------------------------
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
update = super().create(vals)
|
||||
update.project_id.last_update_id = update
|
||||
return update
|
||||
|
||||
# ---------------------------------
|
||||
# Build default description
|
||||
# ---------------------------------
|
||||
@api.model
|
||||
def _build_description(self, project):
|
||||
template = self.env.ref('project.project_update_default_description')
|
||||
return template._render(self._get_template_values(project), engine='ir.qweb')
|
||||
|
||||
@api.model
|
||||
def _get_template_values(self, project):
|
||||
return {
|
||||
'user': self.env.user,
|
||||
'project': project,
|
||||
'tasks': self._get_tasks_values(project),
|
||||
'milestones': self._get_milestone_values(project)
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_tasks_values(self, project):
|
||||
counts = project._get_tasks_analysis_counts(created=True)
|
||||
return {
|
||||
'open_tasks': counts['open_tasks_count'],
|
||||
'total_tasks': counts['tasks_count'],
|
||||
'created_tasks': counts['created_tasks_count'],
|
||||
'closed_tasks': self._get_last_stage_changes(project=project),
|
||||
'action': {
|
||||
'url_default': '/web#' + url_encode({
|
||||
'menu_id': self.env.ref('project.menu_projects').id,
|
||||
'action': self.env.ref('project.action_project_task_burndown_chart_report').id,
|
||||
'active_id': project.id
|
||||
}),
|
||||
'name': 'action_burndown_open_tasks',
|
||||
}
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_last_stage_changes(self, project):
|
||||
query = """
|
||||
SELECT DISTINCT pt.id as task_id
|
||||
FROM mail_message mm
|
||||
INNER JOIN mail_tracking_value mtv
|
||||
ON mm.id = mtv.mail_message_id
|
||||
INNER JOIN ir_model_fields imf
|
||||
ON mtv.field = imf.id
|
||||
AND imf.model = 'project.task'
|
||||
AND imf.name = 'stage_id'
|
||||
INNER JOIN project_task_type new_stage
|
||||
ON mtv.new_value_integer = new_stage.id
|
||||
INNER JOIN project_task pt
|
||||
ON mm.res_id = pt.id
|
||||
AND pt.stage_id = new_stage.id
|
||||
WHERE mm.model = 'project.task'
|
||||
AND mm.message_type = 'notification'
|
||||
AND pt.display_project_id = %(project_id)s
|
||||
AND (new_stage.fold OR new_stage.is_closed)
|
||||
AND mm.date > (now() at time zone 'utc')::date - '1 month'::interval
|
||||
AND pt.active
|
||||
"""
|
||||
self.env.cr.execute(query, {'project_id': project.id})
|
||||
task_ids = [res['task_id'] for res in self.env.cr.dictfetchall()]
|
||||
return self.env['project.task'].search_count([('id', 'in', task_ids)])
|
||||
|
||||
@api.model
|
||||
def _get_milestone_values(self, project):
|
||||
Milestone = self.env['project.milestone']
|
||||
list_milestones = Milestone.search(
|
||||
[('project_id', '=', project.id),
|
||||
'|', ('deadline', '<', fields.Date.context_today(self) + relativedelta(years=1)), ('deadline', '=', False)])._get_data_list()
|
||||
updated_milestones = self._get_last_updated_milestone(project)
|
||||
created_milestones = Milestone.search(
|
||||
[('project_id', '=', project.id),
|
||||
('create_date', '>', fields.Datetime.now() + timedelta(days=-30))])._get_data_list()
|
||||
return {
|
||||
'show_section': (list_milestones or updated_milestones or created_milestones) and True or False,
|
||||
'list': list_milestones,
|
||||
'updated': updated_milestones,
|
||||
'created': created_milestones,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_last_updated_milestone(self, project):
|
||||
query = """
|
||||
SELECT DISTINCT pm.id as milestone_id,
|
||||
pm.deadline as deadline,
|
||||
FIRST_VALUE(old_value_datetime::date) OVER w_partition as old_value,
|
||||
pm.deadline as new_value
|
||||
FROM mail_message mm
|
||||
INNER JOIN mail_tracking_value mtv
|
||||
ON mm.id = mtv.mail_message_id
|
||||
INNER JOIN ir_model_fields imf
|
||||
ON mtv.field = imf.id
|
||||
AND imf.model = 'project.milestone'
|
||||
AND imf.name = 'deadline'
|
||||
INNER JOIN project_milestone pm
|
||||
ON mm.res_id = pm.id
|
||||
WHERE mm.model = 'project.milestone'
|
||||
AND mm.message_type = 'notification'
|
||||
AND pm.project_id = %(project_id)s
|
||||
AND mm.date > (now() at time zone 'utc')::date - '1 month'::interval
|
||||
WINDOW w_partition AS (
|
||||
PARTITION BY pm.id
|
||||
ORDER BY mm.date ASC
|
||||
)
|
||||
ORDER BY pm.deadline ASC;
|
||||
"""
|
||||
self.env.cr.execute(query, {'project_id': project.id})
|
||||
results = self.env.cr.dictfetchall()
|
||||
mapped_result = {res['milestone_id']: {'new_value': res['new_value'], 'old_value': res['old_value']} for res in results}
|
||||
milestones = self.env['project.milestone'].search([('id', 'in', list(mapped_result.keys()))])
|
||||
return [{
|
||||
**milestone._get_data(),
|
||||
'new_value': mapped_result[milestone.id]['new_value'],
|
||||
'old_value': mapped_result[milestone.id]['old_value'],
|
||||
} for milestone in milestones]
|
||||
|
||||
def action_burndown_open_tasks(self):
|
||||
if not self.id or not self.project_id:
|
||||
return False
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('project.action_project_task_burndown_chart_report')
|
||||
context = {
|
||||
'active_id': self.project_id.id,
|
||||
'search_default_project_id': self.project_id.id,
|
||||
'graph_mode': 'bar',
|
||||
}
|
||||
return dict(action, context=context)
|
||||
@@ -17,6 +17,8 @@
|
||||
<filter name="filter_date" date="date" string="Date" />
|
||||
<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="[('stage_id.fold', '=', False), ('stage_id.is_closed', '=', False)]"/>
|
||||
<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'}" />
|
||||
|
||||
@@ -26,3 +26,11 @@ access_project_delete_wizard,access_project_delete_wizard,model_project_delete_w
|
||||
access_project_task_type_delete_wizard,project.task.type.delete.wizard,model_project_task_type_delete_wizard,project.group_project_manager,1,1,1,1
|
||||
access_project_task_recurrence,project.task.recurrence,model_project_task_recurrence,project.group_project_user,1,1,1,1
|
||||
project.access_project_task_burndown_chart_report,access_project_task_burndown_chart_report,project.model_project_task_burndown_chart_report,project.group_project_manager,1,1,1,1
|
||||
access_project_update_user,project.update.user,model_project_update,base.group_user,1,0,0,0
|
||||
access_project_update_portal,project.update.portal,model_project_update,base.group_portal,1,0,0,0
|
||||
access_project_update_project_user,project.update.project.user,model_project_update,project.group_project_user,1,0,0,0
|
||||
access_project_update_project_manager,project.update.project.manager,model_project_update,project.group_project_manager,1,1,1,1
|
||||
access_project_milestone_user,project.milestone.user,model_project_milestone,base.group_user,1,0,0,0
|
||||
access_project_milestone_portal,project.milestone.portal,model_project_milestone,base.group_portal,1,0,0,0
|
||||
access_project_milestone_project_user,project.milestone.project.user,model_project_milestone,project.group_project_user,1,0,0,0
|
||||
access_project_milestone_project_manager,project.milestone.project.manager,model_project_milestone,project.group_project_manager,1,1,1,1
|
||||
|
||||
|
@@ -129,5 +129,33 @@
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.rule" id="update_comp_rule">
|
||||
<field name="name">Project/Updates: multi-company</field>
|
||||
<field name="model_id" ref="model_project_update"/>
|
||||
<field name="domain_force">[('project_id.company_id', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.rule" id="update_visibility_rule">
|
||||
<field name="name">Project/Update: employees: follow required for follower-only projects</field>
|
||||
<field name="model_id" ref="model_project_update"/>
|
||||
<field name="domain_force">[
|
||||
'|',
|
||||
('project_id.privacy_visibility', '!=', 'followers'),
|
||||
'|',
|
||||
('project_id.message_partner_ids', 'in', [user.partner_id.id]),
|
||||
'|',
|
||||
('user_id', '=', user.id),
|
||||
('project_id.user_id', '=', user.id)
|
||||
]</field>
|
||||
<field name="groups" eval="[(4,ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="update_visibility_project_admin" model="ir.rule">
|
||||
<field name="name">Project updates : Project user can see all project updates</field>
|
||||
<field name="model_id" ref="project.model_project_update"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4,ref('project.group_project_manager'))]"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { device } from 'web.config';
|
||||
import { bus } from 'web.core';
|
||||
import fieldRegistry from 'web.field_registry';
|
||||
import FieldHtml from 'web_editor.field.html';
|
||||
|
||||
export const FieldHtmlWithAction = FieldHtml.extend({
|
||||
events: Object.assign({}, FieldHtml.prototype.events, {
|
||||
'click a[type="object"]': '_onClickObject'
|
||||
}),
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
if (!device.isMobile) {
|
||||
bus.on("DOM_updated", this, () => {
|
||||
const $editable = this.$el.find('.note-editable');
|
||||
if ($editable.length) {
|
||||
const resizerHeight = this.$el.find('.o_wysiwyg_resizer').outerHeight();
|
||||
const newHeight = window.innerHeight - $editable.offset().top - resizerHeight - 1;
|
||||
$editable.outerHeight(newHeight);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handle the click on a link with object as type
|
||||
* @param {*} event
|
||||
*/
|
||||
_onClickObject: async function (event) {
|
||||
event.preventDefault();
|
||||
const action = await this._rpc({
|
||||
model: this.model,
|
||||
method: event.target.name,
|
||||
args: [this.res_id],
|
||||
context: this.record.context,
|
||||
});
|
||||
if (action) {
|
||||
this.do_action(action);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fieldRegistry.add('html_with_action', FieldHtmlWithAction);
|
||||
@@ -0,0 +1,39 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { qweb } from 'web.core';
|
||||
import fieldRegistry from 'web.field_registry';
|
||||
import { FieldSelection } from 'web.relational_fields';
|
||||
|
||||
/**
|
||||
* options :
|
||||
* `color_field` : The field that must be use to color the bubble. It must be in the view. (from 0 to 11). Default : grey.
|
||||
*/
|
||||
export const StatusWithColor = FieldSelection.extend({
|
||||
_template: 'project.statusWithColor',
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.color = this.recordData[this.nodeOptions.color_field];
|
||||
if (this.nodeOptions.no_quick_edit) {
|
||||
this._canQuickEdit = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_renderReadonly() {
|
||||
this._super.apply(this, arguments);
|
||||
if (this.value) {
|
||||
this.$el.addClass('o_status_with_color');
|
||||
this.$el.prepend(qweb.render(this._template, {
|
||||
color: this.color,
|
||||
}));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
fieldRegistry.add('status_with_color', StatusWithColor);
|
||||
@@ -9,3 +9,28 @@
|
||||
.o_form_view.o_form_project_tasks .o_notebook > .tab-content > .tab-pane > :first-child.oe_form_field_html .o_readonly {
|
||||
padding: $o-horizontal-padding $o-horizontal-padding;
|
||||
}
|
||||
|
||||
.o_form_view.o_form_project_update:not(.o_form_readonly), .o_form_view.o_form_project_update:not(.o_form_readonly) .o_form_sheet {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.o_project_update_description {
|
||||
.note-editable, .o_wysiwyg_resizer {
|
||||
border: 0;
|
||||
}
|
||||
&.oe_form_field_html, .o_wysiwyg_wrapper {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
&.oe_form_field_html .o_readonly {
|
||||
padding: $o-horizontal-padding $o-horizontal-padding;
|
||||
}
|
||||
}
|
||||
|
||||
.o_form_view .btn.oe_stat_button.o_project_not_clickable {
|
||||
&:hover, &:focus {
|
||||
background-color: transparent;
|
||||
opacity: 0.8;
|
||||
color: $o-main-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
@mixin o-background-color {
|
||||
@for $size from 2 through length($o-colors) {
|
||||
// Note: the first color is supposed to be invisible so it's ignored
|
||||
&.o_color_bubble_#{$size - 1} {
|
||||
background-color: nth($o-colors, $size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_status_bubble {
|
||||
@extend .o_status;
|
||||
margin-right: 0.5rem;
|
||||
border-radius: 50%;
|
||||
@include o-background-color;
|
||||
}
|
||||
|
||||
.o_status_with_color {
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
&.o_field_widget {
|
||||
span {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_project_update_description a[type="object"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -14,4 +14,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
|
||||
<span t-name="project.statusWithColor" t-att-class="'o_status_bubble o_color_bubble_' + color"></span>
|
||||
|
||||
</templates>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<template id="project.milestone_deadline">
|
||||
<t t-if="milestone['deadline']">
|
||||
(due on <t t-esc="milestone['deadline']" t-options='{"widget": "date"}'/><t t-if="not milestone['is_reached'] or not milestone['reached_date']">)</t>
|
||||
<t t-else="">
|
||||
- reached on
|
||||
<t t-if="milestone['reached_date'] > milestone['deadline']">
|
||||
<font t-att-style="'color: rgb(' + str(color_level) + ', 0, 0)'"><b><t t-esc="milestone['reached_date']" t-options='{"widget": "date"}'/></b></font>)
|
||||
</t>
|
||||
<t t-else="">
|
||||
<font t-att-style="'color: rgb(0, ' + str(color_level) + ', 0)'"><b><t t-esc="milestone['reached_date']" t-options='{"widget": "date"}'/></b></font>)
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="project_update_default_description" name="Project Update Description">
|
||||
<div name="tasks">
|
||||
<h1>Tasks</h1>
|
||||
|
||||
<a t-if="user.has_group('project.group_project_manager')" type="object" t-att-href="tasks['action']['url_default']" t-att-name="tasks['action']['name']"><t t-esc="tasks['open_tasks']"/> tasks are open</a><t t-else=""><t t-esc="tasks['open_tasks']"/> tasks are open</t>, out of <t t-esc="tasks['total_tasks']"/>.
|
||||
In the last 30 days,
|
||||
<ul>
|
||||
<li t-if="tasks['created_tasks'] > 1"><t t-esc="tasks['created_tasks']"/> were created</li>
|
||||
<li t-else=""><t t-esc="tasks['created_tasks']"/> was created</li>
|
||||
<li t-if="tasks['closed_tasks'] > 1"><t t-esc="tasks['closed_tasks']"/> were closed (folded stage)</li>
|
||||
<li t-else=""><t t-esc="tasks['closed_tasks']"/> was closed (folded stage)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<hr t-if="milestones['show_section']"/>
|
||||
|
||||
<div name="milestone">
|
||||
<t t-if="milestones['show_section']">
|
||||
<h1>Milestones</h1>
|
||||
|
||||
<ul class="fa-ul" t-if="milestones['list']">
|
||||
<t t-foreach="milestones['list']" t-as="milestone" t-key="milestone['id']">
|
||||
<li><span class="fa-li"><i t-attf-class="fa {{milestone['is_reached'] and 'fa-check-square' or 'fa-square-o'}}"></i></span>
|
||||
<t t-esc="milestone['name']"/>
|
||||
<span t-if="milestone['is_deadline_future'] and not milestone['is_reached']">
|
||||
<font style="color: rgb(190, 190, 190);">
|
||||
<t t-set="color_level" t-value="64"/>
|
||||
<t t-call="project.milestone_deadline"/>
|
||||
</font>
|
||||
</span>
|
||||
<span t-else="">
|
||||
<t t-set="color_level" t-value="128"/>
|
||||
<t t-call="project.milestone_deadline"/>
|
||||
</span>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
|
||||
<t t-if="milestones['updated']">
|
||||
<t t-if="len(milestones['updated']) > 1">Over the last 30 days, the deadline of the following milestones has been updated :</t>
|
||||
<t t-else="">Over the last 30 days, the deadline of the following milestone has been updated :</t>
|
||||
<ul>
|
||||
<t t-foreach="milestones['updated']" t-as="milestone" t-key="milestone['id']">
|
||||
<li>
|
||||
<t t-esc="milestone['name']"/>
|
||||
(<t t-esc="milestone['old_value']" t-options='{"widget": "date"}'/> => <t t-esc="milestone['new_value']" t-options='{"widget": "date"}'/>)
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
|
||||
<t t-if="milestones['created']">
|
||||
<t t-if="len(milestones['created']) > 1">The following milestones have been added :</t>
|
||||
<t t-else="">The following milestone has been added :</t>
|
||||
<ul>
|
||||
<t t-foreach="milestones['created']" t-as="milestone" t-key="milestone['id']">
|
||||
<li>
|
||||
<t t-esc="milestone['name']"/>
|
||||
<span t-if="milestone['is_deadline_future'] and not milestone['is_reached']">
|
||||
<font style="color: rgb(190, 190, 190);">
|
||||
<t t-set="color_level" t-value="64"/>
|
||||
<t t-call="project.milestone_deadline"/>
|
||||
</font>
|
||||
</span>
|
||||
<span t-else="">
|
||||
<t t-set="color_level" t-value="128"/>
|
||||
<t t-call="project.milestone_deadline"/>
|
||||
</span>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="project_update_view_form" model="ir.ui.view">
|
||||
<field name="name">project.update.view.form</field>
|
||||
<field name="model">project.update</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Project Update" class="o_form_project_update">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" class="o_text_overflow" placeholder="Title of the Update"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="project_id" invisible="1"/>
|
||||
<field name="color" invisible="1"/>
|
||||
<field name="status" widget="status_with_color" options="{'color_field': 'color'}"/>
|
||||
<field name="progress" widget="progressbar" options="{'current_value': 'progress', 'max_value': '100', 'editable': true}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="user_id" widget="many2one_avatar_user" readonly="1"/>
|
||||
<field name="date"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator/>
|
||||
<field name="description" widget="html_with_action" nolabel="1" class="o_project_update_description"/>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" options="{'post_refresh':True}" groups="base.group_user"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_milestone_view_form" model="ir.ui.view">
|
||||
<field name="name">project.milestone.view.form</field>
|
||||
<field name="model">project.milestone</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="project_id" invisible="1"/>
|
||||
<field name="name"/>
|
||||
<field name="deadline"/>
|
||||
<field name="is_reached"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_milestone_view_tree" model="ir.ui.view">
|
||||
<field name="name">project.milestone.view.tree</field>
|
||||
<field name="model">project.milestone</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree decoration-danger="is_deadline_exceeded" decoration-muted="is_reached" editable="bottom">
|
||||
<field name="name"/>
|
||||
<field name="deadline" optional="show"/>
|
||||
<field name="is_reached" optional="show"/>
|
||||
<field name="is_deadline_exceeded" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_milestone_all" model="ir.actions.act_window">
|
||||
<field name="name">Milestones</field>
|
||||
<field name="res_model">project.milestone</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="context">{'default_project_id': active_id}</field>
|
||||
<field name="domain">[('project_id', '=', active_id)]</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No milestones found. Let's create one!
|
||||
</p><p>
|
||||
Track major progress points that must be reached to achieve success.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -261,6 +261,16 @@
|
||||
Burndown Chart
|
||||
</span>
|
||||
</button>
|
||||
<button class="oe_stat_button" name="%(project.project_milestone_all)d" type="action" icon="fa-check-square-o">
|
||||
<div class="o_field_widget o_stat_info">
|
||||
<span class="o_stat_value">
|
||||
<field name="milestone_count" nolabel="1"/>
|
||||
</span>
|
||||
<span class="o_stat_text">
|
||||
Milestones
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
|
||||
<div class="oe_title">
|
||||
@@ -399,6 +409,7 @@
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Project Manager" name="Manager" context="{'group_by': 'user_id'}"/>
|
||||
<filter string="Customer" name="Partner" context="{'group_by': 'partner_id'}"/>
|
||||
<filter string="Status" name="status" context="{'group_by': 'last_update_status'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
@@ -419,6 +430,9 @@
|
||||
<field name="privacy_visibility" optional="hide"/>
|
||||
<field name="label_tasks" optional="hide"/>
|
||||
<field name="company_id" optional="show" groups="base.group_multi_company"/>
|
||||
<field name="date" string="Deadline"/>
|
||||
<field name="last_update_color" invisible="1"/>
|
||||
<field name="last_update_status" string="Status" optional="show" widget="status_with_color" options="{'color_field': 'last_update_color'}"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
@@ -521,6 +535,8 @@
|
||||
<field name="date"/>
|
||||
<field name="doc_count"/>
|
||||
<field name="privacy_visibility"/>
|
||||
<field name="last_update_color"/>
|
||||
<field name="last_update_status"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div t-attf-class="#{kanban_color(record.color.raw_value)} oe_kanban_global_click o_has_icon">
|
||||
@@ -589,13 +605,20 @@
|
||||
<span class="o_right"><field name="is_favorite" widget="boolean_favorite" nolabel="1" force_save="1" /></span>
|
||||
</div>
|
||||
|
||||
<div class="o_project_kanban_boxes">
|
||||
<a class="o_project_kanban_box" name="action_view_tasks" type="object">
|
||||
<div>
|
||||
<span class="o_value"><t t-esc="record.task_count.value"/></span>
|
||||
<span class="o_label"><t t-esc="record.label_tasks.value"/></span>
|
||||
<div class="o_kanban_record_bottom">
|
||||
<div class="oe_kanban_bottom_left">
|
||||
<div class="o_project_kanban_boxes">
|
||||
<a class="o_project_kanban_box" name="action_view_tasks" type="object">
|
||||
<div>
|
||||
<span class="o_value"><t t-esc="record.task_count.value"/></span>
|
||||
<span class="o_label"><t t-esc="record.label_tasks.value"/></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="oe_kanban_bottom_right">
|
||||
<span t-att-class="'o_status_bubble mx-0 o_color_bubble_' + record.last_update_color.value" t-att-title="record.last_update_status.value"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
Reference in New Issue
Block a user