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 @@
+
+
+
+
+ (due on )
+
+ - reached on
+
+ )
+
+
+ )
+
+
+
+
+
+
+
+
Tasks
+
+ tasks are open tasks are open, out of .
+ In the last 30 days,
+
+
were created
+
was created
+
were closed (folded stage)
+
was closed (folded stage)
+
+
+
+
+
+
+
+
Milestones
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Over the last 30 days, the deadline of the following milestones has been updated :
+ Over the last 30 days, the deadline of the following milestone has been updated :
+
+
+
+
+ ( => )
+
+
+
+
+
+
+ The following milestones have been added :
+ The following milestone has been added :
+