diff --git a/addons/project/__manifest__.py b/addons/project/__manifest__.py index faf550a88ea..cd7dfd5b0d1 100644 --- a/addons/project/__manifest__.py +++ b/addons/project/__manifest__.py @@ -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', diff --git a/addons/project/data/project_demo.xml b/addons/project/data/project_demo.xml index 38976b4c2d2..d1eb4089a2e 100644 --- a/addons/project/data/project_demo.xml +++ b/addons/project/data/project_demo.xml @@ -315,5 +315,38 @@ Send it ASAP, its urgent. + + + + + First Phase + + + + + + + Second Phase + + + + + + Final Phase + + + + + Review of the situation + + + at_risk + + + Weekly review + + + at_risk + diff --git a/addons/project/models/__init__.py b/addons/project/models/__init__.py index ff2853f7df7..3bec5e7c5a6 100644 --- a/addons/project/models/__init__.py +++ b/addons/project/models/__init__.py @@ -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 diff --git a/addons/project/models/project.py b/addons/project/models/project.py index 3ba2692ff60..cc20a6717b2 100644 --- a/addons/project/models/project.py +++ b/addons/project/models/project.py @@ -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 # --------------------------------------------------- diff --git a/addons/project/models/project_milestone.py b/addons/project/models/project_milestone.py new file mode 100644 index 00000000000..4c97081ba38 --- /dev/null +++ b/addons/project/models/project_milestone.py @@ -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] diff --git a/addons/project/models/project_update.py b/addons/project/models/project_update.py new file mode 100644 index 00000000000..2f0188c8064 --- /dev/null +++ b/addons/project/models/project_update.py @@ -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) diff --git a/addons/project/report/project_task_burndown_chart_report_views.xml b/addons/project/report/project_task_burndown_chart_report_views.xml index a79c51515bc..4185c0a55ac 100644 --- a/addons/project/report/project_task_burndown_chart_report_views.xml +++ b/addons/project/report/project_task_burndown_chart_report_views.xml @@ -17,6 +17,8 @@ + + diff --git a/addons/project/security/ir.model.access.csv b/addons/project/security/ir.model.access.csv index 8283f451708..95e624ccde0 100644 --- a/addons/project/security/ir.model.access.csv +++ b/addons/project/security/ir.model.access.csv @@ -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 diff --git a/addons/project/security/project_security.xml b/addons/project/security/project_security.xml index 17bd279422c..8e17fefeab0 100644 --- a/addons/project/security/project_security.xml +++ b/addons/project/security/project_security.xml @@ -129,5 +129,33 @@ + + Project/Updates: multi-company + + [('project_id.company_id', 'in', company_ids)] + + + + Project/Update: employees: follow required for follower-only projects + + [ + '|', + ('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) + ] + + + + + Project updates : Project user can see all project updates + + [(1, '=', 1)] + + + diff --git a/addons/project/static/src/js/widgets/html_with_action_widget.js b/addons/project/static/src/js/widgets/html_with_action_widget.js new file mode 100644 index 00000000000..19b3c659506 --- /dev/null +++ b/addons/project/static/src/js/widgets/html_with_action_widget.js @@ -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); diff --git a/addons/project/static/src/js/widgets/status_with_color_widget.js b/addons/project/static/src/js/widgets/status_with_color_widget.js new file mode 100644 index 00000000000..2006f66414a --- /dev/null +++ b/addons/project/static/src/js/widgets/status_with_color_widget.js @@ -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); diff --git a/addons/project/static/src/scss/project_form.scss b/addons/project/static/src/scss/project_form.scss index b74cc9d857c..a00a230a58e 100644 --- a/addons/project/static/src/scss/project_form.scss +++ b/addons/project/static/src/scss/project_form.scss @@ -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; + } +} diff --git a/addons/project/static/src/scss/project_widgets.scss b/addons/project/static/src/scss/project_widgets.scss new file mode 100644 index 00000000000..60498e59f9c --- /dev/null +++ b/addons/project/static/src/scss/project_widgets.scss @@ -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; +} diff --git a/addons/project/static/src/xml/project_templates.xml b/addons/project/static/src/xml/project_templates.xml index f30d8c95a0e..684128c39f2 100644 --- a/addons/project/static/src/xml/project_templates.xml +++ b/addons/project/static/src/xml/project_templates.xml @@ -14,4 +14,7 @@ + + + diff --git a/addons/project/views/project_update_templates.xml b/addons/project/views/project_update_templates.xml new file mode 100644 index 00000000000..4a7f3ead282 --- /dev/null +++ b/addons/project/views/project_update_templates.xml @@ -0,0 +1,94 @@ + + + + + + + diff --git a/addons/project/views/project_update_views.xml b/addons/project/views/project_update_views.xml new file mode 100644 index 00000000000..9f43cdc18f0 --- /dev/null +++ b/addons/project/views/project_update_views.xml @@ -0,0 +1,84 @@ + + + + project.update.view.form + project.update + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + project.milestone.view.form + project.milestone + +
+ + + + + + + + + + +
+
+
+ + + project.milestone.view.tree + project.milestone + + + + + + + + + + + + Milestones + project.milestone + tree,form + {'default_project_id': active_id} + [('project_id', '=', active_id)] + +

+ No milestones found. Let's create one! +

+ Track major progress points that must be reached to achieve success. +

+
+
+
diff --git a/addons/project/views/project_views.xml b/addons/project/views/project_views.xml index f48de48afed..23b7b616f06 100644 --- a/addons/project/views/project_views.xml +++ b/addons/project/views/project_views.xml @@ -261,6 +261,16 @@ Burndown Chart +
@@ -399,6 +409,7 @@ + @@ -419,6 +430,9 @@ + + + @@ -521,6 +535,8 @@ + +
@@ -589,13 +605,20 @@
-